From 340dc903ec096aaa46acebe6b0611c8a914ce6ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:47:13 +0000 Subject: [PATCH 1/7] wip(spec): positions and permissions translation groups with their /meta translators Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- packages/spec/liveness/translation.json | 40 +++++++++ packages/spec/src/system/i18n-resolver.ts | 90 +++++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 73 ++++++++++++++--- 3 files changed, 190 insertions(+), 13 deletions(-) diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index c0898b04fc5..b6f6dda7e5c 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -186,6 +186,46 @@ }, "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: the per-field keys are `label`, `placeholder` and `inlineHelpText`, each spelled as the screen field spells the string it overlays. The report's `help` was never a key here: when #7646 ruled the face the screen field declared nothing help-shaped, so it would have parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the page-component face). #17306 gave the screen field `inlineHelpText`, and the face gained it on 2026-10-02 once the console drew the help text; `help` / `helpText` / `hint` / `tooltip` / `description` are aliases onto it on the field surface. `options` stayed out, on `guidance`, because a value-keyed map could not address `ScreenFieldConfig.options[].value`, which is unconstrained (superseded 2026-10-09, see the end of this note). Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file. 2026-09-27 (#20296): the `screens` child FLIPPED to `live` (objectui's FlowRunner reads it at the `.objectui-sha` pin f8a9d0fb; see that row), and `authorHint` was rewritten to say so. The container keeps `planned` + `authorWarn` for `label` alone, which nothing reads yet (#20318). The CLI i18n coverage gate keys its whole-group `flows.*` demand off this `authorWarn`, so dropping the bit waits for that reader, and then the whole group flips. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day. 2026-10-09 (#22507): the closing pass for user-read flow text. The face gains the terminal toasts (`successMessage` / `errorMessage`, child rows `planned`) and, inside `screens`, a screen field's option labels (`fields..options.`, each option keyed by `String(value)`; `ScreenFieldConfigSchema` refuses two options of one field that collide as text). Both are read by `translateFlow` and await the objectui runner change, so neither is drawn translated yet. The container stays `live`: its `label` and `screens` readers are unchanged. The screen `description` stays unkeyed and is recorded as owed in `packages/spec/src/system/flows-translation-face.test.ts`: it is a `{{ }}` template the server renders per run, so its reader is a decision of its own. 2026-10-10 (#22507, maintainer ruling A): that decision is taken. The screen face gains `description`, and the engine picks the translated `title` and `description` templates in the run's locale before it renders them (see the `screens` row). The container stays `live`." }, + "positions": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translatePosition (`positions..label` / `.description`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/rest/src/meta-item-read-gate.ts#translateMetaList (the /meta list read that applies it per request locale)", + "note": "Seeded LIVE with its reader in the same change (the datasets precedent): a served `position` document is localized at the REST boundary, and that list is what the console's `position-ref` picker draws (`GET /meta/position`). The face stops at the two strings the position itself authors.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupSecurityCatalogAttr (`positions..label`, read by translatePosition)", + "note": "Overlays the served position's own `label`." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupSecurityCatalogAttr (`positions..description`, read by translatePosition)", + "note": "Overlays the served position's own `description`." + } + } + }, + "permissions": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translatePermissionSet (`permissions..label` / `.description`, registered in METADATA_DOCUMENT_TRANSLATORS); packages/rest/src/meta-item-read-gate.ts#translateMetaList (the /meta list read that applies it per request locale)", + "note": "Seeded LIVE with its reader in the same change (the datasets precedent): a served `permission` document is localized at the REST boundary, and that list is what the console's `permission-ref` picker draws (`GET /meta/permission`). The face stops at the two strings the permission set itself authors.", + "children": { + "label": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupSecurityCatalogAttr (`permissions..label`, read by translatePermissionSet)", + "note": "Overlays the served permission set's own `label`." + }, + "description": { + "status": "live", + "verifiedAt": "2026-10-24", + "evidence": "packages/spec/src/system/i18n-resolver.ts#lookupSecurityCatalogAttr (`permissions..description`, read by translatePermissionSet)", + "note": "Overlays the served permission set's own `description`." + } + } + }, "metadataForms": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 1e895437cf7..6dafcfe3b09 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -1500,6 +1500,11 @@ const METADATA_DOCUMENT_TRANSLATORS: Record< dataset: translateDataset, page: translatePage, picklist: translatePicklist, + // The security catalog's two display surfaces: the console's `position-ref` + // and `permission-ref` pickers list `GET /meta/position` and + // `GET /meta/permission`, so registering here is the whole wiring. + position: translatePosition, + permission: translatePermissionSet, }; /** @@ -2129,6 +2134,91 @@ export function translateDataset( }; } +// ──────────────────────────────────────────────────────────────────────────── +// Security catalog resolvers (position / permission set: label + description) +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimal shape of a security-catalog document — a position or a permission set. */ +export interface SecurityCatalogItemLike { + name: string; + label?: string; + description?: string; + [key: string]: any; +} + +/** The two bundle groups addressing a security-catalog document, by its own name. */ +type SecurityCatalogGroup = 'positions' | 'permissions'; + +function lookupSecurityCatalogAttr( + bundle: TranslationBundle | undefined, + group: SecurityCatalogGroup, + name: string, + attr: 'label' | 'description', + opts?: ResolveOptions, +): string | undefined { + if (!bundle) return undefined; + for (const code of localeChain(opts)) { + const candidate = pickData(bundle, code)?.[group]?.[name]?.[attr]; + if (typeof candidate === 'string' && candidate.length > 0) return candidate; + } + return undefined; +} + +function translateSecurityCatalogItem( + group: SecurityCatalogGroup, + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + if (!doc || typeof doc !== 'object') return doc; + const name = doc.name; + if (typeof name !== 'string' || name.length === 0 || !bundle) return doc; + const label = lookupSecurityCatalogAttr(bundle, group, name, 'label', opts); + const description = lookupSecurityCatalogAttr(bundle, group, name, 'description', opts); + if (label === undefined && description === undefined) return doc; + return { + ...doc, + ...(label !== undefined ? { label } : {}), + ...(description !== undefined ? { description } : {}), + }; +} + +/** + * Apply the active locale to a position metadata document — its `label` and + * `description` against `positions..label` / `.description`. The input + * document is not mutated, and it comes back as the same reference when the + * bundle carries neither key. + * + * A position is what an admin assigns a person to and what an approval step + * waits on, so it is read next to object, app and flow labels the same bundle + * translates. Registered in {@link METADATA_DOCUMENT_TRANSLATORS}, so the REST + * boundary localizes every served position (`GET /meta/position`, the list the + * console's `position-ref` picker draws) with nothing else to remember. + */ +export function translatePosition( + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + return translateSecurityCatalogItem('positions', doc, bundle, opts); +} + +/** + * Apply the active locale to a permission-set metadata document — its `label` + * and `description` against `permissions..label` / `.description` (the + * group is spelled as the stack spells the collection). Same posture as + * {@link translatePosition}: no mutation, the same reference when nothing + * matched, and registered so `GET /meta/permission` (the `permission-ref` + * picker's list) is localized at the REST boundary. + */ +export function translatePermissionSet( + doc: T, + bundle: TranslationBundle | undefined, + opts?: ResolveOptions, +): T { + return translateSecurityCatalogItem('permissions', doc, bundle, opts); +} + // ──────────────────────────────────────────────────────────────────────────── // Page metadata resolvers (label / description / page:header title + subtitle) // ──────────────────────────────────────────────────────────────────────────── diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 7b403ee4cf4..f5443c49197 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -685,9 +685,10 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + 'application\'s own copy use the eleven groups this bundle does declare, in the order it ' + + 'application\'s own copy use the thirteen groups this bundle does declare, in the order it ' + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " - + "'datasets', 'pages', 'flows', 'metadataForms', 'settingsCommon'. Note the last one: 'settingsCommon' IS " + + "'datasets', 'pages', 'flows', 'positions', 'permissions', 'metadataForms', 'settingsCommon'. " + + "Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' + 'badges, under `settingsCommon.sourceLabels`) are NOT what is being refused here — only the ' + "per-namespace manifest copy under 'settings' is. " @@ -728,7 +729,7 @@ const ITEM_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than overriding it from an application. For an ' - + 'application\'s own copy use the groups this item does declare — the same eleven a per-app ' + + 'application\'s own copy use the groups this item does declare — the same thirteen a per-app ' + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + "refused here — only the per-namespace manifest copy under 'settings' is. " @@ -767,12 +768,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Eleven groups — the twelfth, `settings`, is platform-only and lives in + * schema. Thirteen groups — the fourteenth, `settings`, is platform-only and lives in * {@link platformSettingsShape}. * * Three schemas need exactly these keys: {@link TranslationDataSchema} (one - * entry of a per-app file-authored bundle — these eleven and no more), - * {@link PlatformTranslationDataSchema} (these eleven plus `settings`) and + * entry of a per-app file-authored bundle — these thirteen and no more), + * {@link PlatformTranslationDataSchema} (these thirteen plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — * the per-app face plus `locale`, its identity keys and the ADR-0010 * envelope; it carried the platform face's `settings` too until #19620). The item used to @@ -1613,6 +1614,52 @@ const appTranslationDataShape = () => ({ })).optional().describe("Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node"), })).optional().describe('Screen-flow translations keyed by flow name'), + /** + * Position translations keyed by position name (`Position.name`, + * `identity/position.zod.ts`, the stack's `positions[]`). + * + * positions..label → the position's own `label` + * positions..description → the position's own `description` + * + * A position is what an admin assigns a person to and what an approval step + * is pending on, so its label is read next to object, app and flow labels + * that the same bundle translates. Overlaid by `translatePosition` (`i18n-resolver.ts`) on + * every served `position` document (`GET /meta/position`, the catalog the + * console's `position-ref` picker lists); the face stops at the two strings + * the position itself authors. + */ + positions: z.record(z.string(), strictObject({ + surface: 'this position translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label' }, + }, { + label: z.string().optional().describe("Translated position label — overlays the position's own `label`"), + description: z.string().optional().describe("Translated position description — overlays the position's own `description`"), + })).optional().describe('Position translations keyed by position name'), + + /** + * Permission-set translations keyed by permission-set name + * (`PermissionSet.name`, `security/permission.zod.ts`, the stack's + * `permissions[]` — the group is spelled as the stack spells the + * collection). + * + * permissions..label → the permission set's own `label` + * permissions..description → the permission set's own `description` + * + * Overlaid by `translatePermissionSet` (`i18n-resolver.ts`) on every served `permission` + * document (`GET /meta/permission`, the catalog the console's + * `permission-ref` picker lists). The face stops at the two strings the set + * itself authors: its grants are machine names, not copy. + */ + permissions: z.record(z.string(), strictObject({ + surface: 'this permission-set translation', + history: TRANSLATION_HISTORY, + aliases: { name: 'label', title: 'label' }, + }, { + label: z.string().optional().describe("Translated permission-set label — overlays the set's own `label`"), + description: z.string().optional().describe("Translated permission-set description — overlays the set's own `description`"), + })).optional().describe('Permission-set translations keyed by permission-set name'), + /** * Translations for **metadata-type configuration forms** — the forms * used by admins to author objects, fields, agents, flows, etc. in the @@ -1827,10 +1874,10 @@ const platformSettingsShape = () => ({ * One locale of a PER-APP translation bundle — `stack.translations`, and * everything {@link defineTranslationBundle} builds. * - * Eleven groups: every group the platform bundle declares EXCEPT `settings`, + * Thirteen groups: every group the platform bundle declares EXCEPT `settings`, * which is platform-only and is refused here by name with * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See - * {@link PlatformTranslationDataSchema} for the twelve-group face and for why + * {@link PlatformTranslationDataSchema} for the fourteen-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -1841,7 +1888,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ // `settings`, and an alias prescribing a key the shape rejects is a // suggestion the author cannot take (the `alias-integrity` audit judges // exactly that). Both spellings are answered by `guidance` above instead. - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -1851,7 +1898,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the twelve groups, `settings` + * One locale of a PLATFORM translation bundle — the fourteen groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -1886,7 +1933,7 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the platform translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -2002,7 +2049,7 @@ export type TranslationConfig = z.input; * to whoever — or whatever — authored it. * * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): - * the item takes the PER-APP face, the same eleven groups as + * the item takes the PER-APP face, the same thirteen groups as * {@link TranslationDataSchema}, and refuses `settings` (and the singular * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. * The file door and the item door are two authoring surfaces for one app @@ -2041,7 +2088,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // this door no longer declares `settings`, and an alias prescribing a key // the shape rejects is a suggestion the author cannot take. Both spellings // are answered by `guidance` above instead. - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', picklist: 'picklists', flow: 'flows', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), From 85029292ef66a038912ea378f76d3c54e03daf19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:51:21 +0000 Subject: [PATCH 2/7] wip(cli,spec): walk the security-catalog translation groups; tests Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- packages/cli/src/utils/i18n-coverage.ts | 13 ++ packages/cli/src/utils/i18n-extract.ts | 45 ++++++ .../test/i18n-security-catalog-walk.test.ts | 92 +++++++++++++ packages/spec/authorable-surface/system.json | 6 + .../src/system/i18n-security-catalog.test.ts | 130 ++++++++++++++++++ .../i18n-walk-parity/every-group.stack.json | 6 + 6 files changed, 292 insertions(+) create mode 100644 packages/cli/test/i18n-security-catalog-walk.test.ts create mode 100644 packages/spec/src/system/i18n-security-catalog.test.ts diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 01cc1fac793..66fea336b68 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -70,6 +70,8 @@ export interface CoverageIssue { | 'picklist' | 'page' | 'flow' + | 'position' + | 'permission' | 'metadataForm'; /** Human-readable explanation. */ message: string; @@ -369,6 +371,13 @@ const COVERAGE_SOURCE: Record // locales (#11485). Its six approval flows stayed that invisible until // #24916. flow: 'flow', + // Security-catalog copy (`positions.

.label` / `.description`, + // `permissions..label` / `.description`) — the author's own positions and + // permission sets, read in Setup and on every approval step that waits on a + // position, so each keeps its own bucket and reports as + // `i18n/missing-position` / `i18n/missing-permission`. + position: 'position', + permission: 'permission', metadataType: 'metadataForm', metadataFormSection: 'metadataForm', metadataFormField: 'metadataForm', @@ -390,6 +399,8 @@ const SOURCE_NOUN: Record = { picklist: 'Picklist', page: 'Page', flow: 'Flow', + position: 'Position', + permission: 'Permission set', metadataForm: 'Metadata form', }; @@ -432,6 +443,8 @@ const SOURCE_SURFACE: Record = { picklist: 'picklists', page: 'pages', flow: 'flow screens, approval steps, toasts and refusals', + position: 'positions', + permission: 'permission sets', metadataForm: 'metadata forms', }; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 646ba149dcf..e930860d287 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -121,6 +121,8 @@ * ^ walked because the ledger's `flows` row is `live` and warns no * author; a group the ledger does warn on is left out of the walk * (see `authorWarnedTranslationGroups`) + * positions..label / .description + * permissions..label / .description * metadataForms..label / .description * metadataForms..sections.

.label / .description * metadataForms..fields..label / .helpText / .placeholder @@ -224,6 +226,8 @@ export interface ExpectedEntry { | 'picklist' | 'page' | 'flow' + | 'position' + | 'permission' | 'metadataType' | 'metadataFormSection' | 'metadataFormField'; @@ -1607,6 +1611,9 @@ export function collectExpectedEntries( // ── Shared option lists (`picklists..…`) ──────────────────── walkPicklists(config, out); + // ── Security catalog (`positions..…`, `permissions..…`) ─ + walkSecurityCatalog(config, out); + // ── Pages + their `page:header` copy ────────────────────────────── const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; for (const page of pages) { @@ -1844,6 +1851,44 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void { } } +// ─── Security catalog (`positions..…`, `permissions..…`) ─── + +/** + * Emit the security-catalog copy surface: + * + * positions..label / .description + * permissions..label / .description + * + * Read off the stack's `positions[]` and `permissions[]` (the permission-set + * collection — the bundle group is spelled as the stack spells it). Both are + * served through `translatePosition` / `translatePermissionSet`, registered in + * `METADATA_DOCUMENT_TRANSLATORS`, so the REST boundary localizes them; this + * pass is the half that writes the skeleton and lets `os i18n check` see the + * gap. + * + * `pushOptional` for every key: a permission set's `label` and both + * `description`s are optional at the authoring site, and no renderer fallback + * for an absent one is measured, so a string nobody wrote is recorded without + * an `inline` and never demanded as a translation. A position's `label` is + * required, so it is always authored text. + */ +function walkSecurityCatalog(config: any, out: ExpectedEntry[]): void { + const groups = [ + { group: 'positions', items: config?.positions, source: 'position' }, + { group: 'permissions', items: config?.permissions, source: 'permission' }, + ] as const; + for (const { group, items, source } of groups) { + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const name = item.name; + if (typeof name !== 'string' || name.length === 0) continue; + pushOptional(out, [group, name, 'label'], item.label, source); + pushOptional(out, [group, name, 'description'], item.description, source); + } + } +} + // ─── Screen flows (`flows..screens..…`) ───────────────── /** diff --git a/packages/cli/test/i18n-security-catalog-walk.test.ts b/packages/cli/test/i18n-security-catalog-walk.test.ts new file mode 100644 index 00000000000..4e26faf9ea7 --- /dev/null +++ b/packages/cli/test/i18n-security-catalog-walk.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// `positions..…` and `permissions..…` — the extractor walks the +// security catalog's two translation groups. +// +// A position is what an admin assigns a person to and what an approval step +// waits on; a permission set is what Setup lists under it. Both are served +// through `translatePosition` / `translatePermissionSet`, so without this walk +// `os i18n extract` scaffolded no key for them and `os i18n check` measured a +// debt of zero while the labels shipped in the authoring language. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema } from '@objectstack/spec/system'; + +const config = () => ({ + positions: [ + { name: 'srm_legal_counsel', label: 'Legal Counsel', description: 'Reviews supplier contracts' }, + { name: 'srm_buyer', label: 'Buyer' }, + ], + permissions: [ + { name: 'srm_contract_reviewer', label: 'Contract Review', description: 'Reads and approves contracts', objects: {} }, + // No label, no description: nothing an author wrote, so nothing is demanded. + { name: 'srm_unlabelled', objects: {} }, + ], +}); + +const keysOf = (group: string) => + collectExpectedEntries(config()) + .filter((e) => e.path[0] === group) + .map((e) => e.path.join('.')) + .sort(); + +describe('i18n extract — the `positions` and `permissions` groups', () => { + it('walks each position\'s label and description', () => { + expect(keysOf('positions')).toEqual([ + 'positions.srm_buyer.description', + 'positions.srm_buyer.label', + 'positions.srm_legal_counsel.description', + 'positions.srm_legal_counsel.label', + ]); + }); + + it('walks each permission set\'s label and description', () => { + expect(keysOf('permissions')).toEqual([ + 'permissions.srm_contract_reviewer.description', + 'permissions.srm_contract_reviewer.label', + 'permissions.srm_unlabelled.description', + 'permissions.srm_unlabelled.label', + ]); + }); + + it('seeds authored text and records an unwritten string without demanding it', () => { + const byKey = new Map(collectExpectedEntries(config()).map((e) => [e.path.join('.'), e])); + expect(byKey.get('positions.srm_legal_counsel.label')) + .toMatchObject({ sourceValue: 'Legal Counsel', inline: 'Legal Counsel', source: 'position' }); + expect(byKey.get('permissions.srm_contract_reviewer.description')) + .toMatchObject({ sourceValue: 'Reads and approves contracts', source: 'permission' }); + const unwritten = byKey.get('positions.srm_buyer.description'); + expect(unwritten?.sourceValue).toBeUndefined(); + expect(unwritten?.inline).toBeUndefined(); + }); + + it('`TranslationDataSchema` accepts a bundle written at the extracted paths', () => { + const out = extractTranslations(config(), { locales: ['zh-CN'] }); + const zh = out.bundles['zh-CN'] as any; + expect(Object.keys(zh?.positions ?? {}).sort()).toEqual(['srm_buyer', 'srm_legal_counsel']); + expect(zh?.permissions?.srm_contract_reviewer?.label).toBeDefined(); + expect(TranslationDataSchema.safeParse({ positions: zh.positions, permissions: zh.permissions }).success).toBe(true); + }); + + it('reports an untranslated label under its own bucket', () => { + const report = computeI18nCoverage({ + ...config(), + i18n: { supportedLocales: ['en', 'zh-CN'] }, + translations: [ + { 'zh-CN': { positions: { srm_legal_counsel: { label: '法务顾问', description: '审核供应商合同' } } } }, + ], + }); + const zhIssues = report.issues.filter((i) => i.locale === 'zh-CN'); + const keys = zhIssues.map((i) => i.key); + expect(keys).toContain('positions.srm_buyer.label'); + expect(keys).toContain('permissions.srm_contract_reviewer.label'); + expect(keys).not.toContain('positions.srm_legal_counsel.label'); + // Nothing authored ⇒ nothing to translate. + expect(keys).not.toContain('positions.srm_buyer.description'); + expect(keys).not.toContain('permissions.srm_unlabelled.label'); + expect(zhIssues.find((i) => i.key === 'positions.srm_buyer.label')?.source).toBe('position'); + expect(zhIssues.find((i) => i.key === 'permissions.srm_contract_reviewer.label')?.source).toBe('permission'); + }); +}); diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 6569474eebe..d26d154aab2 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -905,7 +905,9 @@ "system/PlatformTranslationData:metadataForms", "system/PlatformTranslationData:objects", "system/PlatformTranslationData:pages", + "system/PlatformTranslationData:permissions", "system/PlatformTranslationData:picklists", + "system/PlatformTranslationData:positions", "system/PlatformTranslationData:settings", "system/PlatformTranslationData:settingsCommon", "system/PresignedUrlConfig:contentType", @@ -1319,7 +1321,9 @@ "system/TranslationData:metadataForms", "system/TranslationData:objects", "system/TranslationData:pages", + "system/TranslationData:permissions", "system/TranslationData:picklists", + "system/TranslationData:positions", "system/TranslationData:settingsCommon", "system/TranslationDiffItem:aiConfidence", "system/TranslationDiffItem:aiSuggested", @@ -1347,7 +1351,9 @@ "system/TranslationItem:name", "system/TranslationItem:objects", "system/TranslationItem:pages", + "system/TranslationItem:permissions", "system/TranslationItem:picklists", + "system/TranslationItem:positions", "system/TranslationItem:settingsCommon", "system/VectorClock:clock", "system/WorkerStats:active", diff --git a/packages/spec/src/system/i18n-security-catalog.test.ts b/packages/spec/src/system/i18n-security-catalog.test.ts new file mode 100644 index 00000000000..7e8e4b55faa --- /dev/null +++ b/packages/spec/src/system/i18n-security-catalog.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `positions..…` and `permissions..…` — the security catalog's two +// translation groups, and the translators that overlay them on the served +// `position` / `permission` documents. +// +// Before these groups existed, an app authored in one language shipped its +// positions and permission sets in that language to every workspace: the +// bundle had no key for them, so `os validate` refused one and `os i18n check` +// could not report the gap. + +import { describe, it, expect } from 'vitest'; +import { + TranslationDataSchema, + TranslationItemSchema, + PlatformTranslationDataSchema, + type TranslationBundle, +} from './translation.zod'; +import { + translateMetadataDocument, + translatePosition, + translatePermissionSet, + TRANSLATABLE_METADATA_TYPES, +} from './i18n-resolver'; +// The fixtures are PARSED by the authoring schemas rather than hand-shaped, so +// a translator reading a member the schema does not carry cannot pass here. +import { PositionSchema } from '../identity/position.zod'; +import { PermissionSetSchema } from '../security/permission.zod'; + +const position = () => PositionSchema.parse({ + name: 'srm_legal_counsel', + label: '法务顾问', + description: '审核供应商合同', + permissionSets: ['srm_contract_reviewer'], +}) as any; + +const permissionSet = () => PermissionSetSchema.parse({ + name: 'srm_contract_reviewer', + label: '合同审核', + description: '读取并审批供应商合同', + objects: {}, +}) as any; + +const bundle: TranslationBundle = { + en: { + positions: { srm_legal_counsel: { label: 'Legal Counsel', description: 'Reviews supplier contracts' } }, + permissions: { srm_contract_reviewer: { label: 'Contract Review' } }, + }, +}; + +describe('TranslationDataSchema — positions and permissions', () => { + it('accepts the bundle the card measured as refused', () => { + // The exact entry `objectstack validate` refused with + // "Unrecognized key(s) on this locale of the translation bundle: positions". + const result = TranslationDataSchema.safeParse({ positions: { srm_legal_counsel: { label: 'Legal Counsel' } } }); + expect(result.success).toBe(true); + expect(TranslationDataSchema.safeParse(bundle.en).success).toBe(true); + }); + + it('opens at all three doors — bundle entry, platform bundle and registered item', () => { + expect(PlatformTranslationDataSchema.safeParse(bundle.en).success).toBe(true); + expect(TranslationItemSchema.safeParse({ locale: 'en', ...bundle.en }).success).toBe(true); + }); + + it('closes the face at `label` and `description`', () => { + for (const group of ['positions', 'permissions']) { + const result = TranslationDataSchema.safeParse({ [group]: { x: { permissionSets: 'nope' } } }); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + }); + + it('renames the singular and collection spellings onto the group', () => { + expect(() => TranslationDataSchema.parse({ position: { p: { label: 'x' } } })).toThrow(/positions/); + expect(() => TranslationDataSchema.parse({ permission: { s: { label: 'x' } } })).toThrow(/permissions/); + expect(() => TranslationDataSchema.parse({ permissionSets: { s: { label: 'x' } } })).toThrow(/permissions/); + expect(() => TranslationDataSchema.parse({ positions: { p: { title: 'x' } } })).toThrow(/label/); + }); +}); + +describe('translatePosition / translatePermissionSet', () => { + it('are registered in the dispatch table, so the REST boundary follows', () => { + // `TRANSLATABLE_METADATA_TYPES` is DERIVED from the dispatch table and + // `@objectstack/rest` reads it: `GET /meta/position` and + // `GET /meta/permission` are localized with nothing else to remember. + expect(TRANSLATABLE_METADATA_TYPES.has('position')).toBe(true); + expect(TRANSLATABLE_METADATA_TYPES.has('permission')).toBe(true); + }); + + it('overlays a position label and description', () => { + const out = translateMetadataDocument('position', position(), bundle, { locale: 'en' }) as any; + expect(out.label).toBe('Legal Counsel'); + expect(out.description).toBe('Reviews supplier contracts'); + // Identity and the capability binding ride through untouched. + expect(out.name).toBe('srm_legal_counsel'); + expect(out.permissionSets).toEqual(['srm_contract_reviewer']); + }); + + it('overlays a permission-set label and keeps an unaddressed description as authored', () => { + const out = translateMetadataDocument('permission', permissionSet(), bundle, { locale: 'en' }) as any; + expect(out.label).toBe('Contract Review'); + expect(out.description).toBe('读取并审批供应商合同'); + }); + + it('reads each document from its own group only', () => { + // A position and a permission set may share a name; the group decides. + const shared: TranslationBundle = { en: { positions: { auditor: { label: 'Auditor (position)' } } } }; + const set = PermissionSetSchema.parse({ name: 'auditor', label: '审计', objects: {} }) as any; + expect(translatePermissionSet(set, shared, { locale: 'en' })).toBe(set); + const pos = PositionSchema.parse({ name: 'auditor', label: '审计' }) as any; + expect(translatePosition(pos, shared, { locale: 'en' }).label).toBe('Auditor (position)'); + }); + + it('applies the BCP-47 ladder and the fallback chain', () => { + expect((translatePosition(position(), bundle, { locale: 'en-GB' }) as any).label).toBe('Legal Counsel'); + expect((translatePosition(position(), bundle, { locale: 'fr-FR', fallbackChain: ['en'] }) as any).label) + .toBe('Legal Counsel'); + }); + + it('does not mutate the input, and returns the same reference when nothing matched', () => { + const doc = position(); + const before = JSON.parse(JSON.stringify(doc)); + translatePosition(doc, bundle, { locale: 'en' }); + expect(doc).toEqual(before); + expect(translatePosition(doc, bundle, { locale: 'ja-JP' })).toBe(doc); + expect(translatePosition(doc, undefined, { locale: 'en' })).toBe(doc); + const anonymous = { label: 'x' } as any; + expect(translatePosition(anonymous, bundle, { locale: 'en' })).toBe(anonymous); + }); +}); diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index b96339c4d67..6933a991b73 100644 --- a/scripts/fixtures/i18n-walk-parity/every-group.stack.json +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -169,5 +169,11 @@ } ] } + ], + "positions": [ + { "name": "parity_reviewer", "label": "Parity Reviewer", "description": "Reviews parity findings" } + ], + "permissions": [ + { "name": "parity_auditor", "label": "Parity Auditor", "description": "Reads parity findings" } ] } From 776c633db8d0dac723e430b6b11d4592a88dbbbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:58:16 +0000 Subject: [PATCH 3/7] wip: runtime census cells, docs, changeset Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- ...25652-translation-positions-permissions.md | 21 +++++++++++++++++++ .../docs/protocol/kernel/i18n-standard.mdx | 10 ++++++--- content/docs/ui/translations.mdx | 4 +++- .../meta-list-projection-parity.test.ts | 15 ++++++++++++- 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 .changeset/25652-translation-positions-permissions.md diff --git a/.changeset/25652-translation-positions-permissions.md b/.changeset/25652-translation-positions-permissions.md new file mode 100644 index 00000000000..e3a13c422e8 --- /dev/null +++ b/.changeset/25652-translation-positions-permissions.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +--- + +feat(spec,cli): positions and permission sets are translatable — `positions.` and `permissions.` 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; `name` and `title` are renamed onto `label`, and the group spellings `position`, `permission` and `permissionSets` onto the declared groups). 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/cli`:** `os i18n extract` scaffolds `positions..label` / `.description` and `permissions..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' } }, +``` diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index aa5b3475c05..f7a42006272 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -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. @@ -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'); @@ -1091,6 +1092,9 @@ metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against. (`settings` is not checked either, and since it left the per-app bundle it cannot appear in one at all — the parse refuses it before any lint runs.) +`positions` and `permissions` are not checked either: a key there is resolved +by the position's or permission set's `name` at read time, and one that names +nothing simply translates nothing. ### Translation Service Integration diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 959e254effc..521aa97e574 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -82,12 +82,14 @@ export default defineStack({ | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | | Page tab labels (`page:tabs` items) | `pages..components..items..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..label` / `flows..screens..title` / `.description` / `.fields..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..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..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.` — 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 diff --git a/packages/runtime/src/domains/meta-list-projection-parity.test.ts b/packages/runtime/src/domains/meta-list-projection-parity.test.ts index afc60a1c45a..b0b486e54ab 100644 --- a/packages/runtime/src/domains/meta-list-projection-parity.test.ts +++ b/packages/runtime/src/domains/meta-list-projection-parity.test.ts @@ -172,6 +172,9 @@ const STORE: Record = { { name: 'sales_rep', label: 'Sales rep', permissionSets: ['crm_sales_user'] }, { name: 'sales_manager', label: 'Sales manager', permissionSets: [] }, ], + // [#25652] Translated by `translatePermissionSet`, as a position is by + // `translatePosition` — both are cells of the translatable-type axis. + permission: [{ name: 'crm_sales_user', label: 'Sales user', objects: {} }], }; /** @@ -218,6 +221,8 @@ const BUNDLE: Record = { apps: { crm: { label: '客户管理' }, helpdesk: { label: '服务台' } }, objects: { invoice: { label: '发票' } }, pages: { home: { label: '首页' } }, + positions: { sales_rep: { label: '销售代表' } }, + permissions: { crm_sales_user: { label: '销售用户' } }, }, }; @@ -512,7 +517,7 @@ const PARAM_AXIS = new Set(PARAM_PROBES.map((p) => p.param).filter((p): p is str const TYPE_CELLS: readonly string[] = [ 'app', 'apps', 'view', 'views', 'doc', 'docs', 'book', 'books', 'api', 'apis', 'object', 'objects', 'dashboard', 'dashboards', 'page', 'action', 'dataset', 'picklist', 'flow', - 'position', 'positions', + 'position', 'positions', 'permission', 'permissions', // [#20408] A segment that names no metadata type: `RestServer` refuses it // (`refuseUnknownMetaListType`) rather than listing an empty collection. 'totally_invented_type', @@ -672,6 +677,11 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\ ]); expect(text(await read('/meta/positions'))).not.toContain('_holderCount'); expect((await bootRest('non-holder').read('/meta/position', { include: 'grants' })).items?.[0]?._holderCount).toBeNull(); + // [#25652] The security catalog is translated like every other translatable type. + const zhPositions = await read('/meta/positions', {}, { 'accept-language': 'zh-CN' }); + expect(zhPositions.items?.find((p: any) => p.name === 'sales_rep')?.label).toBe('销售代表'); + const zhSets = await read('/meta/permission', {}, { 'accept-language': 'zh-CN' }); + expect(zhSets.items?.find((p: any) => p.name === 'crm_sales_user')?.label).toBe('销售用户'); }); }); @@ -842,6 +852,7 @@ const ITEM_CELLS: readonly string[] = [ '/meta/object/invoice', '/meta/objects/invoice', '/meta/dashboard/ops', '/meta/dashboards/ops', '/meta/page/home', '/meta/action/close_case', '/meta/dataset/pipeline', '/meta/picklist/industry', '/meta/flow/on_lead', '/meta/api/crm_served', + '/meta/position/sales_rep', '/meta/permission/crm_sales_user', ]; const ITEM_TYPE_AXIS = new Set(ITEM_CELLS.map((c) => singular(c.split('/')[2]))); const ITEM_CALLERS = ['holder', 'non-holder', 'builder', 'author'] as const satisfies readonly CallerName[]; @@ -985,6 +996,8 @@ describe('[#20408] the item read: the reference moves — each probe changes Res it('translation, the doc locale, sortability, both draft switches in any case, and the object preview all move', async () => { const { read } = bootRest('holder'); expect((await read('/meta/app/crm', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('客户管理'); + expect((await read('/meta/position/sales_rep', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售代表'); + expect((await read('/meta/permission/crm_sales_user', {}, { 'accept-language': 'zh-CN' })).item?.label).toBe('销售用户'); const zhDoc = await read('/meta/doc/crm_intro', { locale: 'zh-CN' }); expect(zhDoc.item?.label).toBe('入门'); expect(text(zhDoc)).not.toContain('"translations"'); From 8ec6209f102b3b9babb09011fcb70fa05d88924b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:05:39 +0000 Subject: [PATCH 4/7] chore(spec): regenerate api-surface, export-origins, references, strictness and liveness counts Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 + .../docs/references/system/translation.mdx | 48 +++++++++++++++++++ .../system.md | 2 +- packages/spec/api-surface/system.json | 3 ++ packages/spec/export-origins/system.json | 3 ++ .../spec/liveness/state-counts/translation.md | 2 +- 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 7ba9c49b4d2..e1ad2b1bb97 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1778,6 +1778,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 3f2ffabd6b3..a7ec6abeaf2 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -179,6 +179,8 @@ Platform translation data — the per-app groups plus the platform-only `setting | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -266,6 +268,20 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | 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 | @@ -383,6 +399,8 @@ Per-app translation data for objects, apps, and UI messages | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -469,6 +487,20 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | 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 | @@ -536,6 +568,8 @@ One locale of translations — the `translation` metadata type | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **positions** | `Record` | optional | Position translations keyed by position name | +| **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | 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") | @@ -632,6 +666,20 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | 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 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 7ac492a8074..3bbfc220d3b 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 378 | +| `system/` | 380 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index d9b6ccd41af..7e16b7abdfe 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -598,6 +598,7 @@ "SearchIndexConfigSchema (const)", "SearchProvider (type)", "SearchProviderSchema (const)", + "SecurityCatalogItemLike (interface)", "SecurityContextConfig (type)", "SecurityContextConfigParsed (type)", "SecurityContextConfigSchema (const)", @@ -888,7 +889,9 @@ "translateMetadataDocument (function)", "translateObject (function)", "translatePage (function)", + "translatePermissionSet (function)", "translatePicklist (function)", + "translatePosition (function)", "translateView (function)", "validationMessageTranslationKey (function)", "walkAddressedPageComponents (function)" diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index f9facfd9347..802c0827a09 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -572,6 +572,7 @@ "SearchIndexConfigSchema": "src/system/search-engine.zod.ts#SearchIndexConfigSchema (const)", "SearchProvider": "src/system/search-engine.zod.ts#SearchProvider (type)", "SearchProviderSchema": "src/system/search-engine.zod.ts#SearchProviderSchema (const)", + "SecurityCatalogItemLike": "src/system/i18n-resolver.ts#SecurityCatalogItemLike (interface)", "SecurityContextConfig": "src/system/security-context.zod.ts#SecurityContextConfig (type)", "SecurityContextConfigParsed": "src/system/security-context.zod.ts#SecurityContextConfigParsed (type)", "SecurityContextConfigSchema": "src/system/security-context.zod.ts#SecurityContextConfigSchema (const)", @@ -849,7 +850,9 @@ "translateMetadataDocument": "src/system/i18n-resolver.ts#translateMetadataDocument (function)", "translateObject": "src/system/i18n-resolver.ts#translateObject (function)", "translatePage": "src/system/i18n-resolver.ts#translatePage (function)", + "translatePermissionSet": "src/system/i18n-resolver.ts#translatePermissionSet (function)", "translatePicklist": "src/system/i18n-resolver.ts#translatePicklist (function)", + "translatePosition": "src/system/i18n-resolver.ts#translatePosition (function)", "translateView": "src/system/i18n-resolver.ts#translateView (function)", "validationMessageTranslationKey": "src/system/validation-message.ts#validationMessageTranslationKey (function)", "walkAddressedPageComponents": "src/system/i18n-resolver.ts#walkAddressedPageComponents (function)" diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index c60db53046e..fff1d8a09d6 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 30 | 0 | 0 | 0 | 0 | 30 | +| `translation` | 34 | 0 | 0 | 0 | 0 | 34 | From f444163b75e8a6f9083d36cd1198cdd04ad8909f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:06:24 +0000 Subject: [PATCH 5/7] fix(examples): translate the crm and showcase positions and permission sets into zh-CN The new walk counts them; without these, check:i18n-coverage grows 101 -> 109 (crm) and 320 -> 349 (showcase). Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- .../src/translations/crm.translation.ts | 11 ++++++++ .../src/system/translations/index.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 887d5e3ae60..f11cf234fd4 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -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': '线索转化失败,请重试。', diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index cb0f979923c..b578aab68f8 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -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: '示例客户联络人' }, + }, }, }; From 7435c5b3a5c3b45ebec59ef83154e66c181d036c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:19:54 +0000 Subject: [PATCH 6/7] wip(lint): translation-target-unknown legs for positions and permissions Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- .../docs/protocol/kernel/i18n-standard.mdx | 5 +- packages/lint/src/rule-explanations.ts | 4 +- .../validate-translation-references.test.ts | 52 +++++++++++++++++ .../src/validate-translation-references.ts | 58 ++++++++++++++++++- 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index f7a42006272..1fa0f5e09bd 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -1061,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` | @@ -1092,9 +1094,6 @@ metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against. (`settings` is not checked either, and since it left the per-app bundle it cannot appear in one at all — the parse refuses it before any lint runs.) -`positions` and `permissions` are not checked either: a key there is resolved -by the position's or permission set's `name` at read time, and one that names -nothing simply translates nothing. ### Translation Service Integration diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a6d3a1b91fd..48378c9115c 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3404,7 +3404,9 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = { 'entries are declared and translated by the package that owns it. `dashboards.DASH` a ' + 'dashboard, `.widgets.ID` a widget `id` on it, and `.actions.URL` a header action\'s ' + '`actionUrl`, not its label. `picklists.LIST` a picklist\'s `name` that a package of this ' + - 'stack declares.', + 'stack declares. `positions.NAME` a position\'s `name` that a package of this stack ' + + 'declares, or an audience anchor (`everyone`, `guest`); `permissions.NAME` a permission ' + + 'set\'s `name` that a package of this stack declares.', // The terminal toast keys are held to `FLOW_TERMINAL_MESSAGE_KEYS` by the rule's test. '`flows.FLOW` a flow\'s machine `name`; `.successMessage` / `.errorMessage` a toast the flow ' + 'itself authors (a bundle cannot add one); `.screens.NODE` the `id` of a `type: \'screen\'` ' + diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 3a0de1f0e65..4e920e428c4 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -4422,3 +4422,55 @@ describe('[#22161] one-line fixes — translation-target-unknown', () => { expect(text).toContain("a list view's own `tabs` has no renderer"); }); }); + +describe('validateTranslationReferences — the security catalog (positions, permissions)', () => { + const stack = (data: Record, extra: Record = {}) => ({ + positions: [{ name: 'srm_legal_counsel', label: '法务顾问' }], + permissions: [{ name: 'srm_contract_reviewer', label: '合同审核', objects: {} }], + ...extra, + translations: [{ en: data }], + }); + + it('a declared name is clean', () => { + expect(validateTranslationReferences(stack({ + positions: { srm_legal_counsel: { label: 'Legal Counsel', description: 'Reviews contracts' } }, + permissions: { srm_contract_reviewer: { label: 'Contract Review' } }, + }))).toEqual([]); + }); + + it('a name no package declares is translation-target-unknown, at the picklist list face\'s error', () => { + const findings = validateTranslationReferences(stack({ + positions: { srm_legal_counsle: { label: 'Legal Counsel' } }, + permissions: { srm_contract_reviewr: { label: 'Contract Review' } }, + })); + expect(findings.map((f) => [f.rule, f.severity, f.path, f.where])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', 'translations[0].en.positions.srm_legal_counsle', 'locale "en" · position "srm_legal_counsle"'], + [ + TRANSLATION_TARGET_UNKNOWN, + 'error', + 'translations[0].en.permissions.srm_contract_reviewr', + 'locale "en" · permission set "srm_contract_reviewr"', + ], + ]); + expect(findings[0].message).toContain('Did you mean "srm_legal_counsel"?'); + expect(findings[1].message).toContain('Did you mean "srm_contract_reviewer"?'); + }); + + it('judges each group against its own collection — a position name under `permissions` is an orphan', () => { + const findings = validateTranslationReferences(stack({ permissions: { srm_legal_counsel: { label: 'x' } } })); + expect(findings.map((f) => f.path)).toEqual(['translations[0].en.permissions.srm_legal_counsel']); + }); + + it('resolves a name a sibling package of the artifact declares', () => { + const findings = validateTranslationReferences(stack( + { positions: { srm_buyer: { label: 'Buyer' } }, permissions: { srm_buyer_access: { label: 'Buyer access' } } }, + { packages: [{ manifest: { positions: [{ name: 'srm_buyer', label: '采购员' }], permissions: [{ name: 'srm_buyer_access', objects: {} }] } }] }, + )); + expect(findings).toEqual([]); + }); + + it('never reports a platform audience anchor', () => { + expect(validateTranslationReferences(stack({ positions: { everyone: { label: 'Everyone' }, guest: { label: 'Guest' } } }))) + .toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 326bc00c5f7..6dee282662a 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -144,6 +144,17 @@ * `translation-option-key-unknown` (`warning`). A list this stack only EXTENDS, * declared by no package of it, is skipped wholly — rung 2b below, for a list. * + * ── Positions and permission sets: the security catalog ────────────────── + * + * `positions.` resolves against `Position.name` and `permissions.` + * against `PermissionSet.name` — the names `translatePosition` / + * `translatePermissionSet` look a served document up by. A name no package of + * this stack declares is an orphan, `translation-target-unknown` (`error`), + * the picklist list face's verdict. The audience anchors (`everyone`, `guest`, + * `AUDIENCE_ANCHOR_POSITIONS`) are positions the PLATFORM declares in every + * environment, so a key naming one resolves and is never an orphan. Leaf keys + * below the name are the schema's (`label` / `description`, strict). + * * ── Cross-package objects ──────────────────────────────────────────────── * * A stack legitimately translates objects it does not define — `sys_user`'s @@ -188,7 +199,8 @@ * is the one reader of that carrier, and every collection it may be asked for * is a key whose `COMPOSE_KEY_DISPOSITIONS` disposition puts it inside an * ADR-0130 D4 entry's assembled body: `objects` (#19064) plus `views`, - * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349). + * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349), and + * `positions` and `permissions`. * * ⚠️ `apps` reads BOTH that carrier and `navigationContributions`, and they * answer different questions — what a sibling DECLARES, and what this package @@ -205,7 +217,7 @@ * fold facts each rests on, and this rule's test file for the lit controls. */ -import { expandViewContainer } from '@objectstack/spec'; +import { AUDIENCE_ANCHOR_POSITIONS, expandViewContainer } from '@objectstack/spec'; import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { FLOW_TERMINAL_MESSAGE_KEYS, @@ -491,6 +503,10 @@ interface Universe { * rung 2b of the object ladder applied to a list (#23566). */ extendedPicklists: Set; + /** Position names (`Position.name`) a `positions.` key may name, the audience anchors included. */ + positions: Set; + /** Permission-set names (`PermissionSet.name`) a `permissions.` key may name. */ + permissionSets: Set; /** * Page name (`Page.name`) → every component id the page ADDRESSES * (`pages..components.`), with that component's type and, for a @@ -1648,6 +1664,23 @@ function buildUniverse(stack: AnyRec): Universe { } } + // ── Security catalog: the names `positions.` / `permissions.` may name ── + // + // Both carry disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, so a + // sibling package's declarations are read through the artifact carrier like + // every other collection here. + const namesOf = (collection: string): Set => { + const names = new Set(); + for (const record of [...recordsOf(stack[collection]), ...artifactProvidedRecords(stack, collection)]) { + const name = strName(record.name); + if (name) names.add(name); + } + return names; + }; + const positions = namesOf('positions'); + for (const anchor of AUDIENCE_ANCHOR_POSITIONS) positions.add(anchor); + const permissionSets = namesOf('permissions'); + return { objects, extended, @@ -1659,6 +1692,8 @@ function buildUniverse(stack: AnyRec): Universe { actionOwners, picklists, extendedPicklists, + positions, + permissionSets, pages, }; } @@ -2111,6 +2146,25 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind }); } + // ── positions. / permissions. ───────────────────────── + const securityCatalog = [ + { group: 'positions', noun: 'position', known: universe.positions, hint: "Match the key to a position's `name`, or drop it." }, + { group: 'permissions', noun: 'permission set', known: universe.permissionSets, hint: "Match the key to a permission set's `name`, or drop it." }, + ] as const; + for (const { group, noun, known, hint } of securityCatalog) { + for (const name of Object.keys(asRecord(rawData[group]))) { + if (known.has(name)) continue; + orphan( + `${inLocale} · ${noun} "${name}"`, + `${base}.${group}.${name}`, + `Translations are keyed to ${noun} "${name}", which no package in this stack declares, ` + + `so nothing resolves this key and the ${noun} keeps its source-locale label.` + + suggest(name, known), + hint, + ); + } + } + // ── flows.[.screens.[.fields.]] ──────── for (const [flowName, rawFlow] of Object.entries(asRecord(rawData.flows))) { const flowPath = `${base}.flows.${flowName}`; From 5ec0b745fd6c34ec295b1d97bfb25399366e69c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:25:18 +0000 Subject: [PATCH 7/7] docs(changeset): aliases steer the refusal, they do not accept the key; lint leg Claude-Session: https://claude.ai/code/session_01AwcSGXmnoviawmFu9pGgNZ Co-authored-by: Claude --- .changeset/25652-translation-positions-permissions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/25652-translation-positions-permissions.md b/.changeset/25652-translation-positions-permissions.md index e3a13c422e8..067b0f679c9 100644 --- a/.changeset/25652-translation-positions-permissions.md +++ b/.changeset/25652-translation-positions-permissions.md @@ -1,6 +1,7 @@ --- '@objectstack/spec': minor '@objectstack/cli': minor +'@objectstack/lint': minor --- feat(spec,cli): positions and permission sets are translatable — `positions.` and `permissions.` in the translation bundle @@ -10,7 +11,8 @@ 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; `name` and `title` are renamed onto `label`, and the group spellings `position`, `permission` and `permissionSets` onto the declared groups). 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/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.` or `permissions.` 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..label` / `.description` and `permissions..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):