Skip to content
23 changes: 23 additions & 0 deletions .changeset/25652-translation-positions-permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': minor
'@objectstack/cli': minor
'@objectstack/lint': minor
---

feat(spec,cli): positions and permission sets are translatable — `positions.<name>` and `permissions.<name>` in the translation bundle

Clause-②: yes (widening)
Metadata-protocol: added

An app authored in one language ships its positions (`positions[].label`) and permission sets (`permissions[].label`) in that language. Before this change the translation bundle had no key for them: a bundle carrying `positions` was refused by `os validate` as an unrecognized key, so those labels stayed in the authoring language next to translated object, app and flow labels, and `os i18n check` could not report the gap.

- **`@objectstack/spec`:** `TranslationData` (and the registered `translation` item and the platform bundle, which share its groups) gains `positions` and `permissions`, keyed by the position's and the permission set's `name`, each carrying `label` and `description` (strict: any other key is refused — `name` or `title` with a message pointing at `label`, and the group spellings `position`, `permission` and `permissionSets` with a message pointing at `positions` / `permissions`). Two new translators, `translatePosition` and `translatePermissionSet`, overlay them, and both are registered in the metadata-document dispatch, so `GET /meta/position` and `GET /meta/permission` — the lists the console's position and permission-set pickers draw — are served in the request's locale. A key the bundle does not carry keeps the authored string.
- **`@objectstack/lint`:** `translation-target-unknown` reports a `positions.<name>` or `permissions.<name>` key that names no position or permission set a package of the stack declares (the audience anchors `everyone` and `guest` always resolve), as an error with the nearest declared name.
- **`@objectstack/cli`:** `os i18n extract` scaffolds `positions.<name>.label` / `.description` and `permissions.<name>.label` / `.description` from the stack's `positions[]` and `permissions[]`, and `os i18n check` / `os lint` coverage report an untranslated one in its own bucket (`i18n/missing-position`, `i18n/missing-permission`).

Example (`en` bundle of a Chinese-authored app):

```ts
positions: { srm_legal_counsel: { label: 'Legal Counsel' } },
permissions: { srm_contract_reviewer: { label: 'Contract Review', description: 'Reads and approves supplier contracts' } },
```
9 changes: 6 additions & 3 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,9 @@ One locale of a bundle, as JSON — the same groups a `TranslationData` declares
Three rules the shape enforces, all of them closed since #4001:

- **The top level is the declared group set and nothing else** — `objects`,
`apps`, `messages`, `globalActions`, `dashboards`, `datasets`, `pages`,
`flows`, `metadataForms`, `settingsCommon`. A group invented beside them
`picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`,
`pages`, `flows`, `positions`, `permissions`, `metadataForms`,
`settingsCommon`. A group invented beside them
(`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name**
at both authoring doors, with the group to use instead named in the
rejection.
Expand Down Expand Up @@ -482,7 +483,7 @@ what the overlay writes back as the node's `title`.
The contract is `t(key, locale, params?)` — the target locale is always passed
explicitly, and the key itself is returned when no translation is found (there
is no `defaultValue` option). The key is a **dot path into `TranslationData`**,
so it starts at one of the eleven declared groups.
so it starts at one of the thirteen declared groups.

```typescript
const i18n = kernel.getService('i18n');
Expand Down Expand Up @@ -1060,6 +1061,8 @@ beneath an action entry are judged the same way under either.
| `apps.{app}.navigation.{id}` | a navigation item `id` the app declares (nested `children` and `areas[]` included) or one contributed into it; under an app that is only contributed into, the contributed ids alone |
| `dashboards.{dash}` / `.widgets.{id}` / `.actions.{actionUrl}` | a dashboard declared / a widget `id` on it / a header action's `actionUrl` on it |
| `flows.{flow}` | a flow's machine `name`, declared |
| `positions.{name}` | a position's `name`, declared, or an audience anchor (`everyone`, `guest`) |
| `permissions.{name}` | a permission set's `name`, declared |
| `flows.{flow}.screens.{node}` | the `id` of a `type: 'screen'` node in that flow, nodes nested in loop, parallel and try/catch regions included |
| `flows.{flow}.screens.{node}.fields.{field}` | a `config.fields[].name` on that screen — an object-form screen (`config.objectName`) declares none, and its input labels live under `objects.{objectName}.fields` |

Expand Down
2 changes: 2 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1778,6 +1778,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down
48 changes: 48 additions & 0 deletions content/docs/references/system/translation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ Platform translation data — the per-app groups plus the platform-only `setting
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **settings** | `Record<string, { title?: string; description?: string; groups?: Record<string, object>; keys?: Record<string, object>; … }>` | optional | Settings manifest translations keyed by namespace |
Expand Down Expand Up @@ -266,6 +268,20 @@ Translation data for a single object
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `PlatformTranslationData.positions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated position label — overlays the position's own `label` |
| **description** | `string` | optional | Translated position description — overlays the position's own `description` |

### Nested Shape: `PlatformTranslationData.permissions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` |
| **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` |

### Nested Shape: `PlatformTranslationData.metadataForms[string]`

| Property | Type | Required | Description |
Expand Down Expand Up @@ -383,6 +399,8 @@ Per-app translation data for objects, apps, and UI messages
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |

Expand Down Expand Up @@ -469,6 +487,20 @@ Translation data for a single object
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `TranslationData.positions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated position label — overlays the position's own `label` |
| **description** | `string` | optional | Translated position description — overlays the position's own `description` |

### Nested Shape: `TranslationData.permissions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` |
| **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` |

### Nested Shape: `TranslationData.metadataForms[string]`

| Property | Type | Required | Description |
Expand Down Expand Up @@ -536,6 +568,8 @@ One locale of translations — the `translation` metadata type
| **datasets** | `Record<string, { label?: string; description?: string; dimensions?: Record<string, object>; measures?: Record<string, object> }>` | optional | Analytics dataset translations keyed by dataset name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
| **metadataForms** | `Record<string, { label?: string; description?: string; sections?: Record<string, object>; fields?: Record<string, object> }>` | optional | Translations for metadata-type configuration forms keyed by metadata type |
| **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings |
| **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") |
Expand Down Expand Up @@ -632,6 +666,20 @@ Translation data for a single object
| **refusals** | `Record<string, { message?: string }>` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` |
| **approvals** | `Record<string, { label?: string }>` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node |

### Nested Shape: `TranslationItem.positions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated position label — overlays the position's own `label` |
| **description** | `string` | optional | Translated position description — overlays the position's own `description` |

### Nested Shape: `TranslationItem.permissions[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` |
| **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` |

### Nested Shape: `TranslationItem.metadataForms[string]`

| Property | Type | Required | Description |
Expand Down
4 changes: 3 additions & 1 deletion content/docs/ui/translations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,14 @@ export default defineStack({
| Page component copy, by component id | `pages.<name>.components.<id>.title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.<slot>`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` |
| Page tab labels (`page:tabs` items) | `pages.<name>.components.<tabsId>.items.<value>.label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none |
| Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows.<flow>.label` / `flows.<flow>.screens.<node_id>.title` / `.description` / `.fields.<field>.label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below |
| Position label / description | `positions.<name>.label` / `description` — keyed by the position's `name`; overlaid on every served position, which is the list the console's position picker draws |
| Permission-set label / description | `permissions.<name>.label` / `description` — the group is spelled as the stack spells the collection (`permissions[]`), keyed by the set's `name` |
| Global actions, messages | `globalActions`, `messages` |
| Settings UI shell copy (the source badge on a settings row) | `settingsCommon.sourceLabels.<layer>` — the per-namespace settings copy under `settings` is **platform-only**: an app bundle carrying it is refused by name, and the platform's own strings are translated in `@objectstack/service-settings`'s bundle |
| A label written as an inline locale map (`label: { en: 'Members', 'zh-CN': '成员' }`) | Nowhere — it is written on the metadata and resolved at render time; see **Current boundaries** below |

The metadata types resolved per request are **object, view, action, app,
dashboard, dataset, and page** — a field's labels are translated as part of
dashboard, dataset, page, picklist, position and permission** — a field's labels are translated as part of
its object document, and so are the labels of any actions the object declares
inline
(#3370). Before that, an object document went out with its authored action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `system/` | 378 |
| `system/` | 380 |
11 changes: 11 additions & 0 deletions examples/app-crm/src/translations/crm.translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ export const CrmTranslationBundle = defineTranslationBundle({
},
},
},
// The security catalog: what an admin assigns a person to, and what
// Setup lists under it.
positions: {
sales_rep: { label: '销售代表', description: '一线销售代表。' },
sales_manager: { label: '销售经理', description: '管理一支销售代表团队。' },
finance_approver: { label: '财务审批人', description: '有权审批 30% 以上折扣的财务团队成员。' },
},
permissions: {
crm_sales_user: { label: 'CRM 销售用户' },
guest_portal: { label: '访客(公开表单)' },
},
messages: {
'crm.lead.convert.success': '线索已成功转化为商机。',
'crm.lead.convert.error': '线索转化失败,请重试。',
Expand Down
26 changes: 26 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,5 +1311,31 @@ export const ShowcaseTranslationBundle = {
},
},
},
// The security catalog — positions and permission sets. Counted by
// `check-i18n-coverage` like every other declared label: a new position or
// set that skips zh-CN widens the debt.
positions: {
contributor: { label: '贡献者', description: '处理自己项目中的任务。' },
manager: { label: '项目经理', description: '管理项目及其中的贡献者。' },
exec: { label: '高管', description: '用于报表的全量只读可见性。' },
auditor: { label: '审计员', description: '跨私有记录的合规只读视图(viewAllRecords)。' },
ops: { label: '运营', description: '后台运营 — Setup 访问、公告修复、运营应用。' },
field_ops_delegate: { label: '现场运营委派管理员', description: '对现场运营业务单元子树的范围化管理。' },
client_liaison: { label: '客户联络人', description: '准备并发布面向客户的项目简报;看不到内部预算数据。' },
client_portal_user: { label: '客户门户用户', description: '获准进入客户门户的外部客户。' },
finance: { label: '财务', description: '发票和大额费用的财务签批权(仅用于审批路由)。' },
legal: { label: '法务', description: '发票和大额费用的法务签批权(仅用于审批路由)。' },
},
permissions: {
showcase_contributor: { label: '示例贡献者' },
showcase_manager: { label: '示例经理' },
showcase_executive: { label: '示例高管' },
showcase_auditor: { label: '示例审计员' },
showcase_ops: { label: '示例运营' },
showcase_member_default: { label: '示例成员(默认)' },
showcase_guest_portal: { label: '示例访客门户' },
showcase_field_ops_delegate: { label: '现场运营委派管理员' },
showcase_client_liaison: { label: '示例客户联络人' },
},
},
};
Loading
Loading