fix(spec): InboxListResult.unreadCount stops documenting the window count it stopped being (#6438) - #6463
Conversation
… 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.`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
|
Draft/ready churn on this PR, resolved — for the record, so nobody hunts a phantom automation:
Process lesson adopted on the seat (recorded in the seat post): the PM's early-review ready-flip now waits for the dev's final report (or a confirmed-dead dev) — flipping while a dev is alive-and-unreported races its wrap-up, and this PR is the specimen. Generated by Claude Code |
Fixes #6438
The contradiction, and which side was wrong
INotificationServiceis a published contract: this JSDoc ships in the.d.tsand is the sentence a TS SDK consumer reads in their editor. TheunreadCountmember said:That recorded the implementation as it stood before #6363. After #6363 (Option A, maintainer ruling 2026-08-07; PR #6439, merged to
mainas17d095413),service-messagingcounts the total unread across the user's whole matching inbox, and the window boundsnotifications[]only. The wire declaration one directory over had been saying that all along:So one package carried two opposite sentences about one field, with the implementation standing on the
.describe()side — and this JSDoc was the last statement of the retired semantics left in the repo. Verified rather than assumed:grep -rn "returned window"over all*.ts/*.md/*.mdxnow matches nothing but #6363's own changeset, where the phrase correctly describes what was fixed.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 — re-counting
notificationsthemselves, or clamping the badge to the page size. On the anti-AI-error axis it is the same point: an AI-written consumer is generated from this JSDoc and nothing else.What changed
Both members are now documented, because after #6363 their bounds differ on purpose and the interface had never written that difference down anywhere:
notifications— thelimit-bounded window; one page, implementations may clamp. Worded to matchInboxQuery.limit's own existing "Implementations may clamp", so the contract does not bakeservice-messaging's default 50 / cap 200 into a surface every provider must honour.unreadCount— the total across the whole matching inbox, explicitly NOT the window, anchored to the.describe()text, with the consequence spelled out for consumers ("do not re-derive by countingnotifications, do not clamp tonotifications.length") and the measured notification 响应侧:unreadCount声明「总未读数」实测只数 limit 窗口内;响应cursor从无 producer 发出 #6363 symptom kept as the reason.One clause beyond the two sentences the card scoped:
InboxQuery.readdoes not zero the count. Reason it earned its place — "whole matching inbox" alone is ambiguous underread: true, and both in-repo implementations already agree on the answer, so this states an existing fact rather than declaring a new obligation:messaging-service.tssays it outright ("asking for the read half of the inbox does not mean the badge is zero"), and the contract test's own fake computesall.filter((r) => !r.read).lengthover the unfiltered set, not over the sliced window.Deliberately not promoted to the contract: how a
typefilter interacts with the count. That is stated at the implementation level by #6363 and is a per-implementation decision; promoting it here would be a new contract clause, not a truth repair.Scope
Text only — no schema, no value, no behavior. Every input that validated before validates byte-for-byte after. Nothing request-side (
cursor/limitparsing, #6361), no response-sidecursorremoval, no.zod.tsbyte, nocontent/docs/releases/byte.Changeset
Carried,
@objectstack/spec: patch— not skipped. This JSDoc ships in the published.d.ts, so it is user-visible in the only sense that matters for a contract package, and it matches the precedent of #6364 (b70e534cc), a JSDoc-truth flip in the same package that carried apatchchangeset for the same reason.Verification
Premise re-verified against fresh
origin/mainbefore implementing (the issue is a lead, not a spec) — all three anchors held: #6439 merged (17d095413, the count is now a reverse join over the whole matching inbox),protocol.zod.ts:924already reads'Total number of unread notifications', and the stale JSDoc was still present atnotification-service.ts:102.Gate list enumerated from
.github/workflows/lint.yml, not from memory — everycheck:*step of both jobs, run one by one:lint,check:slot-lookup,check:query-options-erasure,check:nul-bytes,check:doc-authoring,check:docs-audit-scope,check:role-word,check:quick-reference-counts,check:adr-anchors,check:org-identifier,check:authz-resolver,check:service-providers,check:route-envelope,check:error-code-casing,check:wildcard-fallthrough,check:meta-type-normalized,check:init-service-contract,check:durability-log-level,check:startup-registry-verdict,check:objectui-changeset,check:release-notes,check:release-body,check:node-version,check:workflow-status-functions,check:shard-attestation,check:published-files,check:engine-double-contract,check:resume-authority-declared,check:merge-driver,check:spec-parsed-alias.check:type-check-coverage,check:driver-conformance,check:stall-guard,tsc --noEmiton spec,check:generated --reconcile-only,check:skill-docs,check:spec-changes,check:upgrade-guide,check:authorable-surface,check:docs,check:skill-refs,check:skill-frame-sync,check:skill-compatibility,check:react-blocks, workspace build, workspace typecheck,check:type-check-debt, examples typecheck, downstream-contract typecheck,check:api-surface,check:exported-any,check:dual-source-exports,check:skill-examples,check:doc-formula-expressions,check:i18n,check:i18n-coverage.check:i18nandcheck:i18n-coveragefirst refused to measure ("PREREQUISITE NOT MET — the workspace CLI is not built", then a chain of unbuilt connector packages). Both are prerequisite refusals, not verdicts — the scripts say so explicitly and warn that piping them reads green either way — so they were made measurable by running CI's own workspace build rather than reported as unmeasured. Final:check-i18n-bundles: OK (9 package(s) — all bundles in sync)andcheck-i18n-coverage: OK (12 config(s), 660 baselined untranslated string(s), none new), both withREAL_EXIT=0captured unpiped.Tests:
pnpm --workspace-concurrency=2 --filter @objectstack/spec test— 339 files / 8679 tests passed.No pin test, stated plainly rather than manufactured. Interface JSDoc has no runtime surface — there is nothing a test can read, so a "pin" here could only assert the file's own text back at itself. The semantics this prose describes are already pinned where they are executable: #6363's service tests and wire-route property test, plus the
.describe()string. The repo's JSDoc-pinning precedents (e.g.object-strictness-batch20.test.tsfrom #6423) pin Zod.describe()values, which are runtime data; this file has none.Reverse verification does not apply here, and saying so beats inventing it. There is no diagnostic that can change direction: reverting the diff restores the false sentence and every gate stays green in both states, because no gate reads TS doc comments. The check that does discriminate is the grep above — before this change the repo contained one live TS statement of the retired semantics, after it contains zero.
Generated-artifact assumption confirmed, not assumed:
git statusafter the full gate run shows exactly two files (the source and the changeset);authorable-surface.base.jsonis byte-identical andcheck:authorable-surface,check:docs,check:skill-refsandcheck:api-surfaceall report in sync with no rewrite.Generated with Claude Code
Generated by Claude Code