Skip to content

Commit 58cd58f

Browse files
feat(service-messaging,plugin-approvals): keyed notification text, rendered per recipient at delivery, so approval notifications reach each recipient in their own language (#25014)
Fixes #24978 Clause-②: yes (widening) ## What this does The approval notifications reached every approver as English sentences built at emit, and no catalog held them. This PR follows triage's route A (R547): the messaging service now takes a notification's text as catalog keys, beside the literal text, and renders it per recipient at delivery in that recipient's language. The approvals plugin passes keys for every notification it sends. - **`@objectstack/service-messaging` (minor).** `EmitInput` gains `keyedText`. A new `NotificationTextRenderer` renders it at delivery, before the channel is called, on the inline fan-out, on each dispatched row and on each row of a digest group. The language is the #13881 chain, `resolveRecipientLocale`: `sys_user.locale`, then the deployment default. - **`@objectstack/plugin-approvals` (patch).** All 14 `notify()` call sites pass keys. The sentences live in en, zh-CN, ja-JP and es-ES in the plugin's own translation bundle, in its `messages` face. The literal `payload.title` and `payload.body` are the `en` entries, byte for byte what each notification carried before. ## The contract change, sentence by sentence (for the contract-tier review) Accept set of `MessagingService.emit(input: EmitInput)`: 1. **Widened.** `EmitInput.keyedText?: { titleKey?: string; bodyKey?: string; params?: Record(string, string or { key: string; fallback: string }) }` is a new optional member. Every input accepted before is still accepted. 2. **Closed shape, refused at emit.** A `keyedText` that is present but malformed throws `TypeError` before any row is written. The cases are an undeclared member, a key that is not a dot path of at least two segments (a face and an id), a param name that is not a `{{hole}}` name, a keyed param missing `fallback`, or neither `titleKey` nor `bodyKey`. This narrows nothing that existed: the member is new. 3. **Delivered text.** With `keyedText`, each recipient's `title` and `body` are `t(key, locale, params)` from the registered `i18n` service. The locale is the recipient's, negotiated with `resolveBundleLocale` against `getLocales()`. Each member falls back to the literal `payload.title` or `payload.body` when it cannot be rendered: no `i18n` service, no renderer composed, `t()` echoing the key, an empty answer or a throw. A miss is said once per key and locale at `warn`. Without `keyedText` nothing is rendered and no locale is read. 4. **The delivery snapshot reserves `keyedText`.** The snapshot already reserves `title`, `body`, `severity`, `actionUrl` and `actorId`, and now `keyedText` too. It carries `EmitInput.keyedText` only when the emit passed one. A producer payload member named `keyedText` is dropped from the snapshot and never rendered, so a flow `notify` node's free-form payload cannot reach the capability on one path only. This is the one edge where something a producer could previously put on the snapshot no longer rides it. Measured: no producer in the repository writes such a member (`git grep keyedText` outside these two packages: 0 hits). 5. **Unchanged.** The event row (`sys_notification.payload` stores the producer's payload as before), recipients, preferences, availability, the notify `template` path and the `sys_notification_template` precedence, which now falls back to the rendered text. For an emit without `keyedText`, every channel's artifact is unchanged. That is pinned against a service with no renderer at all. Public surface of `@objectstack/service-messaging`'s `.` entry: plus `NotificationTextRenderer`, `assertNotificationTextKeys` and the types `NotificationTextKeys`, `NotificationTextParam`, `NotificationTextI18n`, `NotificationTextItem`, `NotificationTextRendererOptions` and `RenderedNotificationText`. Also new are the optional `MessagingServiceContext.textRenderer`, the optional `NotificationDispatcherOptions.textRenderer` and `DeliveryPayload.keyedText`. Nothing is removed or renamed. `@objectstack/plugin-approvals`: no new export. The non-exported structural `ApprovalMessagingSurface.emit` input gains the optional `keyedText` (the mirror of item 1), and the private `notify()` now requires it. `nameSubjectRecord` keeps its name and returns the `{{object}} {{record}}` params instead of one string. No accept set of the plugin changes, so the changeset is a `patch`. ## The catalog: one, and not a new one - **Measured: the Operation Message Catalog** (`packages/spec/src/system/operation-message.ts`). It is the spec-resident built-in catalog for operation-level refusals, addressed `errors.ID`. Its header says that filing anything else under that address "would give a deployment an override key that lies about what it overrides". A notification is not a refusal, and the spec is out of bounds here. It is not reused. - **Reused: the `i18n` service's translation catalog,** the one `service-messaging`'s own translations already load into (`MessagingTranslations`, ADR-0029 D8, `i18n.loadTranslations`). A producer contributes its sentences in its own bundle's `messages` face. This is the shape `@objectstack/plugin-audit` already uses for its notification titles (`messages.mentionedYou`). The `messages` face is declared in the spec today, so the spec is not touched and no fork is needed. A deployment overrides a sentence with ordinary `translation` metadata (the authored layer wins on read). - **Why the entries live in the plugin, not in messaging.** The path is messaging's: one renderer, one locale chain, one read point. The sentences belong to the producer that owns them, as `objects` labels belong to the package that owns the object. A second emitter adds its own `messages` entries and passes keys, with no messaging change. ## Channels, measured Rendering happens ahead of the channel, so every channel receives the rendered `title` and `body` it already reads, including any channel a third party mounts. | Path | Where it renders | Pinned | |---|---|---| | inline fan-out (no outbox) | `MessagingService.fanOut`, once per recipient for all of that recipient's channels | inbox row in zh-CN, en and the deployment default | | durable, single row | `NotificationDispatcher.processRow` | inbox, email (`subject`, `text`) and SMS (`body`) in zh-CN; the stored snapshot holds the keys plus the literal | | durable, digest group | `NotificationDispatcher.processDigestGroup`, one locale read per group | each listed item in zh-CN | | notify `template` path / `sys_notification_template` row | unchanged precedence; the template row's fallback text is now the rendered text | covered by the unchanged suites | ## The object label The subject keeps the #23556 / #23594 ruling B form, the object label plus the id. Measured: `nameSubjectRecord` took the schema label, which no language pack localizes. It now rides as a keyed param, `{ key: objects.OBJECT.label, fallback: schema label }`. That is the key `reader-locale-labels.ts` (#24977) and objectql's refusals name an object by. A zh-CN approver whose app ships `objects.crm_deal.label` reads `商机 D_1`, and one without it reads the schema label. ## Scope: the named six, and the five siblings fixed in place The dispatch named `approval.requested`, `approval.reminder`, `approval.sla_breached`, `approval.approved` / `approval.rejected` and the out-of-office notices. Five siblings in the same file had the same defect: `approval.escalated`, `approval.returned` (send-back and auto-reject), `approval.reassigned`, `approval.request_info` and `approval.comment`. They are fixed here under the bounded in-place exemption, because all four conditions hold: - the same defect class, an English literal built at emit with no catalog; - the same mechanical shape, one `approvalNotificationText(...)` call per site; - `approval-service.ts` is under no other claim, and PR #24940 edits only test files; - the same gate family, with no new verification surface. For `request_info`, `comment` and a send-back that carries a note, only the headline is keyed. The body is a person's own words and is delivered as written. ## PM mechanism assumptions, measured on `e4d604efd3` - **H1 holds, with one correction.** The bodies were literals built at emit (`approval.requested` at `approval-service.ts` ~3913-3928 on `e4d604efd3`). Opening, reminders, the out-of-office notices and the decision outcome emit once per person. `approval.sla_breached` (approvers), `approval.escalated` and `approval.comment` emit ONE emit to several recipients. The delivery-time render covers both shapes, since each recipient gets their own locale either way. - **H2 holds as a chain, not as a single read.** `recipient-locale.ts` is the one chain function. The email and SMS channels read `sys_user.locale` on every delivery, with the address, and the inbox channel only on the notify `template` path. Rendering keyed text ahead of the channel reads it once more per keyed delivery, through the same function, so the two reads cannot disagree on a language. - **H3 holds.** A literal-only emit is byte-unchanged: the inbox row, mail and text are identical to a service composed without a renderer, the snapshot has no `keyedText`, and zero locale reads are made. ## Tests All readings below were taken on head `1408bfb197`, which is this branch with `origin/main` `fbb42d900f` merged in. The dependency closures were rebuilt first. - New `packages/services/service-messaging/src/keyed-notification-text.integration.test.ts` (10 cases) runs a real ObjectQL over SqlDriver (better-sqlite3), the kernel's in-memory i18n, the real `MessagingService`, the inbox, email and SMS channels (fake transports), the SQL outbox and the dispatcher. - New `packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts` (5 cases) runs the real `ApprovalService` over the same driver. Its battery reaches all 12 topics, and it renders through the real messaging renderer with this plugin's own bundle. End to end it uses a real `MessagingService` with the inbox channel. - Whole suites: `@objectstack/service-messaging` 55 files and 618 tests passed; `@objectstack/plugin-approvals` 93 files and 1208 tests passed. `typecheck` exits 0 in both packages, including approvals' `check:test-typecheck`. The 34 test files PR #24940 edits are untouched. - `plugin-approvals` gains `@objectstack/service-messaging` as a devDependency, the shape `service-automation` already has, with a source alias in its `vitest.config.ts` (`check:test-source-alias`). The lockfile gains only that importer entry. **Ablation (one leg per pinned behaviour, on committed head `cbff799d08`).** Each leg went through `scripts/ablation-replace.mjs`. The anchor hit exactly once, the mutation was proven on disk (anchor 1 to 0, blob changed) and the pin file was run. Each leg was then restored by `git checkout HEAD -- PATH`, proven by blob equality with `HEAD:PATH` and an empty `git diff HEAD`. A closing pass re-proved all five mutated files. Every subject resolves through source (relative imports, or the new `^@objectstack/service-messaging$` source alias), so no `dist/` was involved. | Leg | Mutation | Red | |---|---|---| | L1 inline render | `renderedFor` returns the unrendered notification | 4 of 10 (zh-CN, deployment default, declared fallback, member-by-member) | | L2 deployment default | `resolveRecipientLocale(own, undefined)` | 1 of 10 (no-locale recipient); approvals 1 of 5 (end to end) | | L3 a miss falls back | `t()` echoing the key is no longer a miss | 5 of 10 | | L4 literal-only unchanged | inline fan-out renders even without keys | 1 of 10 (byte-unchanged, via the zero-locale-read pin) | | L5 each site passes its key | `approvalNotice` drops `keyedText` | approvals 3 of 5 | | L6 durable render | dispatcher skips the render | 1 of 10 (inbox, email and SMS on the durable path) | | L7 digest items | digest rendered from the unrendered rows | 1 of 10 | | L8 reserved snapshot name | producer payload `keyedText` kept on the snapshot | 1 of 10 | | L9 refused at emit | the assert in `emit()` removed | 1 of 10 | | L10 object label keyed | the label param keyed to an absent key | approvals 2 of 5 | ## Gates Local subset: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 84 families on `1408bfb197`. All 84 ran sequentially under the verify lock, each exit captured before any pipe, and all 84 exited 0. `--ran` reconciles them: 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, since every family recorded its exit code). - An earlier union on the pre-merge head read `check:query-options-erasure` red: the test surface grew from 236 to 237 because a new test's counting proxy typed its `findOne` options `any`. The fix types it with `Parameters` of `ObjectQL.findOne`, and the ratchet holds at 236. - On that earlier head, `check:i18n` and `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3), which is not a measurement. Both were measured on the final head: `check:i18n` reports all bundles in 10 packages in sync, and `check:dual-build-cjs-loads` loads 112 entry points across 69 packages. - Outside the derived set: `eslint --no-inline-config` over the 11 changed source and test files exits 0, `scripts/check-nul-bytes.mjs` exits 0, and a control-byte scan of the changed files finds nothing. - CI-owned families (the repo-wide `pnpm lint` and the required contexts) are read from this PR's check-runs, not re-run here. ## Acceptance notes - **Finding, class a (filed by the seat, not here).** A digest that collapses more than one notification is headed by the dispatcher's own English literal, `You have N notifications` (`digest-render.ts`), for every recipient. The items under it are rendered in the recipient's language after this PR. Reach: a recipient with a `daily` or `weekly` digest preference (`sys_notification_preference.digest`, written through the data API). Dedupe words: `digest title English`, `renderDigest locale`, `You have notifications literal`. - **Observation, carrier: none.** `@objectstack/plugin-audit`'s `collab.mention` title is still rendered at emit, in the writer's resolved workspace locale (`resolveWriteLocale`), not per recipient at delivery. It could move to `keyedText` now that the path exists. That is not a defect this card owns. - **Observation.** The flow `notify` node's inline title is untouched and stays recipient-independent, as `docs/qa/platform-checklist/areas/i18n.json` pins. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05b28c1 commit 58cd58f

19 files changed

Lines changed: 1821 additions & 94 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): approval notifications reach each recipient in their own language
6+
7+
Every approval notification now hands the messaging service its sentence as catalog keys beside the English literal (`EmitInput.keyedText`). The messaging service renders it at delivery in each recipient's `sys_user.locale`, else the deployment default. This plugin's translation bundle gains the sentences in en, zh-CN, ja-JP and es-ES, in its `messages` face (`messages.approvalRequestedTitle` and the rest).
8+
9+
- **Which notifications.** `approval.requested`, `approval.reminder`, `approval.sla_breached` (to the approvers and to the submitter), `approval.approved` and `approval.rejected`, the two out-of-office notices (`approval.ooo_substituted`, `approval.ooo_skipped`), `approval.escalated`, `approval.reassigned` and `approval.returned` (the send-back and the auto-rejection). For `approval.comment`, `approval.request_info` and a send-back that carries the approver's note, only the headline is keyed. The body is that person's own words, delivered as written.
10+
- **The record is named as before:** the object's label and the record id (`Deal D_123`), never a title. The label is rendered from the app's `objects.<object>.label` when its language pack holds one, else the schema label.
11+
- **The English is unchanged.** The payload's `title` and `body` are the `en` catalog entries, byte for byte the sentences each notification carried before. A messaging service that does not render keyed text delivers them as it always did.
12+
- **Unchanged.** Who is notified, each notification's topic, payload fields, deep link, dedup key and organization, and every channel's delivery behaviour apart from the text.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
---
4+
5+
feat(service-messaging): a notification's text can be catalog keys, rendered per recipient at delivery in that recipient's language
6+
7+
Clause-②: yes (widening): `EmitInput` gains an optional `keyedText` member, and the package's `.` entry gains `NotificationTextRenderer`, `assertNotificationTextKeys` and their types. An emit without `keyedText` is accepted and delivered exactly as before.
8+
9+
- **What was wrong.** `emit()` took the text a recipient reads only as `payload.title` and `payload.body`, literals the producer builds before any recipient exists. So every recipient of a notification read the producer's language. The approvals plugin's notifications reached zh-CN, ja-JP and es-ES approvers in English.
10+
- **The new member.** `EmitInput.keyedText` is `{ titleKey?, bodyKey?, params? }`. `titleKey` and `bodyKey` are `II18nService` keys made of a face and an id, such as `messages.approvalRequestedTitle`. A producer contributes their entries through its own translation bundle (`i18n.loadTranslations`, ADR-0029 D8). `params` fills the entries' `{{name}}` holes. A param is a string, or `{ key, fallback }`: the recipient-language text of `key` (such as `objects.<object>.label`), else `fallback`.
11+
- **Where it renders.** At delivery, per recipient, before the channel is called: on the inline fan-out, on each row the dispatcher sends, and on each row of a digest group. The language is the recipient's own `sys_user.locale`, else the deployment default (the recipient-locale chain, `resolveRecipientLocale`), negotiated against the locales the `i18n` service holds. Every channel, a third party's included, receives the rendered `title` and `body` it already reads.
12+
- **The fallback.** The literal `payload.title` and `payload.body` stay what a recipient reads whenever a key cannot be rendered: no `i18n` service, no renderer composed, or a key with no entry in the recipient's locale or in the catalog's declared fallback locale. A member the producer left unkeyed is also delivered as the literal. A key with no entry is said once per key and locale at `warn`.
13+
- **Refused at emit.** A malformed `keyedText` throws a `TypeError` before anything is written: an undeclared member, a key without a face, a param name no `{{hole}}` can spell, or a keyed param without its `fallback`.
14+
- **The delivery snapshot reserves `keyedText`,** the way it already reserves `title`, `body`, `severity`, `actionUrl` and `actorId`. The snapshot stores `EmitInput.keyedText` under that name only when the emit keyed its text. A producer's own payload member of that name (a flow `notify` node's free-form payload, say) is dropped from the snapshot and is never rendered. No producer in this repository writes one.
15+
- **Cost.** One `sys_user` read per keyed delivery (one per digest group). A delivery without keys, or a deployment with no `i18n` service, makes no read.
16+
- **Unchanged.** Every emit that passes no `keyedText` (and whose payload has no member of that name): the event row, the snapshot, and what the inbox, email and SMS channels deliver. The notify `template` path, `sys_notification_template` rendering (which now falls back to the rendered text), recipients, preferences and channel availability are also unchanged.
17+
- **Composing it.** `MessagingServicePlugin` wires one `NotificationTextRenderer` into the service and the dispatcher. A hand-built `MessagingService` passes `textRenderer` in its context and a `NotificationDispatcher` passes it in its options. Without one, keyed text is delivered as its literal, and the service says so once.

‎packages/plugins/plugin-approvals/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@objectstack/metadata-protocol": "workspace:*",
3434
"@objectstack/objectql": "workspace:*",
3535
"@objectstack/service-automation": "workspace:*",
36+
"@objectstack/service-messaging": "workspace:*",
3637
"@objectstack/trigger-record-change": "workspace:*",
3738
"@types/node": "^26.6.3",
3839
"hono": "^4.13.9",

0 commit comments

Comments
 (0)