From f66e3671a71fbbb9369a6dd7f32618ee4c038d75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 23:21:23 +0000 Subject: [PATCH] fix(spec): `InboxListResult.unreadCount` stops documenting the window count it stopped being (#6438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `INotificationService` is a published contract — its JSDoc ships in the `.d.ts` and is the sentence a TS SDK consumer reads in their editor. The `unreadCount` member said `Unread count over the returned window.`, which recorded the implementation as it was BEFORE #6363. After #6363 (Option A, maintainer ruling 2026-08-07; PR #6439, merged as `17d095413`) `service-messaging` counts the TOTAL unread across the user's whole matching inbox and the window bounds `notifications[]` only. The wire declaration in the same package had said so all along — `ListNotificationsResponseSchema.unreadCount.describe('Total number of unread notifications')` (`api/protocol.zod.ts:924`) — so one package carried two opposite sentences about one field, the implementation standing on the `.describe()` side, and this JSDoc was the last statement of the retired semantics anywhere in the repo (`grep -rn 'returned window'` over `**/*.ts` now returns nothing but #6363's own changeset describing the fix). Left alone it is the sentence that teaches the bug back: a consumer told the number is "over the returned window" writes exactly the adaptation #6363 exists to delete — counting `notifications` themselves, or clamping the badge to the page size. That holds double for AI-written consumers, which are generated from this JSDoc and nothing else. Both members are documented, because after #6363 their bounds differ ON PURPOSE and the interface had never written that difference down: * `notifications` — the `limit`-bounded window, one page, implementations may clamp (matching `InboxQuery.limit`'s own existing wording). * `unreadCount` — the total across the whole matching inbox, explicitly NOT the window, with the "do not re-derive, do not clamp" consequence spelled out, plus one clause saying `InboxQuery.read` does not zero it. Both in-repo implementations already agree with that clause: `messaging-service.ts` states it, and the contract test's own fake computes `all.filter(r => !r.read)` over the unfiltered set, not over the sliced window. Text only. No schema, no value, no behavior: every input that validated before validates byte-for-byte after, and no generated artifact moves — the reference docs render from Zod `.describe()` strings, none of which this touches. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk --- .../inbox-list-result-unread-count-jsdoc.md | 37 +++++++++++++++++++ .../src/contracts/notification-service.ts | 28 +++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/inbox-list-result-unread-count-jsdoc.md diff --git a/.changeset/inbox-list-result-unread-count-jsdoc.md b/.changeset/inbox-list-result-unread-count-jsdoc.md new file mode 100644 index 0000000000..002c2bbc2d --- /dev/null +++ b/.changeset/inbox-list-result-unread-count-jsdoc.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `InboxListResult.unreadCount` no longer documents the window count it stopped being (#6438) + +`INotificationService` is a published contract — its JSDoc ships in the `.d.ts` and is +the sentence a TS SDK consumer reads in their editor. The `unreadCount` member said: + +> Unread count over the returned window. + +That recorded the implementation as it was *before* #6363. After #6363 (Option A, +maintainer ruling 2026-08-07; PR #6439, merged as `17d095413`) `service-messaging` +counts the **total** unread across the user's whole matching inbox, and the window +bounds `notifications[]` only. The wire declaration one directory over had already said +the same thing all along — +`ListNotificationsResponseSchema.unreadCount.describe('Total number of unread +notifications')` (`api/protocol.zod.ts`) — so one package carried two opposite sentences +about one field, with the implementation standing on the `.describe()` side and this +JSDoc the last statement of the retired semantics. + +Left alone, it is the sentence that teaches the bug back. A consumer told the number is +"over the returned window" writes exactly the adaptation #6363 exists to delete: counting +`notifications` themselves, or clamping the badge to the page size. That holds double for +AI-written consumers, which are generated from this JSDoc and nothing else. + +Both members are now documented, because after #6363 their bounds differ **on purpose** +and the interface had never written that difference down anywhere: + +* `notifications` — the `limit`-bounded window, one page, implementations may clamp. +* `unreadCount` — the total across the whole matching inbox, explicitly NOT the window, + with the "do not re-derive, do not clamp" consequence spelled out for consumers. + +Text only. No schema, no value, no behavior: every input that validated before validates +byte-for-byte after, and the generated artifacts (`check:docs`, `check:authorable-surface`, +`check:skill-refs`, `check:api-surface`) are unchanged — the reference docs render from +Zod `.describe()` strings, none of which this touches. diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts index 47fb62af65..719b10b1c1 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -96,10 +96,34 @@ export interface InboxNotification { createdAt: string; } -/** Result of {@link INotificationService.listInbox}. */ +/** + * Result of {@link INotificationService.listInbox}. + * + * Its two members carry deliberately DIFFERENT bounds (#6363): `notifications` + * is the requested page, `unreadCount` is the whole matching inbox. + */ export interface InboxListResult { + /** + * The `limit`-bounded window — at most {@link InboxQuery.limit} rows + * (implementations may clamp), newest first. One page of the inbox, not + * the whole of it. + */ notifications: InboxNotification[]; - /** Unread count over the returned window. */ + /** + * Total unread across the user's whole matching inbox — NOT the window + * above (#6363). The same quantity the wire contract publishes as + * `ListNotificationsResponseSchema.unreadCount` ("Total number of unread + * notifications", `api/protocol.zod.ts`). + * + * This is the number a bell badge shows, so it must not saturate at the + * page size: do not re-derive it by counting `notifications`, and do not + * clamp it to `notifications.length`. Counting over the window is exactly + * the defect #6363 fixed — a user with 60 unread was told 50, and + * `?limit=10` told them 10. + * + * {@link InboxQuery.read} does not zero it either: asking for the READ half + * of an inbox is not a claim that nothing is unread. + */ unreadCount: number; }