Repository navigation
feat(service-messaging,plugin-approvals): keyed notification text, rendered per recipient at delivery, so approval notifications reach each recipient in their own language - #25014
Conversation
…ered per recipient at delivery Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…e names the keyed-text reader Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude <noreply@anthropic.com>
…proval-notifications-recipient-locale
📓 Docs Drift CheckThis PR changes 2 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 9 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fe42e502a67d65872a9c0130a3c4e586401f7ac6 && git checkout fe42e502a67d65872a9c0130a3c4e586401f7ac6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff 1408bfb197cf80b2d3dea541d14c9b9f2efdbe65 && git checkout -B drift-repro 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff && git merge --no-ff 1408bfb197cf80b2d3dea541d14c9b9f2efdbe65
node scripts/docs-audit/affected-docs.mjs --json 93e6c1a5f8bf256310f9ed477523ff2c90bf1bff
|
席位核对 · ACCEPT ·
|
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).EmitInputgainskeyedText. A newNotificationTextRendererrenders 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 [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881 chain,resolveRecipientLocale:sys_user.locale, then the deployment default.@objectstack/plugin-approvals(patch). All 14notify()call sites pass keys. The sentences live in en, zh-CN, ja-JP and es-ES in the plugin's own translation bundle, in itsmessagesface. The literalpayload.titleandpayload.bodyare theenentries, 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):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.keyedTextthat is present but malformed throwsTypeErrorbefore 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 missingfallback, or neithertitleKeynorbodyKey. This narrows nothing that existed: the member is new.keyedText, each recipient'stitleandbodyaret(key, locale, params)from the registeredi18nservice. The locale is the recipient's, negotiated withresolveBundleLocaleagainstgetLocales(). Each member falls back to the literalpayload.titleorpayload.bodywhen it cannot be rendered: noi18nservice, no renderer composed,t()echoing the key, an empty answer or a throw. A miss is said once per key and locale atwarn. WithoutkeyedTextnothing is rendered and no locale is read.keyedText. The snapshot already reservestitle,body,severity,actionUrlandactorId, and nowkeyedTexttoo. It carriesEmitInput.keyedTextonly when the emit passed one. A producer payload member namedkeyedTextis dropped from the snapshot and never rendered, so a flownotifynode'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 keyedTextoutside these two packages: 0 hits).sys_notification.payloadstores the producer's payload as before), recipients, preferences, availability, the notifytemplatepath and thesys_notification_templateprecedence, which now falls back to the rendered text. For an emit withoutkeyedText, 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: plusNotificationTextRenderer,assertNotificationTextKeysand the typesNotificationTextKeys,NotificationTextParam,NotificationTextI18n,NotificationTextItem,NotificationTextRendererOptionsandRenderedNotificationText. Also new are the optionalMessagingServiceContext.textRenderer, the optionalNotificationDispatcherOptions.textRendererandDeliveryPayload.keyedText. Nothing is removed or renamed.@objectstack/plugin-approvals: no new export. The non-exported structuralApprovalMessagingSurface.emitinput gains the optionalkeyedText(the mirror of item 1), and the privatenotify()now requires it.nameSubjectRecordkeeps its name and returns the{{object}} {{record}}params instead of one string. No accept set of the plugin changes, so the changeset is apatch.The catalog: one, and not a new one
packages/spec/src/system/operation-message.ts). It is the spec-resident built-in catalog for operation-level refusals, addressederrors.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.i18nservice's translation catalog, the oneservice-messaging's own translations already load into (MessagingTranslations, ADR-0029 D8,i18n.loadTranslations). A producer contributes its sentences in its own bundle'smessagesface. This is the shape@objectstack/plugin-auditalready uses for its notification titles (messages.mentionedYou). Themessagesface is declared in the spec today, so the spec is not touched and no fork is needed. A deployment overrides a sentence with ordinarytranslationmetadata (the authored layer wins on read).objectslabels belong to the package that owns the object. A second emitter adds its ownmessagesentries and passes keys, with no messaging change.Channels, measured
Rendering happens ahead of the channel, so every channel receives the rendered
titleandbodyit already reads, including any channel a third party mounts.MessagingService.fanOut, once per recipient for all of that recipient's channelsNotificationDispatcher.processRowsubject,text) and SMS (body) in zh-CN; the stored snapshot holds the keys plus the literalNotificationDispatcher.processDigestGroup, one locale read per grouptemplatepath /sys_notification_templaterowThe object label
The subject keeps the #23556 / #23594 ruling B form, the object label plus the id. Measured:
nameSubjectRecordtook 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 keyreader-locale-labels.ts(#24977) and objectql's refusals name an object by. A zh-CN approver whose app shipsobjects.crm_deal.labelreads商机 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.rejectedand 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_infoandapproval.comment. They are fixed here under the bounded in-place exemption, because all four conditions hold:approvalNotificationText(...)call per site;approval-service.tsis under no other claim, and PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940 edits only test files;For
request_info,commentand 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
e4d604efd3approval.requestedatapproval-service.ts~3913-3928 one4d604efd3). Opening, reminders, the out-of-office notices and the decision outcome emit once per person.approval.sla_breached(approvers),approval.escalatedandapproval.commentemit ONE emit to several recipients. The delivery-time render covers both shapes, since each recipient gets their own locale either way.recipient-locale.tsis the one chain function. The email and SMS channels readsys_user.localeon every delivery, with the address, and the inbox channel only on the notifytemplatepath. 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.keyedText, and zero locale reads are made.Tests
All readings below were taken on head
1408bfb197, which is this branch withorigin/mainfbb42d900fmerged in. The dependency closures were rebuilt first.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 realMessagingService, the inbox, email and SMS channels (fake transports), the SQL outbox and the dispatcher.packages/plugins/plugin-approvals/src/approval-notification-keyed-text.integration.test.ts(5 cases) runs the realApprovalServiceover 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 realMessagingServicewith the inbox channel.@objectstack/service-messaging55 files and 618 tests passed;@objectstack/plugin-approvals93 files and 1208 tests passed.typecheckexits 0 in both packages, including approvals'check:test-typecheck. The 34 test files PR feat(driver-sql,driver-turso)!: 18.x step 7 part 7b-0 — a table the driver creates carries its tenant column NOT NULL #24940 edits are untouched.plugin-approvalsgains@objectstack/service-messagingas a devDependency, the shapeservice-automationalready has, with a source alias in itsvitest.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 throughscripts/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 bygit checkout HEAD -- PATH, proven by blob equality withHEAD:PATHand an emptygit 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 nodist/was involved.renderedForreturns the unrendered notificationresolveRecipientLocale(own, undefined)t()echoing the key is no longer a missapprovalNoticedropskeyedTextkeyedTextkept on the snapshotemit()removedGates
Local subset:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 84 families on1408bfb197. All 84 ran sequentially under the verify lock, each exit captured before any pipe, and all 84 exited 0.--ranreconciles them: 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero, since every family recorded its exit code).check:query-options-erasurered: the test surface grew from 236 to 237 because a new test's counting proxy typed itsfindOneoptionsany. The fix types it withParametersofObjectQL.findOne, and the ratchet holds at 236.check:i18nandcheck:dual-build-cjs-loadsanswered PREREQUISITE NOT MET (exit 3), which is not a measurement. Both were measured on the final head:check:i18nreports all bundles in 10 packages in sync, andcheck:dual-build-cjs-loadsloads 112 entry points across 69 packages.eslint --no-inline-configover the 11 changed source and test files exits 0,scripts/check-nul-bytes.mjsexits 0, and a control-byte scan of the changed files finds nothing.pnpm lintand the required contexts) are read from this PR's check-runs, not re-run here.Acceptance notes
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 adailyorweeklydigest preference (sys_notification_preference.digest, written through the data API). Dedupe words:digest title English,renderDigest locale,You have notifications literal.@objectstack/plugin-audit'scollab.mentiontitle is still rendered at emit, in the writer's resolved workspace locale (resolveWriteLocale), not per recipient at delivery. It could move tokeyedTextnow that the path exists. That is not a defect this card owns.notifynode's inline title is untouched and stays recipient-independent, asdocs/qa/platform-checklist/areas/i18n.jsonpins.Generated by Claude Code