From 23ba1f8c5b5ee6de67742a2d83c0d8f46ecf749a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 00:36:30 +0000 Subject: [PATCH 1/4] fix(cli,spec,i18n): default-locale bundle tracks the source; promote approval labels into the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The i18n extractor's merge branch ran for every locale including the default one, so an existing en entry always beat the source seed: an author's edit to a label/description/help never reached the committed en bundle and the drift gate stayed green (53 stale entries across 6 packages at branch time). The default locale is now rewritten from the source on every extract; translated locales keep merge semantics exactly as before (30 translated bundles verified byte-identical through a fixed regeneration). Contract-first (#8543 ruling, variant C): the five humanized sys_approval_request.status labels lived only in the generated en bundle, so a source-tracking en channel would have regressed them to raw machine values. They are promoted into packages/spec as APPROVAL_STATUS_LABELS beside APPROVAL_STATUSES, and the column derives its options from both — never re-typed at the column (#3786). Same treatment for the sibling sys_approval_action.action (#8580): APPROVAL_ACTION_KIND_LABELS, whose twelve entries previously shipped raw (submit / request_info / ...) because the #7232 humanization missed the field. Guard rail (variant B's axis): bare-string / label-less select options — including the {value, label: value} shape Field.select normalizes bare strings into — now seed through the extractor's derived channel: the value still seeds the skeleton, but inline stays unset, so the coverage gate no longer demands translations of machine identifiers and a copied value cannot masquerade as authored display text. In-repo coverage-ratchet blast radius: zero (12 configs, no count moved). Fixes #8543 Fixes #8580 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz --- .changeset/i18n-en-bundle-tracks-source.md | 16 ++++ AGENTS.md | 2 +- packages/cli/src/utils/i18n-extract.ts | 49 ++++++---- packages/cli/test/i18n-coverage.test.ts | 40 +++++++- packages/cli/test/i18n-extract.test.ts | 94 ++++++++++++++++++- .../en.metadata-forms.generated.ts | 38 ++++---- .../apps/translations/en.objects.generated.ts | 24 ++--- .../src/approval-vocabularies.test.ts | 37 +++++++- .../src/sys-approval-action.object.ts | 11 ++- .../src/sys-approval-request.object.ts | 11 ++- .../approval-status-vocabulary.test.ts | 36 ++++--- .../src/translations/en.objects.generated.ts | 24 ++--- .../src/translations/en.objects.generated.ts | 6 +- .../src/translations/en.objects.generated.ts | 18 ++-- .../src/translations/en.objects.generated.ts | 8 +- .../src/translations/en.objects.generated.ts | 2 +- packages/spec/api-surface/contracts.json | 2 + packages/spec/export-origins/contracts.json | 2 + .../spec/src/contracts/approval-service.ts | 55 +++++++++++ scripts/check-i18n-bundles.mjs | 6 +- 20 files changed, 377 insertions(+), 104 deletions(-) create mode 100644 .changeset/i18n-en-bundle-tracks-source.md diff --git a/.changeset/i18n-en-bundle-tracks-source.md b/.changeset/i18n-en-bundle-tracks-source.md new file mode 100644 index 0000000000..cab5f00e3e --- /dev/null +++ b/.changeset/i18n-en-bundle-tracks-source.md @@ -0,0 +1,16 @@ +--- +'@objectstack/cli': minor +'@objectstack/spec': minor +'@objectstack/plugin-approvals': patch +'@objectstack/platform-objects': patch +'@objectstack/plugin-audit': patch +'@objectstack/plugin-security': patch +'@objectstack/plugin-webhooks': patch +'@objectstack/service-messaging': patch +--- + +The i18n extractor's default locale now tracks the source instead of merging (#8543), and the approval vocabularies carry authored English labels in the contract (#8580). + +- `os i18n extract` merge mode no longer applies to the default locale: `en` is a copy of the source, not a translation, so an edited label/description/help now reaches the regenerated `en` bundle instead of being silently shadowed by the stale entry forever (53 stale entries had accumulated across 6 packages under the old behavior; all rewritten here). Translated locales (`zh-CN` / `ja-JP` / `es-ES`) keep merge semantics exactly as before — no existing translation is overwritten. +- Bare-string and label-less select options now seed through the extractor's derived channel: the machine value still seeds the skeleton, but the coverage gate no longer demands "translations" of machine identifiers, and a copied value can no longer masquerade as authored display text. +- New `@objectstack/spec/contracts` exports `APPROVAL_STATUS_LABELS` and `APPROVAL_ACTION_KIND_LABELS`: the authored English for `sys_approval_request.status` (previously living only in the generated `en` bundle) and `sys_approval_action.action` (previously shipping raw machine values such as `submit` / `request_info` — the #7232 humanization missed this sibling field). Both columns derive their option labels from these maps; the regenerated `en` bundles copy them verbatim. diff --git a/AGENTS.md b/AGENTS.md index 2fdf10862f..d9e4870607 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -478,7 +478,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d |:---|:---|:---| | `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. | | `content/docs/releases/` | **RELEASE-OWNED** | ❌ Never edit in a code PR. Release notes are written **centrally at release time**, compiled from changesets + the ADR-0087 registries — not accreted a row per PR. Per-PR appends made `releases/v.mdx` the repo's hottest conflict magnet (three PRs raced the same table inside one afternoon), and every manual resolution risks dropping someone else's row. Your PR's input is its **changeset**; for spec removals also the D2/D3 registry entries. Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes. | -| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode — every existing translation is preserved); `pnpm i18n:extract` still covers `platform-objects` alone. Translation *values* are hand-written and expected to be: the gate compares against a merge-mode extract, so editing a string is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. | +| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode preserves every existing **translated-locale** value; the default locale `en` is rewritten from the source on every run — it is a copy of the source, not a translation, so hand-edits to `en.*.generated.ts` do not survive and belong in the source metadata instead, #8543); `pnpm i18n:extract` still covers `platform-objects` alone. Translated-locale *values* (`zh-CN` / `ja-JP` / `es-ES`) are hand-written and expected to be: the gate compares against a merge-mode extract, so editing one of those strings is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. | | `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. | | `content/docs/concepts/` | hand-written | ✅ | | `content/docs/getting-started/` | hand-written | ✅ | diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index f6475f733e..093d4feb77 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -645,30 +645,38 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { pushOptional(out, ['objects', objectName, 'fields', fieldName, 'placeholder'], field.placeholder, 'field', { objectName }); // Options — accept either `{value, label}[]` arrays or a record map. + // + // An option whose label is absent — or byte-equal to its own machine + // value, which is what `Field.select(['pending'])` normalizes a bare + // string into — is seeded from the value but recorded as DERIVED + // (#8543): the seed keeps the skeleton usable, while `inline` stays + // unset so the coverage gate never demands a translation of a machine + // identifier, and nothing downstream mistakes the copied value for + // deliberately-authored display text. Authored English for a select + // belongs on the option (or in the contract beside the vocabulary — + // see `APPROVAL_STATUS_LABELS` in @objectstack/spec/contracts), where + // this walk sees it as a real label. + const pushOption = (value: string, label: unknown): void => { + const path = ['objects', objectName, 'fields', fieldName, 'options', value]; + const authored = inlineText(label); + if (authored !== undefined && authored !== value) { + pushEntry(out, path, authored, 'option', { objectName }); + } else { + pushDerived(out, path, value, undefined, 'option', { objectName }); + } + }; const opts = field.options; if (Array.isArray(opts)) { for (const opt of opts) { if (opt && typeof opt === 'object' && 'value' in opt) { - pushEntry( - out, - ['objects', objectName, 'fields', fieldName, 'options', String(opt.value)], - String(opt.label ?? opt.value), - 'option', - { objectName }, - ); + pushOption(String(opt.value), opt.label); } else if (typeof opt === 'string') { - pushEntry(out, ['objects', objectName, 'fields', fieldName, 'options', opt], opt, 'option', { objectName }); + pushOption(opt, undefined); } } } else if (opts && typeof opts === 'object') { for (const [value, label] of Object.entries(opts)) { - pushEntry( - out, - ['objects', objectName, 'fields', fieldName, 'options', value], - typeof label === 'string' ? label : String(value), - 'option', - { objectName }, - ); + pushOption(value, label); } } } @@ -1077,7 +1085,16 @@ export function extractTranslations(config: any, opts: ExtractOptions = {}): Ext // verbatim so the generated file remains a complete, self-contained // bundle (not just the missing-key delta). Set --no-merge to skip // baselines entirely. - if (opts.mergeExisting !== false) { + // + // The default locale is deliberately NOT merged (#8543): it is a copy of + // the source, not a translation, so "never overwrite an existing entry" + // protects the wrong thing there — an author edits a field description, + // the regeneration keeps the stale entry, and the served text drifts from + // the source silently while the drift gate reports OK (measured at 53 + // stale entries across 6 packages when this branch ran for every locale). + // The seed IS the source text for the default locale (line below), so it + // always wins; translated locales keep merge semantics exactly as before. + if (opts.mergeExisting !== false && locale !== defaultLocale) { const existingValue = lookupDeep(existing[locale], entry.path); if (existingValue !== undefined && existingValue !== '') { value = String(existingValue); diff --git a/packages/cli/test/i18n-coverage.test.ts b/packages/cli/test/i18n-coverage.test.ts index 4291bc53b7..1989726360 100644 --- a/packages/cli/test/i18n-coverage.test.ts +++ b/packages/cli/test/i18n-coverage.test.ts @@ -157,7 +157,16 @@ describe('computeI18nCoverage', () => { expect(zhKeys.has('objects.account.fields.stage.options.direct_mail')).toBe(true); }); - it('covers options declared as a bare string array', () => { + it('#8543: bare-string options are DERIVED — no translation demanded for a machine identifier', () => { + // A bare-string option has no authored display text: its "label" is a + // copy of the machine value (`Field.select(['planning'])` normalizes to + // `{ value: 'planning', label: 'planning' }`). This test used to pin the + // opposite — that the gate demands a zh-CN translation of `planning` / + // `closed` — which both erased the authored/derived axis the extractor + // documents and taught authors to "translate" machine identifiers. + // Authored option labels (previous test) stay gated; a bundle that + // externalizes text for a derived key re-enters the expected set via + // `authoredInBundle`. const stringOptionConfig: any = { objects: [ { @@ -170,8 +179,35 @@ describe('computeI18nCoverage', () => { }; const report = computeI18nCoverage(stringOptionConfig, { defaultLocale: 'en' }); const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key)); + expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(false); + expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(false); + // The field's own authored label is still owed. + expect(zhKeys.has('objects.account.fields.stage.label')).toBe(true); + }); + + it('#8543: a bundle that authors text for a derived option key re-enters the expected set', () => { + // The other half of the derived-channel contract: `inline` unset does not + // mean "never gated" — a project that externalizes display text for a + // bare-string option into some bundle owes the other locales a + // translation of it, exactly like any externalized string. + const externalized: any = { + objects: [ + { + name: 'account', + label: 'Account', + fields: { stage: { label: 'Stage', options: ['planning'] } }, + }, + ], + translations: [ + { + en: { objects: { account: { fields: { stage: { options: { planning: 'Planning' } } } } } }, + 'zh-CN': {}, + }, + ], + }; + const report = computeI18nCoverage(externalized, { defaultLocale: 'en' }); + const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key)); expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true); - expect(zhKeys.has('objects.account.fields.stage.options.closed')).toBe(true); }); it('promotes warnings to errors under --strict', () => { diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 2ac69877db..0613222340 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -133,6 +133,50 @@ describe('collectExpectedEntries', () => { expect(byPath['metadataForms.flow.fields.name.label']).toBe('Name'); }); + it('#8543: label-less options seed through the DERIVED channel, authored labels stay authored', () => { + // `Field.select(['pending'])` normalizes a bare string to + // `{ value: 'pending', label: 'pending' }` — the label is a copy of the + // machine value, not authored English. Recording that as authored is how + // the coverage gate came to demand translations of machine identifiers, + // and how a raw machine value could ship as rendered text without any + // gate noticing (#8580 is the shipped instance). All three authoring + // shapes are pinned: `{value,label}` with the label equal to the value, + // a bare string, and a record map whose label restates the key. + const cfg: any = { + objects: [ + { + name: 'w', + label: 'W', + fields: { + normalized: { + label: 'Normalized', + options: [ + { value: 'pending', label: 'pending' }, // Field.select(['pending']) shape + { value: 'approved', label: 'Approved' }, // genuinely authored + { value: 'rejected' }, // no label at all + ], + }, + bare: { label: 'Bare', options: ['draft'] }, + map: { label: 'Map', options: { open: 'open', closed: 'Closed' } }, + }, + }, + ], + }; + const entries = collectExpectedEntries(cfg); + const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e])); + const opt = (p: string) => byPath[`objects.w.fields.${p}`]; + + // Derived: seeded from the value so skeletons stay usable, but `inline` + // stays unset — nobody authored display text. + for (const p of ['normalized.options.pending', 'normalized.options.rejected', 'bare.options.draft', 'map.options.open']) { + expect(opt(p)?.sourceValue, p).toBe(p.split('.').pop()); + expect(opt(p)?.inline, p).toBeUndefined(); + } + // Authored: the label is real display text and drives the coverage gate. + expect(opt('normalized.options.approved')?.inline).toBe('Approved'); + expect(opt('map.options.closed')?.inline).toBe('Closed'); + }); + it('emits action param entries (inline + top-level), skipping field-backed labels without overrides', () => { const entries = collectExpectedEntries(config); const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e.sourceValue])); @@ -265,8 +309,8 @@ describe('extractTranslations', () => { locales: ['en'], mergeExisting: true, }); - // Existing translations are preserved verbatim so the generated file - // is a complete, self-contained bundle (not just a delta). + // The fixture's en bundle matches the source, so this only proves the + // seed path; the divergence cases live in the #8543 test below. expect(bundles.en.objects?.sys_position?.label).toBe('Role'); expect(bundles.en.objects?.sys_position?.fields?.active?.label).toBe('Active'); // Missing keys are still filled from schema defaults. @@ -274,6 +318,52 @@ describe('extractTranslations', () => { expect(bundles.en.objects?.sys_position?.fields?.label?.label).toBe('Display Name'); }); + it('#8543: the default locale tracks the SOURCE, not a stale existing entry; translated locales keep merge', () => { + // The en bundle is a copy of the source, not a translation. Before #8543 + // the merge branch ran for every locale, so an author editing a field + // description could never get the edit into the committed en bundle — the + // stale entry always won and the drift gate stayed green (53 stale + // entries had accumulated across 6 packages when this was fixed). + const cfg: any = { + objects: [ + { + name: 'thing', + label: 'Thing (new wording)', + fields: { note: { label: 'Note', help: 'New help text' } }, + }, + ], + translations: [ + { + en: { + objects: { + thing: { + label: 'Thing (stale wording)', + fields: { note: { label: 'Note', help: 'Old help text' } }, + }, + }, + }, + 'zh-CN': { + objects: { + thing: { label: '事物', fields: { note: { label: '备注', help: '说明' } } }, + }, + }, + }, + ], + }; + const { bundles } = extractTranslations(cfg, { + defaultLocale: 'en', + locales: ['zh-CN'], + mergeExisting: true, + }); + // en: the source seed wins over the stale bundle entry. + expect(bundles.en.objects?.thing?.label).toBe('Thing (new wording)'); + expect(bundles.en.objects?.thing?.fields?.note?.help).toBe('New help text'); + // zh-CN: the human translation is preserved verbatim — merge semantics + // for translated locales are exactly what they were. + expect(bundles['zh-CN'].objects?.thing?.label).toBe('事物'); + expect(bundles['zh-CN'].objects?.thing?.fields?.note?.help).toBe('说明'); + }); + it('filters by object name regex', () => { const cfg = { objects: [ diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 3ff558d194..460ffac647 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -18,7 +18,7 @@ export const enMetadataForms: NonNullable = { }, fields: { label: "Fields", - description: "Define the data model — each row becomes a column in the database table." + description: "Define the data model — each entry becomes a column in the database table." }, capabilities: { label: "Capabilities", @@ -52,7 +52,7 @@ export const enMetadataForms: NonNullable = { }, isSystem: { label: "Is System", - helpText: "System object (protected from deletion)" + helpText: "System object (protected from deletion; defaults sharing to public)" }, fields: { label: "Fields", @@ -144,7 +144,7 @@ export const enMetadataForms: NonNullable = { }, "fields.reference": { label: "Reference", - helpText: "Target object (for lookup/master_detail)" + helpText: "Target object name" }, "fields.lookupFilters": { label: "Lookup Filters", @@ -192,7 +192,7 @@ export const enMetadataForms: NonNullable = { }, "fields.trackHistory": { label: "Track History", - helpText: "Keep change history" + helpText: "Summarize this field on the record activity timeline" }, "fields.visibleWhen": { label: "Visible When", @@ -624,7 +624,7 @@ export const enMetadataForms: NonNullable = { }, filter: { label: "Filter", - helpText: "Filter conditions" + helpText: "Filter conditions — same visual builder as the list toolbar, with field-type-aware operators and value inputs" }, sort: { label: "Sort", @@ -740,7 +740,7 @@ export const enMetadataForms: NonNullable = { }, type: { label: "Type", - helpText: "Page kind — list / record / home / app / utility. How a list page looks (grid / kanban / calendar) is a visualization set under Interface, not a page type." + helpText: "Page kind. \"List / Interface\" binds a source view into a curated surface — how it looks (grid / kanban / calendar / …) is a visualization set under Interface, not a page type." }, template: { label: "Template", @@ -974,7 +974,7 @@ export const enMetadataForms: NonNullable = { }, defaultAgent: { label: "Default Agent", - helpText: "AI agent for the ambient assistant button" + helpText: "Platform agent for the ambient assistant ('ask' by default; 'build' for authoring surfaces)" }, branding: { label: "Branding", @@ -1003,7 +1003,7 @@ export const enMetadataForms: NonNullable = { }, advanced: { label: "Advanced", - description: "Bulk operations, AI exposure, and API request shape." + description: "AI exposure and API request shape." } }, fields: { @@ -1041,7 +1041,7 @@ export const enMetadataForms: NonNullable = { }, body: { label: "Body", - helpText: "JavaScript code to execute" + helpText: "Either an L1 expression or an L2 sandboxed JS body" }, "body.language": { label: "Language", @@ -1118,7 +1118,7 @@ export const enMetadataForms: NonNullable = { sections: { basics: { label: "Basics", - description: "Identity and data source." + description: "Identity and report type." }, dataset_binding: { label: "Dataset binding", @@ -1126,11 +1126,11 @@ export const enMetadataForms: NonNullable = { }, joined_blocks: { label: "Joined blocks", - description: "Additional blocks joined into a single report (joined reports only)." + description: "Additional dataset-bound blocks stacked into a single report (joined reports only)." }, filter_and_chart: { label: "Filter & chart", - description: "Report-level filters and chart presentation." + description: "Render-time scope filter and chart presentation." } }, fields: { @@ -1162,7 +1162,7 @@ export const enMetadataForms: NonNullable = { }, columns: { label: "Columns", - helpText: "Columns to display in the report" + helpText: "Dimension names across (matrix only)" }, order: { label: "Order", @@ -1174,7 +1174,7 @@ export const enMetadataForms: NonNullable = { }, blocks: { label: "Blocks", - helpText: "Join multiple objects (joined report only)" + helpText: "Dataset-bound sub-reports (joined report only)" }, runtimeFilter: { label: "Runtime Filter", @@ -1238,7 +1238,7 @@ export const enMetadataForms: NonNullable = { }, measures: { label: "Measures", - helpText: "Each: name, aggregate, field (optional for count), display format/currency, and a “certified” governance flag" + helpText: "Each: name, aggregate, field (optional for count), and display format/currency" } } }, @@ -1326,7 +1326,7 @@ export const enMetadataForms: NonNullable = { sections: { identity: { label: "Identity", - description: "Identifier and content type. The id is referenced by sendTemplate({ template: id, ... })." + description: "Template identifier resolved by IEmailService.sendTemplate({ template: name, locale, ... })." }, subject: { label: "Subject", @@ -1381,7 +1381,7 @@ export const enMetadataForms: NonNullable = { }, variables: { label: "Variables", - helpText: "List of variable names referenced in subject/body" + helpText: "[{ \"name\": \"user.name\", \"type\": \"string\", \"required\": true, \"description\": \"...\" }]" }, fromOverride: { label: "From Override", @@ -1426,7 +1426,7 @@ export const enMetadataForms: NonNullable = { }, tab_and_row_level_security: { label: "Tab & Row-Level Security", - description: "Tab visibility, RLS policies, and custom context variables for predicate evaluation." + description: "Tab visibility and RLS policies." } }, fields: { @@ -1498,7 +1498,7 @@ export const enMetadataForms: NonNullable = { }, capabilities: { label: "Capabilities", - description: "Skills, tools, and knowledge sources the agent can use." + description: "Skills and knowledge sources the agent can use." }, access: { label: "Access & Security", diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 0ec9d21067..859bab8156 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -728,7 +728,7 @@ export const enObjects: NonNullable = { }, organization_id: { label: "Organization", - help: "Parent organization for this team" + help: "Parent organization for this team. Null in single-tenant; auto-stamped in multi-tenant." }, member_count: { label: "Member Count", @@ -805,7 +805,7 @@ export const enObjects: NonNullable = { sys_business_unit: { label: "Business Unit", pluralLabel: "Business Units", - description: "Hierarchical org-skeleton node (department / division / business unit / office).", + description: "Canonical Business Unit tree — hierarchical org/data-partition node (company / division / department / region / office). ADR-0057 D2.", fields: { name: { label: "Name" @@ -831,7 +831,7 @@ export const enObjects: NonNullable = { }, organization_id: { label: "Organization", - help: "Tenant scope." + help: "Tenant scope. Null in single-tenant; auto-stamped in multi-tenant." }, manager_user_id: { label: "Business Unit Head", @@ -906,7 +906,7 @@ export const enObjects: NonNullable = { }, is_primary: { label: "Primary Assignment", - help: "When the user is in multiple departments, this marks the canonical one for reporting." + help: "When the user is in multiple business units, this marks the canonical one for reporting." }, effective_from: { label: "Effective From" @@ -1910,9 +1910,9 @@ export const enObjects: NonNullable = { } }, sys_notification: { - label: "Notification", - pluralLabel: "Notifications", - description: "Per-user notification inbox entries", + label: "Notification Event", + pluralLabel: "Notification Events", + description: "Notification events — one row per emit() (ADR-0030 Layer 2 ingress)", fields: { id: { label: "Notification ID" @@ -1948,7 +1948,7 @@ export const enObjects: NonNullable = { }, actor_id: { label: "Actor", - help: "User who caused the notification (mentioner, assigner)" + help: "User who caused the event (mentioner, assigner)" }, created_at: { label: "Created At" @@ -2659,7 +2659,7 @@ export const enObjects: NonNullable = { help: "Organization for multi-tenant isolation." }, environment_id: { - label: "Project (deprecated)", + label: "Environment (deprecated)", help: "DEPRECATED. Use organization_id for tenant isolation." }, version: { @@ -2709,7 +2709,7 @@ export const enObjects: NonNullable = { sys_metadata_history: { label: "Metadata History", pluralLabel: "Metadata History", - description: "Version history and audit trail for metadata changes", + description: "Durable event log of metadata overlay changes (per-org, append-only)", fields: { id: { label: "ID" @@ -2739,7 +2739,7 @@ export const enObjects: NonNullable = { }, metadata: { label: "Metadata", - help: "JSON-serialized metadata snapshot at this version" + help: "JSON-serialized metadata snapshot at this version (null for deletes)." }, checksum: { label: "Checksum" @@ -2749,7 +2749,7 @@ export const enObjects: NonNullable = { }, change_note: { label: "Change Note", - help: "Description of what changed in this version" + help: "Description of what changed in this version." }, source: { label: "Source" diff --git a/packages/plugins/plugin-approvals/src/approval-vocabularies.test.ts b/packages/plugins/plugin-approvals/src/approval-vocabularies.test.ts index 5535522774..6c020f2793 100644 --- a/packages/plugins/plugin-approvals/src/approval-vocabularies.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-vocabularies.test.ts @@ -22,7 +22,12 @@ */ import { describe, it, expect } from 'vitest'; -import { APPROVAL_STATUSES, APPROVAL_ACTION_KINDS } from '@objectstack/spec/contracts'; +import { + APPROVAL_STATUSES, + APPROVAL_STATUS_LABELS, + APPROVAL_ACTION_KINDS, + APPROVAL_ACTION_KIND_LABELS, +} from '@objectstack/spec/contracts'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; @@ -33,6 +38,11 @@ const optionValues = (obj: unknown, field: string): string[] => { return raw.map((o: unknown) => (typeof o === 'string' ? o : (o as any)?.value)); }; +const optionLabels = (obj: unknown, field: string): string[] => { + const raw = (obj as any)?.fields?.[field]?.options ?? []; + return raw.map((o: unknown) => (typeof o === 'string' ? o : (o as any)?.label)); +}; + describe('approval vocabularies are derived from @objectstack/spec/contracts (#3786)', () => { it('the contract vocabularies are non-empty and reachable', () => { // Guard the guard: an unresolvable import would make both assertions below @@ -49,6 +59,31 @@ describe('approval vocabularies are derived from @objectstack/spec/contracts (#3 expect(optionValues(SysApprovalAction, 'action')).toEqual([...APPROVAL_ACTION_KINDS]); }); + it('sys_approval_request.status labels are the authored contract labels (#8543)', () => { + // The English display text lives in APPROVAL_STATUS_LABELS, beside the + // vocabulary — never re-typed at the column, and the generated `en` bundle + // is a verbatim copy of it. A raw machine value leaking into a label here + // is exactly the defect #8543/#8580 closed. + expect(optionLabels(SysApprovalRequest, 'status')).toEqual( + APPROVAL_STATUSES.map((s) => APPROVAL_STATUS_LABELS[s]), + ); + expect(optionLabels(SysApprovalRequest, 'status')).not.toEqual( + optionValues(SysApprovalRequest, 'status'), + ); + }); + + it('sys_approval_action.action labels are the authored contract labels (#8580)', () => { + // The #7232 humanization covered `status` and missed this sibling — the + // shipped en bundle rendered `submit` / `request_info` raw. The labels now + // derive from APPROVAL_ACTION_KIND_LABELS in the contract. + expect(optionLabels(SysApprovalAction, 'action')).toEqual( + APPROVAL_ACTION_KINDS.map((k) => APPROVAL_ACTION_KIND_LABELS[k]), + ); + expect(optionLabels(SysApprovalAction, 'action')).not.toEqual( + optionValues(SysApprovalAction, 'action'), + ); + }); + it('the two vocabularies stay distinct', () => { // A copy-paste that pointed one column at the other constant would satisfy // "derived from the contract" while being the wrong vocabulary entirely. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index b0055d25bc..6c5a318d9a 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { APPROVAL_ACTION_KINDS } from '@objectstack/spec/contracts'; +import { APPROVAL_ACTION_KINDS, APPROVAL_ACTION_KIND_LABELS } from '@objectstack/spec/contracts'; /** * sys_approval_action — Audit trail row per approval action. @@ -99,11 +99,14 @@ export const SysApprovalAction = ObjectSchema.create({ }), action: Field.select( - // Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS` + // Derived from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS` // is where the list and the per-kind notes live (which kinds move the flow // and which are thread-only); `ApprovalActionKind` is derived from it, so - // this column and the contract cannot disagree. - [...APPROVAL_ACTION_KINDS], + // this column and the contract cannot disagree. The authored English label + // per kind lives beside it in `APPROVAL_ACTION_KIND_LABELS` (#8580 — the + // #7232 humanization pass missed this field) — mapped here, never + // re-typed, so the `en` bundle regenerates from the contract's own text. + APPROVAL_ACTION_KINDS.map((value) => ({ value, label: APPROVAL_ACTION_KIND_LABELS[value] })), { label: 'Action', required: true, diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index fd23c9f345..dc62d8a861 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { APPROVAL_STATUSES } from '@objectstack/spec/contracts'; +import { APPROVAL_STATUSES, APPROVAL_STATUS_LABELS } from '@objectstack/spec/contracts'; /** * sys_approval_request — Live approval instance. @@ -129,10 +129,13 @@ export const SysApprovalRequest = ObjectSchema.create({ }), status: Field.select( - // Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is + // Derived from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is // where the list and the reason for each entry live; `ApprovalStatus` is - // derived from it, so this column and the contract cannot disagree. - [...APPROVAL_STATUSES], + // derived from it, so this column and the contract cannot disagree. The + // authored English label per entry lives beside it in + // `APPROVAL_STATUS_LABELS` (#8543) — mapped here, never re-typed, so the + // `en` bundle regenerates from the contract's own text. + APPROVAL_STATUSES.map((value) => ({ value, label: APPROVAL_STATUS_LABELS[value] })), { label: 'Status', required: true, diff --git a/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts b/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts index 9943506988..afad083f46 100644 --- a/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts +++ b/packages/plugins/plugin-approvals/src/translations/approval-status-vocabulary.test.ts @@ -17,10 +17,15 @@ // • Re-reading `zhCNObjects.…options.pending` and expecting 待审批 restates // the line it is guarding. Every assertion here goes through the real // resolver (`translateObject` / `resolveViewLabel`) against the real -// `SysApprovalRequest`, so it also proves the bundle is REACHED — the -// declared option label is the bare enum value (see the guard-the-guard -// case), so a bundle that stopped being consulted would surface here as -// raw values rather than as a silent pass. +// `SysApprovalRequest`, so the TRANSLATED-locale cases also prove the +// bundle is REACHED — the declared option label is the authored English +// from `APPROVAL_STATUS_LABELS` (#8543 promoted it into the contract; see +// the guard-the-guard case), so a bundle that stopped being consulted +// would surface in the zh-CN case as English rather than as a silent +// pass. (Before #8543 the declared label was the bare enum value and the +// `en` bundle was the only home of the humanized text; now `en` is a +// verbatim copy of the contract labels, so for `en` alone bundle-reach is +// indistinguishable from source fallback — by design.) // // • Pinning each locale in isolation lets the layers drift apart again, which // is the whole defect. The parity case compares this plugin's `my_pending` @@ -33,7 +38,7 @@ // thing are pinned to keep saying it. import { describe, it, expect } from 'vitest'; -import { APPROVAL_STATUSES } from '@objectstack/spec/contracts'; +import { APPROVAL_STATUSES, APPROVAL_STATUS_LABELS } from '@objectstack/spec/contracts'; import { translateObject, resolveViewLabel } from '@objectstack/spec/system'; import type { TranslationData } from '@objectstack/spec/system'; import { zhCN, jaJP, esES } from '@objectstack/platform-objects/apps'; @@ -59,18 +64,25 @@ const navApprovalsLabel = (data: TranslationData): string | undefined => (data as any)?.apps?.account?.navigation?.nav_account_approvals?.label; describe('approval status vocabulary (#7232)', () => { - it('guard the guard: the DECLARED option label is the bare enum value', () => { - // `Field.select([...APPROVAL_STATUSES])` normalizes each bare string to - // `{ label: 'pending', value: 'pending' }` — the label IS the value. Every - // humanized label below therefore comes from the bundle and nowhere else; - // without this case a resolver that silently stopped consulting the bundle - // could still satisfy an en expectation of "pending". + it('guard the guard: the DECLARED option label is the authored contract label', () => { + // #8543: the column derives `{ value, label }` from `APPROVAL_STATUSES` + + // `APPROVAL_STATUS_LABELS`, so the humanized English now lives in the + // CONTRACT and the `en` bundle is a verbatim copy of it (the extractor's + // default-locale channel rewrites `en` from the source on every run). + // This case goes red if someone re-inlines the labels at the column or + // reverts to the bare-value spread — either would put the English back in + // a place the regeneration does not read. Bundle-reach for translated + // locales is proven by the zh-CN case below: the declared label is + // English, so 待审批 can only come from the bundle. const declared = (SysApprovalRequest as any).fields.status.options as Array<{ value: string; label?: string; }>; expect(declared.length).toBe(APPROVAL_STATUSES.length); - expect(declared.map((o) => o.label)).toEqual(declared.map((o) => o.value)); + expect(declared.map((o) => o.value)).toEqual([...APPROVAL_STATUSES]); + expect(declared.map((o) => o.label)).toEqual( + APPROVAL_STATUSES.map((s) => APPROVAL_STATUS_LABELS[s]), + ); }); it('en humanizes every status instead of shipping the raw enum value', () => { diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 28d00b1362..a1465aa993 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -215,18 +215,18 @@ export const enObjects: NonNullable = { action: { label: "Action", options: { - submit: "submit", - approve: "approve", - reject: "reject", - recall: "recall", - escalate: "escalate", - reassign: "reassign", - remind: "remind", - request_info: "request_info", - comment: "comment", - revise: "revise", - resubmit: "resubmit", - ooo_substitute: "ooo_substitute" + submit: "Submit", + approve: "Approve", + reject: "Reject", + recall: "Recall", + escalate: "Escalate", + reassign: "Reassign", + remind: "Remind", + request_info: "Request Info", + comment: "Comment", + revise: "Revise", + resubmit: "Resubmit", + ooo_substitute: "Out-of-Office Substitution" } }, actor_id: { diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 7ff79a5579..72a74a97d4 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -31,8 +31,8 @@ export const enObjects: NonNullable = { } }, user_id: { - label: "Actor", - help: "User who performed the action (null for system actions)" + label: "User", + help: "User who performed the action (null for non-user / service actions — see actor)" }, actor: { label: "Actor", @@ -158,7 +158,7 @@ export const enObjects: NonNullable = { help: "Optional deep-link to the activity target" }, environment_id: { - label: "Project", + label: "Environment", help: "Environment context (multi-environment deployments)" }, metadata: { diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index 7acd67268e..df36ab915b 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -12,14 +12,14 @@ export const enObjects: NonNullable = { sys_position: { label: "Position", pluralLabel: "Positions", - description: "Position definitions — flat capability-distribution groups (ADR-0090 D3)", + description: "Position definitions for capability distribution (ADR-0090)", fields: { label: { label: "Display Name" }, name: { label: "API Name", - help: "Unique machine name for the position (e.g. admin, editor, viewer)" + help: "Machine name for the position, unique per organization (e.g. sales_manager, hr_specialist)" }, description: { label: "Description" @@ -41,7 +41,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform (built-in) / package (declared) / admin (tenant-created).", + help: "Record provenance (unified tri-state, A4 #2920): platform = framework built-in (read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows may carry system (== platform) / config (== package) / user (== admin).", options: { platform: "Platform", package: "Package", @@ -79,7 +79,7 @@ export const enObjects: NonNullable = { }, deactivate_position: { label: "Deactivate Position", - confirmText: "Deactivate this position? Users with the position keep their assignment but the position stops granting permissions until re-activated.", + confirmText: "Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.", successMessage: "Position deactivated" }, set_default_position: { @@ -96,7 +96,7 @@ export const enObjects: NonNullable = { }, name: { label: "New API Name", - helpText: "Unique snake_case machine name" + helpText: "snake_case machine name, unique per organization" } } } @@ -105,7 +105,7 @@ export const enObjects: NonNullable = { sys_capability: { label: "Capability", pluralLabel: "Capabilities", - description: "Authorization capability definitions referenced by name from permission sets and resource requirements.", + description: "Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.", fields: { label: { label: "Display Name" @@ -127,7 +127,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform / package (shipped, not user-deletable) / admin (created in Setup).", + help: "platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.", options: { platform: "Platform", package: "Package", @@ -184,7 +184,7 @@ export const enObjects: NonNullable = { }, name: { label: "API Name", - help: "Unique machine name for the permission set" + help: "Unique machine name for the permission set. This is the set’s metadata identity (ADR-0094) and cannot be changed after creation — the data door rejects a rename; clone the set to a new name instead." }, description: { label: "Description" @@ -222,7 +222,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform (shipped) / package (packaged) / admin (env-authored).", + help: "Record provenance (unified tri-state, A4 #2920): 'platform' = shipped by the platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", options: { platform: "Platform", package: "Package", diff --git a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts index cae6601e96..de04e5fcad 100644 --- a/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-webhooks/src/translations/en.objects.generated.ts @@ -12,7 +12,7 @@ export const enObjects: NonNullable = { sys_webhook: { label: "Webhook", pluralLabel: "Webhooks", - description: "Outbound HTTP webhook subscription. Authored via defineWebhook() in code or the Studio editor; executed by the HTTP connector plugin.", + description: "Outbound HTTP webhook subscription. Declared in code via defineStack({ webhooks }) / defineWebhook() (materialized into rows on boot) or authored directly in the Studio editor; dispatched by the webhook auto-enqueuer onto the shared HTTP outbox.", fields: { id: { label: "Webhook ID" @@ -30,7 +30,7 @@ export const enObjects: NonNullable = { }, triggers: { label: "Triggers", - help: "Record events that fire this webhook. bulk_update / bulk_delete fire on predicate writes and deliver a count, not a record.", + help: "Record events that fire this webhook (bulk_* deliver a count, not a record)", options: { create: "create", update: "update", @@ -45,7 +45,7 @@ export const enObjects: NonNullable = { }, method: { label: "HTTP Method", - help: "GET / POST / PUT / PATCH / DELETE", + help: "HTTP method used for the callback request", options: { get: "GET", post: "POST", @@ -63,7 +63,7 @@ export const enObjects: NonNullable = { }, definition_json: { label: "Definition", - help: "Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — full headers/auth/retry/payload config" + help: "Serialised Webhook JSON (see @objectstack/spec/automation/webhook) — timeout and the rest of the authored envelope. Credentials are NOT stored here: the signing secret lives in the encrypted `signing_secret` field and the custom headers in the encrypted `headers_secret` field." }, headers_secret: { label: "Custom Headers", diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index e4a3bd5e72..cec152cf06 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -350,7 +350,7 @@ export const enObjects: NonNullable = { }, attempts: { label: "Attempts", - help: "Number of attempts made so far" + help: "Number of attempts made so far. 0 on a terminal row means the delivery was PARKED — never sent, and not redeliverable (#8069); see `error` for the cause." }, claimed_by: { label: "Claimed By" diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index 42f7ecf551..a9e5c335a9 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -14,7 +14,9 @@ "AIToolDefinition (interface)", "AIToolResult (type)", "APPROVAL_ACTION_KINDS (const)", + "APPROVAL_ACTION_KIND_LABELS (const)", "APPROVAL_STATUSES (const)", + "APPROVAL_STATUS_LABELS (const)", "AdapterContext (interface)", "AdapterSearchOptions (interface)", "AnalyticsDriverCapabilities (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 71038c9b8b..21f5f839f4 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -14,7 +14,9 @@ "AIToolDefinition": "src/contracts/ai-service.ts#AIToolDefinition (interface)", "AIToolResult": "src/contracts/ai-service.ts#AIToolResult (type)", "APPROVAL_ACTION_KINDS": "src/contracts/approval-service.ts#APPROVAL_ACTION_KINDS (const)", + "APPROVAL_ACTION_KIND_LABELS": "src/contracts/approval-service.ts#APPROVAL_ACTION_KIND_LABELS (const)", "APPROVAL_STATUSES": "src/contracts/approval-service.ts#APPROVAL_STATUSES (const)", + "APPROVAL_STATUS_LABELS": "src/contracts/approval-service.ts#APPROVAL_STATUS_LABELS (const)", "AdapterContext": "src/contracts/knowledge-adapter.ts#AdapterContext (interface)", "AdapterSearchOptions": "src/contracts/knowledge-adapter.ts#AdapterSearchOptions (interface)", "AnalyticsDriverCapabilities": "src/contracts/analytics-service.ts#AnalyticsDriverCapabilities (interface)", diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index e91133f0ad..93e1b37e77 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -55,6 +55,31 @@ export const APPROVAL_STATUSES = [ /** Lifecycle state of an approval request — derived from {@link APPROVAL_STATUSES}. */ export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number]; +/** + * Authored English display label for each {@link APPROVAL_STATUSES} entry — + * what an English reader sees in the `sys_approval_request.status` select, + * badges, and the Approvals Inbox (#8543). + * + * These five strings used to live ONLY in `plugin-approvals`' generated `en` + * bundle (#7232 humanized them there), which made the bundle the sole home of + * deliberately-authored English — exactly what broke the "`en` is a copy of + * the source" invariant the i18n extractor's default-locale channel relies on. + * Promoting them here puts English in one place: the column derives its option + * labels from this map (never re-typed at the column — #3786's rule extended + * to labels), and the `en` bundle is regenerated from it verbatim. + * + * `satisfies` is exhaustive in both directions: a status added to + * {@link APPROVAL_STATUSES} without a label — or a label for a status the + * vocabulary dropped — fails to compile. + */ +export const APPROVAL_STATUS_LABELS = { + pending: 'Pending', + approved: 'Approved', + rejected: 'Rejected', + recalled: 'Recalled', + returned: 'Returned', +} as const satisfies Record; + /** Live request row. */ export interface ApprovalRequestRow { id: string; @@ -280,6 +305,36 @@ export const APPROVAL_ACTION_KINDS = [ /** Kinds of entries on a request's audit trail — derived from {@link APPROVAL_ACTION_KINDS}. */ export type ApprovalActionKind = (typeof APPROVAL_ACTION_KINDS)[number]; +/** + * Authored English display label for each {@link APPROVAL_ACTION_KINDS} entry — + * what an English reader sees in the `sys_approval_action.action` column of a + * request's audit trail (#8580). + * + * The #7232 humanization pass covered `sys_approval_request.status` and missed + * this sibling field, so the shipped `en` bundle rendered the raw machine + * values (`submit`, `request_info`, …) — `fieldOptionLabel` in + * `@object-ui/i18n` falls back to the option's own label with no humanization + * step, so what is in the bundle is what renders. Same contract-first shape as + * {@link APPROVAL_STATUS_LABELS}: the column derives its option labels from + * this map, and the `en` bundle is regenerated from it verbatim. + * + * `satisfies` is exhaustive in both directions, same as the status map. + */ +export const APPROVAL_ACTION_KIND_LABELS = { + submit: 'Submit', + approve: 'Approve', + reject: 'Reject', + recall: 'Recall', + escalate: 'Escalate', + reassign: 'Reassign', + remind: 'Remind', + request_info: 'Request Info', + comment: 'Comment', + revise: 'Revise', + resubmit: 'Resubmit', + ooo_substitute: 'Out-of-Office Substitution', +} as const satisfies Record; + /** * A file attached to a decision action (#3266) — the READ shape of one * `sys_approval_action.attachments` entry. diff --git a/scripts/check-i18n-bundles.mjs b/scripts/check-i18n-bundles.mjs index 18d88f88c2..3e3c4f4cbe 100644 --- a/scripts/check-i18n-bundles.mjs +++ b/scripts/check-i18n-bundles.mjs @@ -820,8 +820,10 @@ if (broken.length || drifted.length || undeclared.length) { if (drifted.length) { console.error( `\nRegenerate and commit: node scripts/check-i18n-bundles.mjs --write\n` + - `Merge mode is on, so no existing translation is overwritten — new schema keys are\n` + - `added filled with the source text, and they still need translating.`, + `Merge mode is on for translated locales, so no existing translation is\n` + + `overwritten — new schema keys are added filled with the source text, and they\n` + + `still need translating. The default locale (en) is rewritten from the source\n` + + `on every run (#8543): it is a copy of the source, not a translation.`, ); } } From 18b78f78d427e6f3670831fcb06d931e60ef99fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 00:51:23 +0000 Subject: [PATCH 2/4] docs(agents): state the default-locale bundle lesson self-contained, no issue-ID citation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corrected Documentation Guardrails row cited the card's issue number in operative text, which check-skill-id-lint rejects: a lesson there must stand on its own (failure mode + discipline + boundary) because a reader hitting the rule cannot go read the issue. The row now says what went wrong (a merged default locale let a stale bundle entry beat an edited source string, drifting silently under a green gate), what to do (edit the source metadata, not the generated en file), and where the boundary sits (translated-locale values stay hand-written and merge-protected) — number dropped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d9e4870607..fb728cb96a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -478,7 +478,7 @@ Root also exports: `defineStack`, `composeStacks`, `defineView`, `defineApp`, `d |:---|:---|:---| | `content/docs/references/` | **AUTO-GEN** | ❌ Never hand-edit. Regenerated by `packages/spec/scripts/build-docs.ts`. | | `content/docs/releases/` | **RELEASE-OWNED** | ❌ Never edit in a code PR. Release notes are written **centrally at release time**, compiled from changesets + the ADR-0087 registries — not accreted a row per PR. Per-PR appends made `releases/v.mdx` the repo's hottest conflict magnet (three PRs raced the same table inside one afternoon), and every manual resolution risks dropping someone else's row. Your PR's input is its **changeset**; for spec removals also the D2/D3 registry entries. Factual error on a releases page → dedicated docs-only PR or an issue, never a rider on code changes. | -| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode preserves every existing **translated-locale** value; the default locale `en` is rewritten from the source on every run — it is a copy of the source, not a translation, so hand-edits to `en.*.generated.ts` do not survive and belong in the source metadata instead, #8543); `pnpm i18n:extract` still covers `platform-objects` alone. Translated-locale *values* (`zh-CN` / `ja-JP` / `es-ES`) are hand-written and expected to be: the gate compares against a merge-mode extract, so editing one of those strings is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. | +| `**/translations/*.generated.ts` (nine packages — `platform-objects`, five plugins, three services) | **AUTO-GEN** | ❌ Never hand-edit the file *structure*. Run `node scripts/check-i18n-bundles.mjs --write` to regenerate all nine (merge mode preserves every existing **translated-locale** value; the default locale `en` is rewritten from the source on every run — it is a copy of the source, not a translation: when the extractor merged `en` too, a stale bundle entry always beat an edited source string, so the served text drifted from the source silently under a green gate. Hand-edits to `en.*.generated.ts` therefore do not survive and belong in the source metadata instead); `pnpm i18n:extract` still covers `platform-objects` alone. Translated-locale *values* (`zh-CN` / `ja-JP` / `es-ES`) are hand-written and expected to be: the gate compares against a merge-mode extract, so editing one of those strings is fine, while adding or dropping keys is drift. `pnpm check:i18n` gates all nine in CI, and `pnpm check:i18n-coverage` ratchets untranslated declared labels. | | `content/docs/guides/` | hand-written | ✅ Update `meta.json` when adding pages. | | `content/docs/concepts/` | hand-written | ✅ | | `content/docs/getting-started/` | hand-written | ✅ | From 3b06a42d31786f359957470516655c637671c9e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 01:18:55 +0000 Subject: [PATCH 3/4] test(cli): annotate the three new test-layer params the TEST_DEBT ratchet caught The two i18n test files live in the package's hidden test layer (tsconfig includes only src), where a frozen TS2835 relative-import cascade makes every bare arrow parameter an implicit any. The three parameters the #8543 tests added (one map in i18n-extract.test.ts, one filter+map pair in i18n-coverage.test.ts) each cost +1 raw tsc error against the shrink-only TEST_DEBT entry (188 -> 191). Structural annotations bring the measurement back to exactly 188; the ledger itself is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz --- packages/cli/test/i18n-coverage.test.ts | 9 ++++++++- packages/cli/test/i18n-extract.test.ts | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/i18n-coverage.test.ts b/packages/cli/test/i18n-coverage.test.ts index 1989726360..da4ab874d6 100644 --- a/packages/cli/test/i18n-coverage.test.ts +++ b/packages/cli/test/i18n-coverage.test.ts @@ -206,7 +206,14 @@ describe('computeI18nCoverage', () => { ], }; const report = computeI18nCoverage(externalized, { defaultLocale: 'en' }); - const zhKeys = new Set(report.issues.filter((i) => i.locale === 'zh-CN').map((i) => i.key)); + // Structural annotations: the module import is outside this file's tsc + // program reach (frozen TS2835 debt), so bare parameters here would be + // implicitly-any additions to the package's TEST_DEBT ledger. + const zhKeys = new Set( + report.issues + .filter((i: { locale: string }) => i.locale === 'zh-CN') + .map((i: { key: string }) => i.key), + ); expect(zhKeys.has('objects.account.fields.stage.options.planning')).toBe(true); }); diff --git a/packages/cli/test/i18n-extract.test.ts b/packages/cli/test/i18n-extract.test.ts index 0613222340..386e651618 100644 --- a/packages/cli/test/i18n-extract.test.ts +++ b/packages/cli/test/i18n-extract.test.ts @@ -163,7 +163,12 @@ describe('collectExpectedEntries', () => { ], }; const entries = collectExpectedEntries(cfg); - const byPath = Object.fromEntries(entries.map((e) => [e.path.join('.'), e])); + // Structural annotation: the module import is outside this file's tsc + // program reach (frozen TS2835 debt), so the parameter would otherwise be + // an implicitly-any addition to the package's TEST_DEBT ledger. + const byPath = Object.fromEntries( + entries.map((e: { path: string[] }) => [e.path.join('.'), e]), + ); const opt = (p: string) => byPath[`objects.w.fields.${p}`]; // Derived: seeded from the value so skeletons stay usable, but `inline` From 4f06e9c1fb2c9f3e572b7523b0c557b892e152d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:44:43 +0000 Subject: [PATCH 4/4] regen(i18n): regenerate plugin-security en bundle from the merged source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discharges the merge commit's placeholder: node scripts/check-i18n-bundles.mjs --write over the merged tree. The result carries both sides — main's #8599 source wording and this branch's source-tracking rewrites — as the extractor's own output, not a hand-picked side. Only the en file changed; the 30 translated-locale bundles were rewritten in place and came back byte-identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jqe56GnYFddggeAyfkZFVz --- .../src/translations/en.objects.generated.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index cd2a0e1023..e91d514384 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -12,14 +12,14 @@ export const enObjects: NonNullable = { sys_position: { label: "Position", pluralLabel: "Positions", - description: "Position definitions — flat capability-distribution groups (ADR-0090 D3)", + description: "Position definitions for capability distribution (ADR-0090)", fields: { label: { label: "Display Name" }, name: { label: "API Name", - help: "Unique machine name for the position (e.g. admin, editor, viewer)" + help: "Machine name for the position, unique per organization (e.g. sales_manager, hr_specialist)" }, description: { label: "Description" @@ -41,7 +41,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform (built-in) / package (declared) / admin (tenant-created).", + help: "Record provenance (unified tri-state, A4 #2920): platform = framework built-in (read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows may carry system (== platform) / config (== package) / user (== admin).", options: { platform: "Platform", package: "Package", @@ -79,7 +79,7 @@ export const enObjects: NonNullable = { }, deactivate_position: { label: "Deactivate Position", - confirmText: "Deactivate this position? Users with the position keep their assignment but the position stops granting permissions until re-activated.", + confirmText: "Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.", successMessage: "Position deactivated" }, set_default_position: { @@ -96,7 +96,7 @@ export const enObjects: NonNullable = { }, name: { label: "New API Name", - helpText: "Unique snake_case machine name" + helpText: "snake_case machine name, unique per organization" } } } @@ -105,7 +105,7 @@ export const enObjects: NonNullable = { sys_capability: { label: "Capability", pluralLabel: "Capabilities", - description: "Authorization capability definitions referenced by name from permission sets and resource requirements.", + description: "Authorization capability definitions (ADR-0066 D1). Referenced by name from permission-set systemPermissions and resource requiredPermissions.", fields: { label: { label: "Display Name" @@ -127,7 +127,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform / package (shipped, not user-deletable) / admin (created in Setup).", + help: "platform/package-owned capabilities are shipped and not user-deletable; admin-owned are created in Setup.", options: { platform: "Platform", package: "Package", @@ -184,7 +184,7 @@ export const enObjects: NonNullable = { }, name: { label: "API Name", - help: "Machine name for the permission set, unique per organization" + help: "Machine name for the permission set, unique per organization. This is the set’s metadata identity (ADR-0094) and cannot be changed after creation — the data door rejects a rename; clone the set to a new name instead." }, description: { label: "Description" @@ -222,7 +222,7 @@ export const enObjects: NonNullable = { }, managed_by: { label: "Managed By", - help: "Record provenance: platform (shipped) / package (packaged) / admin (env-authored).", + help: "Record provenance (unified tri-state, A4 #2920): 'platform' = shipped by the platform; 'package' = versioned package metadata (re-seeded on upgrade, read-mostly for admins); 'admin' = created/owned in this environment by an administrator (live-edited, never touched by package seeding). Legacy rows may carry 'user' (== admin).", options: { platform: "Platform", package: "Package",