Skip to content

Commit ba69b16

Browse files
fix(plugin-approvals): serve the approval flow, step and object labels in the reader's locale (#24977)
Fixes #24863 Clause-②: no This is the services half of #24863 (triage split R539): the request labels and the inbox row's object label. The `approval.requested` notification half is NOT in this PR. It needs a decision, set out in **Open questions** below, so the card stays open (`Part of`, not `Fixes`). ## What changes The approvals door now serves a request's flow, step and object labels in the reader's locale, picked when the row is read. - **Doors.** `GET /api/v1/approvals/requests` (the inbox and the record's approval panel), `GET /api/v1/approvals/requests/:id`, and the request a decision answers with. - **Fields.** `process_label`, `step_label` / `current_step_label` (two names of one value, which move together) and `object_label`. The get's step progress (`flow_steps`) names each step the same way, so the strip and `current_step_label` agree. - **Keys.** `flows.FLOW.label`, `flows.FLOW.approvals.NODE.label` (the key PR #24943 added) and `objects.OBJECT.label` (`objectLabelKey`). - **Fallback.** The stored snapshot, whenever nothing is picked: a key the pack lacks, a locale no pack holds, a read with no locale, no `i18n` service, or a service that throws. - **Unchanged.** - The stored `process_label` / `current_step_label` columns. No column is added, rewritten or migrated. - The generic data door and the list views, which still serve the stored text. - Who may read a request. - The notification bodies. ### Files - `packages/plugins/plugin-approvals/src/reader-locale-labels.ts` (new): the keys, the pick and the overlay. - `approval-service.ts`: the new option `ApprovalServiceOptions.i18nServiceSource`, and `readerLabels(context)`. `listRequests` and `loadRequest` apply it after `enrichRows`, and `attachFlowSteps` takes the same labels. - `approvals-plugin.ts`: one lazy `i18n` reader feeds both `messageTranslator` (unchanged behaviour) and `i18nServiceSource`. - `display-labels.ts` and the open-time snapshot comment: the docblocks now say the chain answers the authored label and the door overlays the translation. - `.changeset/24863-approval-labels-reader-locale.md`: `@objectstack/plugin-approvals` patch. ## Mechanism (measured; reused, not a second one) Ruling 6093472708 on #22507 says a server-rendered user string is translated where it is rendered. I measured how this repo's server already renders a user-read string in the caller's language. The pattern is the same in three places: - objectql `objectDisplayLabel` (`objectLabelKey`); - `packages/rest/src/not-null-refusal-label.ts`; - the automation engine's `renderFlowTextSlot`. All three take `ExecutionContext.locale`, negotiate it against the i18n service's `getLocales()` with `resolveBundleLocale`, call `II18nService.t(key, locale)` (a miss echoes the key), and fall back to the authored text. This PR uses the same sequence. The service is read lazily from the kernel (`ctx.getService('i18n')`), the same read the plugin's existing `messageTranslator` already makes. ## The mechanism assumptions, measured on `origin/main` `693352e4c2` - **H1 holds.** Every door served the stored row with no locale step. - `rowFromRequest` (`approval-service.ts:1203`, labels at `:1233`–`:1239`) builds the labels from `requestDisplayLabels` (the stored column, then the config snapshot, then the machine name). - It serves `listRequests` (`:8109`, rows at `:8160`) and `loadRequest` (`:8274`). Those feed `getRequest` (`:8247`) and `readBackRequest` (`:8232`, a decision's echo). - `object_label` was `objectLabelOf` (`:7374`), the schema label, set at `:7513`. - `attachFlowSteps` (`:8391`/`:8403`) used the live node's authored label. - REST: `packages/rest/src/approvals-routes.ts:214` (list) and `:310` (get). - Two more doors serve the labels and are deliberately left as they are (see Acceptance notes): the generic data door's list views (`sys-approval-request.object.ts:130`–`:163`) and the token action page (`action-link-pages.ts:65`–`:68`). - **H2 holds.** The per-request locale is `ExecutionContext.locale`. The shared assembler sets it to `requestLocale ?? localization?.locale` (`packages/core/src/security/assemble-execution-context.ts:388`), and the REST approvals routes pass that context (`resolveExecCtx`). The `i18n` service is reachable from `plugin-approvals` through `ctx.getService('i18n')`, already used at `approvals-plugin.ts:252` for refusal copy. - **H3 holds.** The stored row carries both key parts. `process_name` is `flow:` plus `Flow.name` (the engine's `$flowName`, `approval-node.ts:180`, `service-automation` `engine.ts:13331`), and `current_step` is the node id (`approval-service.ts:3750`). No flow definition is read. A process name without the `flow:` prefix names no flow, so its labels stay as stored. ## Tests All runs are on head `2335508393` (the merge of `origin/main` `6440cf1633`), under `os-verify-lock.sh`. - **New: `reader-locale-labels.test.ts`** (unit, 15 cases). - Each key the reader asks is rebuilt into the nested pack shape and parsed by `TranslationDataSchema`, with a control that a step leaf the face does not declare is refused. - A bare `zh` is negotiated to `zh-CN`, also against an exact-match service. - Picks nothing on a miss, an empty entry, a blank entry, an unheld locale or a throwing service. A read with no locale or no service is no pick at all. - One `t()` per key per read. - The overlay moves both step names together and keeps unpicked labels. - **New: `reader-locale-display-labels.integration.test.ts`** (10 cases). The rig is a real `ObjectQL` over better-sqlite3, the real `AutomationEngine` running a two-step flow (so the real approval node opens the request), the real `ApprovalsServicePlugin.start` over a kernel whose `i18n` is `createMemoryI18n` loaded with a zh-CN pack, and the pack parsed by `TranslationDataSchema` first. - A zh-CN reader gets zh-CN labels on the record panel list, the inbox list, the get and its `flow_steps`, and a decision's echo. - A bare `zh` reader reaches the zh-CN pack. - A step the pack lacks is served as stored, beside the translated flow label. - A ja-JP reader with no pack gets the stored snapshot. - An en reader and a reader with no locale are unchanged. - The stored row keeps `Account Approval` / `Manager Review` after the reads. - **Package.** - `pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2`: `Test Files 92 passed (92)`, `Tests 1203 passed (1203)`. - `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0. It includes `check:test-typecheck: OK`, so the new test files compile with no new debt. - `pnpm --filter @objectstack/plugin-approvals build`: exit 0. - Closure: `turbo run build --filter=@objectstack/plugin-approvals^... --concurrency=2`, 32 of 32 successful. - **Ablation**, one leg per changed behaviour, each through `scripts/ablation-replace.mjs` in WRAP mode with a trap restore. The new tests import the subject by relative path, so they read `src/` and no build is needed for either leg. - Leg 1 makes `readerLabels` return nothing (on disk: anchor 1→0, marker 0→1). Result: `Tests 6 failed | 19 passed (25)`. Exactly the six zh-CN pins went red; every as-stored pin and the unit file stayed green. - Leg 2 makes `attachFlowSteps` name steps by the authored label only. Result: `1 failed | 9 passed (10)`, and the one red was the step-progress pin. - Restore after each leg: `git checkout HEAD -- PATH`. The blob after restore equals HEAD (`06899aa721b5`), `git diff HEAD` is empty, and `git status --porcelain` is clean. - The direction was predicted in the test docblock before the run, and it held. ## Gates Local subset, at head `2335508393`, under the lock. Each exit code was captured before any pipe. - **Derivation.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 families (vs merge base `6440cf163`, 7 paths). All 70 were run. - **Results.** 69 exited 0. One exited 3: - `pnpm check:dual-build-cjs-loads` answered `PREREQUISITE NOT MET`. It reads every package's `dist/`, and 38 packages are unbuilt in this tree. **NOT MEASURED** locally; CI runs it in `Lint & Repo Gates`. - **`pnpm check:i18n`.** It first exited 3 (the CLI was not built). After the prerequisite closure the gate names (`turbo run build`, 61 of 61 tasks), it exited 0: `check-i18n-bundles: OK (10 package(s) — all bundles in sync, no undeclared authoring keys)`. - **`--ran` reconciliation:** `70 derived famil(ies) accounted for — 69 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. - **Left to CI:** - `test:repo`: the diff touches none of its files, so it runs in CI's `Test Core`. - `pnpm lint` and the whole-repo farm. - CI check-runs had not concluded when this PR opened. ## Open questions (decision frame: real business need, long-term soundness, preventing AI mistakes, no scope creep at the startup stage) ### 1. The `approval.requested` notification body (and its siblings) **Measured.** - The body is built at open (`approval-service.ts:3890`–`:3901`): title `Approval requested`, body ``A decision on ${subject} is waiting on you.``, where `subject` is `nameSubjectRecord` (the schema label plus the record id). - It is emitted once per approver, under the SUBMITTER's request context, so the only locale in hand is the submitter's. - The locale it should use is the recipient's. Ruling #13881, as encoded in `service-messaging/src/recipient-locale.ts` (the stated ONE read point): the recipient's `sys_user.locale`, then the deployment default, resolved after fan-out. - Nothing localizes the sentence today. The sentence has no catalog entry: the Operation Message Catalog (`@objectstack/spec/system`) holds no notification sentences, and the plugin's packs hold object translations only. - So localizing it needs a NEW translation key in `packages/spec` (or a new plugin catalog). This dispatch forbids both. - The same holds for `approval.reminder`, `approval.sla_breached`, `approval.approved` / `approval.rejected` and the OOO notices. **Options.** - **A. Keyed text, rendered per recipient at delivery.** Approvals hands messaging a catalog key and params. Each channel renders the text in the recipient's locale through `recipient-locale.ts` and the shared catalog, which gains notification entries in en / zh-CN / ja-JP / es-ES. - Business need: real (HotCRM measured it; every non-English approver gets the English body). - Long-term: best. One locale chain and one catalog serve every producer. - AI mistakes: hardest to get wrong. A producer cannot hand-roll a sentence, and a key's existence is checkable. - Startup scope: widest. Three lanes (spec catalog, the messaging contract and channels, approvals). Each is a card. - **B. Rendered at emit, per approver, in plugin-approvals** (the fan-out is already one emit per approver). The text comes from new entries in the shared Operation Message Catalog. The recipient's locale comes from messaging (a small read it exposes), so the chain keeps one read point. - Business need: real. - Long-term: good. Every approval topic reuses one helper, but each other producer would repeat it. - AI mistakes: medium. Per-producer rendering is a pattern to copy. - Startup scope: two lanes (spec catalog entries, approvals) plus one small messaging read. - **C. Keep the English bodies and record the gap.** - Business need: leaves a measured defect. - Startup scope: zero cost. **Recommendation: B.** It fixes the measured defect at the smallest cost that keeps one catalog and one locale read point. A stays the target if a second non-approval producer asks for localized notifications. ### 2. The generic data door's list views `Setup → Approvals → Requests` and its siblings are list views over `sys_approval_request`. They serve the stored columns by the #23889 contract, so a zh-CN reader still sees the authored labels there. - **A. Leave them stored.** These are admin lists, the column is the audit record, and `?q=` search matches the stored text. - **B. Add a read-time overlay hook on the data door.** Filter and sort would then disagree with the cells, and the authored text would become unreachable through the door. - **C. Translate in the console from the bundle.** That is a client overlay on a served value. **Recommendation: A**, until a measured user need appears. HotCRM measured the inbox and the record panel, which this PR fixes. ## Acceptance notes - **Not changed, by design.** - The token action page (`action-link-pages.ts`, bilingual en/zh copy) has no reader locale: the bearer has a token, not a session, and its context is the system's. - `?q=` search matches the stored labels. - **The `packages/spec` follow-ups this reader makes due.** They are spec-lane edits, outside this dispatch. - (1) Liveness row `packages/spec/liveness/translation.json` → `flows.approvals` (and `.label`) says "Flip to `live` on the commit that cites that reader". This PR is that reader. - (2) The contract docblocks on `ApprovalRequestRow.process_label` / `current_step_label` / `object_label` (`packages/spec/src/contracts/approval-service.ts:220`–`:243`) describe the authored/schema label. They now hold only for a reader with no translation. - (3) PR #24943 suggested a `flowApprovalLabelKey` helper next to `flowRefusalMessageKey`. This PR spells the two flow keys locally in `reader-locale-labels.ts`, held to `TranslationDataSchema` by a test. A spec helper would let the reader import them. - **The CLI half (#24916).** The extractor asking apps to translate approval flows and steps is #24916's. With this reader landed, that ask is no longer a declared-but-not-honoured demand. - **The console half.** objectstack-ai/objectui#13248 (the history row draws `step_name`) will read `sys_approval_action.step_label`, a stored snapshot served through the data door. It will show the authored step label, not the reader's. That is a carrier note for objectui#13248, not filed. - **Negotiation glue.** The few lines that negotiate against `getLocales()` now have four consumer copies (objectql, rest, automation, here) around the one rule. This is an observation, not filed. > **Seat's append** (`domain:services` seat 1, `session_011TdZ8GjixNZFLz1xJD9zzK`): > - **First line changed** from `Part of #24863` to `Fixes #24863`, by seat ruling `6300710414` on #24863. The notification half moved to #24978, and the spec liveness / docblock follow-up is #24979. > - **Open question 2:** ruled A, so the admin lists stay stored. > - Where the sections above say this PR is `Part of` or the notification half is open here, this append supersedes them. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b811c73 commit ba69b16

7 files changed

Lines changed: 779 additions & 12 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
fix(plugin-approvals): the approvals inbox, the record's approval panel and the request page name the flow, the step and the record's object in the reader's language
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** An approval request copies the flow's label and the Approval node's label when it opens (`process_label`, `current_step_label`), and the approvals door served that copy to every reader. A user whose locale is zh-CN, ja-JP or es-ES saw the authoring language, although the app's language pack carried `flows.<flow>.label` and `flows.<flow>.approvals.<node_id>.label` for those strings. The inbox row's `object_label` was the object's untranslated schema label.
10+
- **Now.** `GET /api/v1/approvals/requests` (the inbox and the record's approval panel), `GET /api/v1/approvals/requests/:id` and the request a decision answers with serve `process_label`, `step_label` / `current_step_label` and `object_label` in the reader's locale (`Accept-Language`, else the `localization` settings). Each is read from the deployment's `i18n` service: `flows.<flow>.label`, `flows.<flow>.approvals.<node_id>.label` and `objects.<object>.label`. A bare `zh` reaches a `zh-CN` pack. The request get's step progress (`flow_steps`) names each step the same way.
11+
- **Fallback.** A label the pack does not carry, a locale no pack holds, a read without a locale and a stack without an `i18n` service all get the label the request stored, exactly as before. An `en` reader of an app authored in English sees no change.
12+
- **Unchanged.** The stored `process_label` and `current_step_label` columns keep the authored text they were written with: nothing is migrated or rewritten, and the generic data door, the list views and the one-tap action page still show the stored text. Who may read a request is unchanged. The `approval.requested` notification body is still English.
13+
- **`ApprovalServiceOptions.i18nServiceSource`.** A new optional option: a lazy reader of the `i18n` service. `ApprovalsServicePlugin` wires it from the kernel's `i18n` service. A host that constructs `ApprovalService` by hand and leaves it out keeps the stored labels.

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ import {
9393
type ActingCaller,
9494
} from './approver-address.js';
9595
import { labelOrMachineName, requestDisplayLabels, slateLabelOf } from './display-labels.js';
96+
import {
97+
localizeRequestLabels,
98+
readerLabelsFor,
99+
type ReaderLabelI18n,
100+
type ReaderLabels,
101+
} from './reader-locale-labels.js';
96102
import type { SlotLabelSource } from './display-label-backfill.js';
97103
import {
98104
redactSnapshot,
@@ -1429,6 +1435,17 @@ export interface ApprovalServiceOptions {
14291435
* `translation` metadata wins over the built-in sentence.
14301436
*/
14311437
messageTranslator?: () => ValidationMessageTranslator | undefined;
1438+
/**
1439+
* [#24863] The deployment's `i18n` service, through which the approvals door
1440+
* serves a request's flow, step and object labels in the READER's locale
1441+
* (`reader-locale-labels.ts`). Resolved LAZILY per read, for the reason
1442+
* {@link ApprovalServiceOptions.messageTranslator} is: the provider is
1443+
* another plugin's and may start after this one.
1444+
*
1445+
* Absent (or resolving to `undefined`) serves the labels as stored, the
1446+
* authored text the request snapshotted when it opened.
1447+
*/
1448+
i18nServiceSource?: () => ReaderLabelI18n | undefined;
14321449
}
14331450

14341451
/**
@@ -1528,6 +1545,8 @@ export class ApprovalService implements IApprovalService {
15281545
private objectReadAdmission?: ObjectReadAdmissionSource;
15291546
/** [#11993] Lazily-resolved deployment i18n lookup for refusal copy. */
15301547
private messageTranslator?: () => ValidationMessageTranslator | undefined;
1548+
/** [#24863] Lazily-resolved `i18n` service for the reader-locale display labels. */
1549+
private i18nServiceSource?: () => ReaderLabelI18n | undefined;
15311550
/**
15321551
* [#8652] The HOST-named object set for the record-reader visibility tier
15331552
* (the constructor option). EMPTY is the default, and the tier is then off
@@ -1556,6 +1575,7 @@ export class ApprovalService implements IApprovalService {
15561575
this.tenancyPosture = opts.tenancyPosture;
15571576
this.fieldVisibility = opts.fieldVisibility;
15581577
this.messageTranslator = opts.messageTranslator;
1578+
this.i18nServiceSource = opts.i18nServiceSource;
15591579
this.recordReaderVisibleObjects = new Set(
15601580
(Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : [])
15611581
.map((n) => String(n ?? '').trim())
@@ -3746,6 +3766,9 @@ export class ApprovalService implements IApprovalService {
37463766
// a reader (`sys-approval-request.object.ts` documents each column).
37473767
// An empty authored label counts as none: the chain falls through to
37483768
// the humanized machine name, the label the approvals door served.
3769+
// [#24863] The approvals door serves the reader's-locale translation of
3770+
// both labels over this snapshot at read time (`reader-locale-labels.ts`);
3771+
// the stored text stays the authored one.
37493772
process_label: labelOrMachineName(input.flowLabel, processName) ?? null,
37503773
current_step: input.nodeId,
37513774
current_step_label: labelOrMachineName(input.nodeLabel, input.nodeId) ?? null,
@@ -8164,10 +8187,38 @@ export class ApprovalService implements IApprovalService {
81648187
const served = await this.servedSubjectFields(list, context);
81658188
this.redactPayloads(list, served);
81668189
await this.enrichRows(list, served, context);
8190+
const labels = this.readerLabels(context);
8191+
if (labels) for (const row of list) localizeRequestLabels(row, labels);
81678192
this.attachViewers(list, context, caller);
81688193
return list;
81698194
}
81708195

8196+
/**
8197+
* [#24863] The labels this read serves in the READER's locale
8198+
* (`ExecutionContext.locale`), picked through the deployment's `i18n`
8199+
* service from `flows.<flow>.label`, `flows.<flow>.approvals.<node_id>.label`
8200+
* and `objects.<object>.label`, or `undefined` when the read can pick none
8201+
* (no locale, no service). The one spelling of the pick, the fallback and
8202+
* the keys is `reader-locale-labels.ts`.
8203+
*
8204+
* A read, never a write: the stored `process_label` / `current_step_label`
8205+
* stay the authored snapshot, and a label nothing is picked for is served as
8206+
* the row already carries it. Asked on every door that serves a request to
8207+
* a reader: the list ({@link listRequests}: the inbox and the record's
8208+
* approval panel), and the get and a decision's echo ({@link loadRequest}),
8209+
* whose step progress ({@link attachFlowSteps}) names its steps the same way.
8210+
*/
8211+
private readerLabels(context: ExecutionContext): ReaderLabels | undefined {
8212+
let service: ReaderLabelI18n | undefined;
8213+
try {
8214+
service = this.i18nServiceSource?.();
8215+
} catch {
8216+
// The i18n service is optional and late-bound: the stored labels stand.
8217+
return undefined;
8218+
}
8219+
return readerLabelsFor(service, context?.locale);
8220+
}
8221+
81718222
async countRequests(
81728223
filter: Parameters<IApprovalService['listRequests']>[0],
81738224
context: ExecutionContext,
@@ -8278,7 +8329,9 @@ export class ApprovalService implements IApprovalService {
82788329
const served = await this.servedSubjectFields([row], context);
82798330
this.redactPayloads([row], served);
82808331
await this.enrichRows([row], served, context);
8281-
await this.attachFlowSteps(row);
8332+
const labels = this.readerLabels(context);
8333+
if (labels) localizeRequestLabels(row, labels);
8334+
await this.attachFlowSteps(row, labels);
82828335
await this.attachDecisionProgress(row, raw);
82838336
this.attachViewers([row], context, caller);
82848337
return row;
@@ -8387,8 +8440,13 @@ export class ApprovalService implements IApprovalService {
83878440
* preferring `approve`/`true` edges, so the result is the flow's main
83888441
* approval trunk; conditional side-steps show as part of the potential
83898442
* path. Display-only and best-effort.
8443+
*
8444+
* [#24863] A step is named the way the request's own step is: in the
8445+
* reader's locale when `labels` picks its `flows.<flow>.approvals.<node_id>.label`,
8446+
* else by the node's authored label. So the progress strip and the request's
8447+
* `current_step_label` name the current step alike.
83908448
*/
8391-
private async attachFlowSteps(row: ApprovalRequestRow): Promise<void> {
8449+
private async attachFlowSteps(row: ApprovalRequestRow, labels: ReaderLabels | undefined): Promise<void> {
83928450
try {
83938451
const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
83948452
if (!flowName || typeof this.automation?.getFlow !== 'function') return;
@@ -8400,7 +8458,9 @@ export class ApprovalService implements IApprovalService {
84008458
let cur: any = flow.nodes.find((n: any) => n.type === 'start');
84018459
while (cur && !seen.has(cur.id)) {
84028460
seen.add(cur.id);
8403-
if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id });
8461+
if (cur.type === 'approval') {
8462+
steps.push({ id: cur.id, label: labels?.stepLabel(flowName, cur.id) ?? (cur.label || cur.id) });
8463+
}
84048464
const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id);
84058465
if (!out.length) break;
84068466
const pick = out.find((e: any) => e.label === 'approve')

‎packages/plugins/plugin-approvals/src/approvals-plugin.ts‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,20 @@ export class ApprovalsServicePlugin implements Plugin {
216216
}
217217
this.engine = engine;
218218

219+
// The deployment's `i18n` service, read LAZILY at question time: it is
220+
// contributed by another plugin (ADR-0029 D8) and may start after this
221+
// one, so a reading taken here would pin its absence for the life of the
222+
// process. `getService` THROWS on an empty slot. The one read both i18n
223+
// consumers below ask, so they cannot disagree about which service exists.
224+
const i18nService = (): II18nService | undefined => {
225+
try {
226+
const i18n = ctx.getService<II18nService>('i18n');
227+
return typeof i18n?.t === 'function' ? i18n : undefined;
228+
} catch {
229+
return undefined;
230+
}
231+
};
232+
219233
this.service = new ApprovalService({
220234
engine: engine as ApprovalEngine,
221235
logger: ctx.logger,
@@ -248,16 +262,15 @@ export class ApprovalsServicePlugin implements Plugin {
248262
// `translation` for `errors.approval_recall_not_submitter` wins — the
249263
// override address the catalog documents, made real for this emitter.
250264
messageTranslator: () => {
251-
try {
252-
const i18n = ctx.getService<II18nService>('i18n');
253-
const t = i18n?.t;
254-
if (typeof t !== 'function') return undefined;
255-
return (key: string, locale: string, params?: Record<string, unknown>) =>
256-
t.call(i18n, key, locale, params);
257-
} catch {
258-
return undefined;
259-
}
265+
const i18n = i18nService();
266+
if (!i18n) return undefined;
267+
return (key: string, locale: string, params?: Record<string, unknown>) =>
268+
i18n.t(key, locale, params);
260269
},
270+
// [#24863] The same service, for the approvals door's flow, step and
271+
// object labels in the reader's locale (`reader-locale-labels.ts`).
272+
// Without one, the door serves the labels as stored.
273+
i18nServiceSource: i18nService,
261274
});
262275

263276
// [#10749] Field-visibility authority for payload-snapshot redaction.

‎packages/plugins/plugin-approvals/src/display-labels.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
* string counts as no label — the spec allows one (`FlowSchema.label` and
2424
* `FlowNodeSchema.label` are `z.string()` with no minimum) — so the chain falls
2525
* through to the machine name instead of showing a blank cell.
26+
*
27+
* [#24863] The chain answers the AUTHORED label, in the language the flow was
28+
* written in. The approvals door then serves the reader's-locale translation
29+
* over it where the reader's language pack carries one
30+
* (`reader-locale-labels.ts`), a read-time pick that never writes the columns.
31+
* So the lists and the door show the same label to a reader with no
32+
* translation, and a reader whose pack translates the flow or the step sees
33+
* the translation on the door and the stored text on the lists.
2634
*/
2735

2836
/**

0 commit comments

Comments
 (0)