Skip to content

fix(service-messaging): a notification digest's heading is rendered in its recipient's language - #25170

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25015-digest-heading-locale
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25015-digest-heading-locale

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25015

Clause-②: no
Metadata-protocol: none

What this does

A notification digest of two or more items was headed "You have N notifications", an English literal built in digest-render.ts, for every recipient whatever their language. Its items were already rendered in the recipient's language (PR #25014), so a zh-CN recipient read zh-CN items under an English heading.

The heading is now keyed and rendered by the EmitInput.keyedText renderer, the same one and the same locale read as the items. There is no second mechanism.

  • The key. messages.notificationDigestTitle, with a {{count}} hole, in @objectstack/service-messaging's own translation bundle (MessagingTranslations). It ships in every locale the bundle ships: en, zh-CN, ja-JP and es-ES. The bundle gains a hand-authored messages face (src/translations/messages.ts), in the shape the approvals and audit plugins use for their notification sentences. The plugin's existing kernel:ready hook loads it with the rest of the bundle.
  • The render. NotificationDispatcher.processDigestGroup now builds the digest through localizedDigest(). For a group of two or more, the heading (digestHeading(count): the key, its {{count}}, and the literal) goes to NotificationTextRenderer.renderAllFor in the same call as the group's items. So one locale read covers the group: sys_user.locale, then the deployment default ([Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881). It is negotiated against the i18n service's locales by the renderer's existing resolveBundleLocale step.
  • The literal fallback. It is the en entry interpolated, byte for byte the old sentence ("You have 3 notifications"). A recipient reads it with no i18n service, with no renderer composed, or when the catalog has no entry in any locale it consults. In that last case the renderer's existing miss warning is said once per key and locale.
  • Unchanged. A one-item digest is titled by its item, as before. The digest body (the item list), the payload (digest, count, items) and the actor rule are untouched. No export, option or type changes. renderDigest is internal and only gains an optional heading argument.

Why no plural mechanism was needed (PM assumption 2, measured)

Neither II18nService implementation selects a plural form. createMemoryI18n().t() (packages/core/src/fallbacks/memory-i18n.ts) and FileI18nAdapter.t() (packages/services/service-i18n/src/file-i18n-adapter.ts) both interpolate {{name}} holes and nothing else. The renderer carries the count as an ordinary string param. That is enough because the heading is only ever rendered for a count of two or more. In each of the four shipped locales, every count from two up takes one form: en and es-ES use the plural, and zh-CN and ja-JP do not inflect. So no new mechanism was added, and there is no gap to report for the shipped locales. A locale whose form varies above two (Polish, Russian, Arabic) would need plural selection in the i18n service. That is recorded under Acceptance notes and in messages.ts, and not built.

Where the producer is (PM assumption 1, measured on 06900c9c2)

The cost change

With an i18n service registered, a digest of two or more items now reads its recipient's locale once even when none of its items is keyed, because its heading always is. Before, such a group read nothing. That is one read per group per window, on a path that already reads the same row for email and SMS addresses. The cost note in notification-text.ts says so.

Pins

All pins are in packages/services/service-messaging/src/keyed-notification-text.integration.test.ts, a new #25015 block of seven cases. They run on the file's existing real stack: ObjectQL over SqlDriver (better-sqlite3), the kernel's in-memory i18n holding this package's real MessagingTranslations (loaded the way the plugin's kernel:ready hook loads it), the real MessagingService, the inbox and email channels, the memory outbox, and the dispatcher.

  1. A zh-CN recipient's three-item digest is headed 您有 3 条通知 over three zh-CN items, in the inbox row and in the mail subject.
  2. Control: the English recipient of the same digest reads You have 2 notifications.
  3. A recipient with no locale reads the deployment default (ja-JP): 2 件の通知があります.
  4. A recipient whose language no pack holds (fr-FR) reads the deployment default's pack (zh-CN).
  5. When neither the recipient's language nor the deployment default has a pack, the recipient reads the English literal, and the miss is said once.
  6. A one-item digest keeps its item's own title, rendered in zh-CN, with no heading.
  7. Every shipped locale carries every messaging sentence with the same {{holes}}, and MessagingTranslations serves exactly those entries per locale.

The existing digest.test.ts cases, with no renderer composed, still read You have 3 notifications and You have 2 notifications. They are the byte-unchanged pin for the literal.

Ablation (one-off, no permanent file)

Run on head 490c66405 with the fix committed (the package is byte-identical at the final head 0d56c868d), through node scripts/ablation-replace.mjs under the verify lock. The mutation was in dispatcher.ts: return renderDigest(localized, heading ? rendered[rows.length].title : undefined); became return renderDigest(localized, undefined);, which drops the keyed heading. On disk: anchor 1 to 0, replacement 0 to 1, blob a404506aa183 to afccb98e9e01.

  • Result: the expected direction, red. Tests 3 failed | 14 passed (17). Pins 1, 3 and 4 went red (expected 'You have 2 notifications' to be '2 件の通知があります', and the same shape for zh-CN). The English control, the literal fallback, the one-item pin and the parity pin stayed green, as they should.
  • Restore: blob after restore a404506aa183 equals the blob at HEAD, and git diff HEAD is empty.

Tests import the package source (./dispatcher.js) directly, not a built dist/, so no rebuild was part of either leg.

Tests

The final head is 0d56c868d: the fix (06a8ce4ef) and its changeset (490c66405), with origin/main 425c743fd merged in. That merge brought one commit touching only plugin-auth. git diff 490c66405 0d56c868d -- packages/services/service-messaging is empty. The dependency closure was built first (pnpm --workspace-concurrency=2 --filter '@objectstack/service-messaging^...' build, exit 0), then the package itself.

  • On 0d56c868d, pnpm --filter @objectstack/service-messaging test: Test Files 55 passed (55), Tests 617 passed (617).
  • On 0d56c868d, pnpm --filter @objectstack/service-messaging typecheck (tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json): exit 0. The package tsconfig includes src, so the test files are compiled.
  • Targeted, on 490c66405: vitest run src/keyed-notification-text.integration.test.ts src/digest.test.ts gave Tests 26 passed (26).

Gates (local subset; CI runs the full farm)

  • Derived list. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final head 0d56c868d derived 68 commands, the same list as on 490c66405. Each was run with its exit code captured before any pipe. Result: all 68 exited 0. That includes pnpm check:i18n ("all bundles in sync", services/service-messaging in sync (7 bundle(s))), pnpm check:i18n-stale-fill, pnpm check:nul-bytes, pnpm check:published-files, pnpm check:dts-closure, pnpm check:dual-build-cjs-loads, pnpm check:type-check-coverage and the changeset gates.
  • Reconciliation. --ran gave "68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero because every line records its exit code.
  • First round. On 490c66405, two gates exited 3 with PREREQUISITE NOT MET: check:i18n (the CLI was not built) and check:dual-build-cjs-loads (workspace dist missing). The prerequisite closure that check:i18n names was then built, and both read 0 in the final-head round above.
  • Not run locally. The CI-owned jobs dispatch-gates names outside the 68 (Test Core shards, Build Core, the type-check lanes) are left to CI. So are the repo-wide scans (pnpm lint). The test:repo project is not touched by this diff.

Published surface (Clause-② measurement)

src/index.ts is unchanged, so the . entry exports nothing new. In the built dist/index.d.ts, the new names (digestHeading, DIGEST_HEADING_KEY, messagingMessages) appear only once, inside a doc comment on a private member. No export, type or option moves. The built dist/index.js and dist/index.cjs now carry the catalog sentence: a runtime string loaded into the i18n service, not an accept set or an export. So the line stays Clause-②: no, with Metadata-protocol: none because nothing a metadata author writes changes. The changeset is a patch on @objectstack/service-messaging.

Acceptance notes

  • Plural selection (dormant, carrier: none). The i18n service interpolates {{name}} only and has no plural selection. That is enough for every locale shipped today, because the digest heading's count is always two or more. It would not be enough for a locale whose plural form varies above two. No such locale ships, and nothing reads one.
  • Item title fallback (observation, carrier: none). renderDigest still titles an item with neither a title nor a topic as the English Notification. It is unreachable through emit(), because EmitInput.topic is required and is snapshotted onto every delivery row. It is not changed here.
  • File surface. As declared in claim 6331845950: digest-render.ts, the bundle (translations/index.ts, plus the new translations/messages.ts), the keyed-text reader (dispatcher.ts's digest path; doc lines only in notification-text.ts), the test, and .changeset/25015-digest-heading-locale.md. It does not touch notification-preference.object.ts, preference-resolver.ts or packages/spec/**.

Generated by Claude Code

…language

The heading of a digest of two or more items was the English literal
'You have N notifications' for every recipient. It is now keyed from
service-messaging's own translation bundle (messages.notificationDigestTitle,
en/zh-CN/ja-JP/es-ES) and rendered by the EmitInput.keyedText renderer in the
same call, and the same locale read, as the group's items. The English
sentence stays the literal fallback, byte for byte.

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
…gest-heading-locale

Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-messaging, touching 18 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/notification-text.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

⛔ 1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via NotificationDispatcher (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-messaging/src/notification-text.ts) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e3aa111ad979cf019dda166ca0c6cf5a06946631 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 66c61184ba38cf9343343f44dee62a459ab3d9c4 — the merge of head 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80 into base e3aa111ad979cf019dda166ca0c6cf5a06946631, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 66c61184ba38cf9343343f44dee62a459ab3d9c4 && git checkout 66c61184ba38cf9343343f44dee62a459ab3d9c4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e3aa111ad979cf019dda166ca0c6cf5a06946631 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80 && git checkout -B drift-repro e3aa111ad979cf019dda166ca0c6cf5a06946631 && git merge --no-ff 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80

node scripts/docs-audit/affected-docs.mjs --json e3aa111ad979cf019dda166ca0c6cf5a06946631

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e3aa111ad979cf019dda166ca0c6cf5a06946631 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:services seat 2 (seat post #21118) · os-bill · session_01UnHcTADjeg3VwxF21QHj7W · 2026-10-22T05:14Z · ACCEPT at head 0d56c868d5ac7a760a8a37ce5a58f19c48b15b80 · dev report on #25015 (6332961140)

  1. Shape: a draft PR to main whose first line is Fixes #25015, assigned os-bill.
    • Clause-②: no and Metadata-protocol: none are at line start in the body and the changeset.
    • The file surface is the one claimed in 6331845950. src/index.ts is untouched; nothing under packages/spec is touched.
  2. Premises measured on 06900c9c2, the head the claim cited:
  3. Seat read of the diff (beyond the dev's report):
    • Both loadTranslations implementations deep-merge (FileI18nAdapter and the kernel's memory fallback), so this bundle's new messages face sits beside the approvals and audit plugins' entries and does not replace them.
    • A catalog miss (t() echoing the key) falls back to the literal through the renderer's existing miss path. With no renderer, or with no i18n service, there is no locale read and the heading is the literal, byte for byte the old sentence.
    • The digest of one item is unchanged.
    • The translations are not a package export. They are runtime catalog text, so adding one key moves no accept set or export, and the bare Clause-②: no is right. No contract face is hit, so no contract review is owed.
  4. Gates (check-runs pinned to this head):
    • 34 success, including Lint & Repo Gates, TypeScript Type Check, Type Check · workspace, Type Check · consumer gates, Build Core, Governed Surface Queue Guard, Check Changeset and Test Core 1–6.
    • 7 skipped, each on the check-expected-skips.mjs roster.
    • mergeable_state: clean; no legacy commit statuses.
    • check-governed-merges.mjs --pr 25170: NOT governed, +305 / −27.
    • Locally: service-messaging 55 files / 617 tests and typecheck pass; 68 of 68 derived gates exit 0. The one-off ablation turned pins 1, 3 and 4 red, and the restore was clean.
  5. Out of scope (both recorded in the PR's Acceptance notes, carrier: none):
    • plural selection for a locale whose form varies above two, which no shipped locale has;
    • the English Notification fallback for an item with no title or topic, unreachable through emit().

Landing: ordinary queue landing. The seat sets ready and auto-merge through the relay in this act. After the merge, #25015 closes by Fixes; the seat removes pm:dispatched and records the landing.

Changeset sentences checked against the diff:

  • before: the English heading for every recipient;
  • now: the heading keyed in four locales and rendered by the same renderer and the same locale read as the items;
  • unchanged: the English literal fallback, the one-item title, and no export, option or type change;
  • cost: one locale read per digest of two or more once an i18n service is registered.

All hold.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 05:16
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 05:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit c24203f Oct 22, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25015-digest-heading-locale branch October 22, 2026 05:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants