Repository navigation
Commit 58cd58f
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
File tree
- .changeset
- packages
- plugins/plugin-approvals
- src
- translations
- services/service-messaging/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
0 commit comments