diff --git a/.changeset/book-job-translation-app-authorwarn-keys-retired.md b/.changeset/book-job-translation-app-authorwarn-keys-retired.md new file mode 100644 index 0000000000..2fe7c4d2b1 --- /dev/null +++ b/.changeset/book-job-translation-app-authorwarn-keys-retired.md @@ -0,0 +1,67 @@ +--- +'@objectstack/spec': major +'@objectstack/example-todo': patch +--- + +feat(spec)!: retire the six remaining `authorWarn` dead keys — book/group `translations`, `job.id`, `translation.validationMessages`, `app.homePageId`, `app.areas[].order` (#4667) + +The #4488 liveness audit marked as `authorWarn` the keys whose *declaration* +actively misleads — not merely unread, but shaped so an author reasonably +concludes they configure something. #4509 and #4583 cleared the rest; these six +are what remained, and each shipped with its own reason for reading alive. + +**The retirement kit:** + +| FROM | TO | Fix | +|---|---|---| +| `book.translations` | *(removed)* | Delete the key. Localize the **docs** — `doc.translations` is live on every doc render path. | +| `book.groups[].translations` | *(removed)* | Same. Tombstoned, since `BookGroupSchema` is not `.strict()`. | +| `job.id` | *(removed)* | Delete the key. `name` is the job's identity everywhere. | +| `translation.validationMessages` | *(removed)* | Delete the key. Author the message on the rule: `object.validations[].message`. | +| `app.homePageId` | *(removed)* | Delete the key. Reorder `navigation`; set `isDefault` for the root landing. | +| `app.areas[].order` | *(removed)* | Delete the key. Reorder the `areas` array itself. | + +Run `os migrate meta --from 16` to rewrite existing sources automatically. + +**Each read alive for a different reason, and the prescriptions say which:** + +- **book `translations`** — *proximity*. `doc.translations`, two files over, same + name and shape, works on every read path. The book-level map was parsed, + stored and round-tripped, and rendered in the authoring locale to every + reader: the tree endpoint and the portal emit `label` / `description` + verbatim. +- **`job.id`** — *its own description*. "Defaults to `name` when omitted" + advertises an identity override that does not exist. `name` is the scheduling + key, the `sys_job` row key, and the `JobExecution.jobId` stamp — so two jobs + differing only in `id` were one job declared twice. +- **`translation.validationMessages`** — *the platform's own signposts, twice*. + The schema example showed a concrete override, and #3778's legacy-key + migration table steered retired `errors:` authors straight into it. **That + guidance entry is rewritten here**: retiring one dead key by pointing at + another is the defect, not the fix. +- **`app.homePageId`** — *its own hedge*. "If not set, usually defaults to the + first navigation item" described the only behaviour there was. +- **`app.areas[].order`** — *the sibling that works*. Nav-item `order` really is + sorted; area-level order never was, and both renderers iterate the array as + authored. + +**Routes differ, deliberately.** `book.groups[].translations` and +`app.homePageId` are **tombstoned** (`retiredKey`: `never` at compile time, a +prescription at parse time) — the group schema is a plain `z.object`, where a +bare delete would have zod silently strip the key, trading one silent no-op for +another. The other four are strict deletions carrying `guidance`. Retired alias +spellings (`i18n`, `home`, `homepage`, `landingpage`, `sort`) route to the same +prescriptions rather than renaming onto keys that are gone. + +Registered as three ADR-0087 D2 conversions (`book-translations-removed`, +`job-id-removed`, `translation-validation-messages-removed`) plus an extension +of `app-dead-authoring-keys-removed`, all wired into the protocol-17 D3 chain. + +**Also corrected, both found by the gates rather than by grep:** the published +`objectstack-i18n` skill taught `validationMessages` in a copy-paste example +(an AI reproduces that verbatim), and `examples/app-todo` authored the group in +three locales — where the `en` entries merely duplicated the rule's own text and +the zh-CN / ja-JP translations had never once been rendered. + +After this, the only `authorWarn` keys left in the ledger are the two fail-open +area gates tracked in #4651, which need a decision rather than a patch. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index d25db79f97..d4ccc23bf5 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -193,7 +193,6 @@ const zh: TranslationData = { pages: { landing: { title: '欢迎' } }, globalActions: { export_csv: { label: '导出 CSV' } }, messages: { 'common.save': '保存' }, - validationMessages: { 'discount_limit': '折扣不能超过40%' }, }; ``` @@ -841,7 +840,7 @@ options: { 'Direct Mail': '直邮' } // ❌ keyed by the label — never resolv options: { 'direct-mail': '直邮' } // ❌ a variant spelling of the value ``` -`messages`, `validationMessages`, `settings`, `settingsCommon` and +`messages`, `settings`, `settingsCommon` and `metadataForms` are **not** checked: their keys are owned by application code, plugins, and the platform's own metadata-type registry rather than by this stack's metadata, so there is no set of legal names to resolve against. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 604cfcebb2..8be9c64e2d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -862,7 +862,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **locale** | `string` | ✅ | Locale code | -| **translations** | `{ objects?: Record }>; … }>; apps?: Record }>; messages?: Record; validationMessages?: Record; … }` | ✅ | Translation data | +| **translations** | `{ objects?: Record }>; … }>; apps?: Record }>; messages?: Record; globalActions?: Record }>; … }>; … }` | ✅ | Translation data | --- diff --git a/content/docs/references/system/book.mdx b/content/docs/references/system/book.mdx index 36b80ff021..d73a33c0ce 100644 --- a/content/docs/references/system/book.mdx +++ b/content/docs/references/system/book.mdx @@ -60,12 +60,11 @@ const result = BookSchema.parse(data); | **name** | `string` | ✅ | Book name (namespace prefix recommended, like every metadata name) | | **label** | `string` | optional | Display title | | **description** | `string` | optional | | -| **translations** | `Record` | optional | | | **slug** | `string` | optional | Portal URL segment; defaults to name sans prefix | | **icon** | `string` | optional | | | **order** | `number` | optional | Orders books within the portal | | **audience** | `'org' \| 'public' \| { permissionSet: string }` | optional | Access audience; defaults to 'org' (inherits package grant) | -| **groups** | `{ key: string; label: string; translations?: Record; order?: number; … }[]` | ✅ | The spine: ordered sections. Two levels total. | +| **groups** | `{ key: string; label: string; translations?: any; order?: number; … }[]` | ✅ | The spine: ordered sections. Two levels total. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | | **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | @@ -116,7 +115,7 @@ Type: `'public'` | :--- | :--- | :--- | :--- | | **key** | `string` | ✅ | Stable group key (used by overrides, deep links, explicit `doc.group`) | | **label** | `string` | ✅ | Section title — first-class, i18n-homed | -| **translations** | `Record` | optional | Per-locale label variants | +| **translations** | `any` | optional | [REMOVED] Inline `translations` on a book (and on a book group) was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the docs portal render `label` / `description` verbatim in every locale, so a localized book shipped its authoring-locale strings to every reader. Delete the key. NOTE the near neighbour that DOES work: `doc.translations` is live and read on every doc render path — localize the docs themselves, and the portal picks the reader's locale up from there. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **order** | `number` | optional | Order of THIS group within the book | | **include** | `string \| { tag: string }` | optional | Rule that derives membership (glob or tag) | | **package** | `string` | optional | Scope the rule to a package id (default: the book package; cross-package via ADR-0048) | diff --git a/content/docs/references/system/job.mdx b/content/docs/references/system/job.mdx index 54d159f9d2..1976c85006 100644 --- a/content/docs/references/system/job.mdx +++ b/content/docs/references/system/job.mdx @@ -56,7 +56,6 @@ const result = CronScheduleSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | optional | Unique job identifier (defaults to `name` when omitted) | | **name** | `string` | ✅ | Job name (snake_case) | | **label** | `string` | optional | Human-readable label | | **description** | `string` | optional | Job description / purpose | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index a74a9b0b89..f4b879ac25 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -156,7 +156,6 @@ Translation data for objects, apps, and UI messages | **objects** | `Record }>; … }>` | optional | Object translations keyed by object name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | -| **validationMessages** | `Record` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) | | **globalActions** | `Record }>; … }>` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | @@ -210,7 +209,6 @@ One locale of translations — the `translation` metadata type | **objects** | `Record }>; … }>` | optional | Object translations keyed by object name | | **apps** | `Record }>` | optional | App translations keyed by app name | | **messages** | `Record` | optional | UI message translations keyed by message ID | -| **validationMessages** | `Record` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) | | **globalActions** | `Record }>; … }>` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record }>` | optional | Dashboard translations keyed by dashboard name | | **pages** | `Record` | optional | Page translations keyed by page name | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 3bd2579dd4..98256ec670 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -81,9 +81,9 @@ const result = ActionNavItemSchema.parse(data); | **isDefault** | `boolean` | optional | Is default app | | **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead | | **navigation** | `{ id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { id: string; label: string; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| { id: string; label: string; icon?: string; order?: number; … }[]` | optional | Full navigation tree for the app sidebar | -| **areas** | `{ id: string; label: string; icon?: string; order?: number; … }[]` | optional | Navigation areas for partitioning navigation by business domain | +| **areas** | `{ id: string; label: string; icon?: string; description?: string; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | -| **homePageId** | `string` | optional | ID of the navigation item to serve as landing page | +| **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell ever read it. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. | | **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), not the app shell. Delete the key. | @@ -210,7 +210,6 @@ const result = ActionNavItemSchema.parse(data); | **id** | `string` | ✅ | Unique area identifier (lowercase snake_case) | | **label** | `string` | ✅ | Area display label | | **icon** | `string` | optional | Area icon name | -| **order** | `number` | optional | Sort order among areas (lower = first) | | **description** | `string` | optional | Area description | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) for this area. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this area | diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 1276d19e52..e09ce702bb 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -50,10 +50,6 @@ const crmApp = { | `navigation` | `NavigationItem[]` | optional | Navigation tree | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | -| `homePageId` | `string` | optional | ID of the navigation item to serve as the landing page | -| `objects` | `unknown[]` | optional | Objects belonging to this app | -| `apis` | `unknown[]` | optional | Custom APIs belonging to this app | -| `mobileNavigation` | `object` | optional | Mobile-specific navigation | ## Navigation Items @@ -181,7 +177,8 @@ label — the only keys it accepts are `type`, an optional `id`, and an optional All navigation items **except `separator`** share these base properties. Every item **must** declare a unique `id` (lowercase `snake_case`) — it is required by -the schema and is referenced by `homePageId` and `mobileNavigation.bottomNavItems`. +the schema, and it is what `order` sorts and what the landing page resolves to +(the first item, since `homePageId` was removed in 17.0.0 — see below). (On a `separator`, `id` is optional and the remaining properties below are rejected — a divider has nothing to label, gate, or badge.) @@ -286,7 +283,9 @@ const projectApp = { ], requiredPermissions: ['pm_access'], - homePageId: 'nav_home', + // No `homePageId`: the landing page IS the first navigation item (by `order`), + // and the ROOT landing follows `isDefault`. The key was removed in 17.0.0 (#4667) + // because nothing ever read it. }; ``` diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index f1ada45c3f..5f0a9b4fcc 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -235,9 +235,11 @@ up-to-date bundle re-extracts byte-identically. Honest limits worth knowing before you plan around them: -- **`validationMessages` has no runtime consumer today.** The schema accepts it - and the coverage tooling counts it, but nothing reads it back — validation - errors are not translated through bundles yet. +- **Validation messages are not translated through bundles.** The + `validationMessages` group was removed in 17.0.0 (#4667): it had no runtime + consumer, so a translated rule message was stored and never shown. Author the + message on the rule itself (`object.validations[].message`), which the engine + returns on every rejected write. - **No ICU MessageFormat** — plural/gender formatting isn't available; interpolation is always simple `{variable}` substitution. - **Runtime authoring is process-wide.** The authored layer is synced across all diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index dd0447651e..6cdd1426b3 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -183,7 +183,7 @@ Finally, five keys retire because the advisory lint could never have warned abou | `flow-node-script-config-aliases` | `flow.node.script.config` | script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796) | live — protocol 17 loader accepts the old shape | | `permission-rls-priority-removed` | `permission.rowLevelSecurity.priority` | RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome) | retired — `migrate meta` only | | `tool-inert-authoring-keys-removed` | `tool.category / tool.permissions / tool.active / tool.builtIn` | tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing) | retired — `migrate meta` only | -| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' plus contextSelectors 'includeAll'/'placement' removed (liveness audits #4001, #4509 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, and includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope) | retired — `migrate meta` only | +| `app-dead-authoring-keys-removed` | `app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order` | app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas) | retired — `migrate meta` only | | `field-required-notnull-explicit` | `object.fields.*.required / object.fields.*.storage.notNull` | required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract) | retired — `migrate meta` only | | `action-inert-keys-removed` | `action.shortcut / action.bulkEnabled` | action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions) | retired — `migrate meta` only | | `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only | @@ -197,6 +197,9 @@ Finally, five keys retire because the advisory lint could never have warned abou | `datasource-capabilities-removed` | `datasource.capabilities` | datasource key 'capabilities' removed (#4583 — eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only) | retired — `migrate meta` only | | `datasource-inert-blocks-removed` | `datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission` | datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (#4583 — nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody) | retired — `migrate meta` only | | `mapping-inert-keys-removed` | `mapping.extractQuery / mapping.errorPolicy / mapping.batchSize` | mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (#4509 — no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches) | retired — `migrate meta` only | +| `book-translations-removed` | `book.translations / book.groups.translations` | book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live | retired — `migrate meta` only | +| `job-id-removed` | `job.id` | job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist) | retired — `migrate meta` only | +| `translation-validation-messages-removed` | `translation.validationMessages` | translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`) | retired — `migrate meta` only | | `datasource-config-driver-key-aliases` | `datasource.config` | datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation) | retired — `migrate meta` only | | `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only | diff --git a/examples/app-todo/src/translations/en.ts b/examples/app-todo/src/translations/en.ts index 3750764219..81e62a4610 100644 --- a/examples/app-todo/src/translations/en.ts +++ b/examples/app-todo/src/translations/en.ts @@ -108,8 +108,8 @@ export const en: TranslationData = { 'error.required': 'This field is required', 'error.load_failed': 'Failed to load data', }, - validationMessages: { - completed_date_required: 'Completed date is required when status is Completed', - recurrence_fields_required: 'Recurrence type is required for recurring tasks', - }, + // `validationMessages` retired in spec 17.0.0 (#4667) — no resolver ever read + // it, so the zh-CN / ja-JP strings here were never rendered and the `en` ones + // merely duplicated the rule's own text. The live home for these messages is + // `validations[].message` on the task object. }; diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index 2eb31e1885..2f0c7bf5c8 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -112,8 +112,4 @@ export const jaJP: TranslationData = { 'error.required': 'この項目は必須です', 'error.load_failed': 'データの読み込みに失敗しました', }, - validationMessages: { - completed_date_required: 'ステータスが「完了」の場合、完了日は必須です', - recurrence_fields_required: '繰り返しタスクには繰り返しタイプが必要です', - }, }; diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 509365ba46..1ef14e60b9 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -116,8 +116,4 @@ export const zhCN: TranslationData = { 'error.required': '此字段为必填项', 'error.load_failed': '数据加载失败', }, - validationMessages: { - completed_date_required: '状态为"已完成"时,完成日期为必填项', - recurrence_fields_required: '周期性任务必须指定重复类型', - }, }; diff --git a/packages/cli/src/utils/lint-liveness-properties.test.ts b/packages/cli/src/utils/lint-liveness-properties.test.ts index 4c83549e3e..320ea9c03e 100644 --- a/packages/cli/src/utils/lint-liveness-properties.test.ts +++ b/packages/cli/src/utils/lint-liveness-properties.test.ts @@ -261,16 +261,19 @@ describe('lintLivenessProperties', () => { // The app ledger's most important entries: area-level gating keys that FAIL // OPEN (nothing evaluates them, so a "hidden"/"gated" area shows for // everyone), on the surface whose item-level siblings ARE enforced. - it('warns on the fail-open area gates and dead homePageId (#4488)', () => { + // `homePageId` and `areas.order` used to be asserted here too. Both were + // RETIRED in 17.0.0 (#4667) — the schema owns them now (a tombstone and a + // strict rejection respectively), so this advisory lint correctly says + // nothing about them. The two that remain are the ones #4651 still has to + // decide, and they are the reason this test exists. + it('warns on the fail-open area gates (#4488, tracked as #4651)', () => { const findings = lintLivenessProperties({ apps: [{ name: 'crm', label: 'CRM', - homePageId: 'nav_pipeline', areas: [{ id: 'area_sales', label: 'Sales', - order: 2, visible: "'sales' in current_user.positions", requiredPermissions: ['crm.access'], navigation: [], @@ -278,8 +281,6 @@ describe('lintLivenessProperties', () => { }], }); const msgs = paths(findings); - expect(msgs.some((m) => m.includes('homePageId'))).toBe(true); - expect(msgs.some((m) => m.includes('areas.order'))).toBe(true); expect(msgs.some((m) => m.includes('areas.visible'))).toBe(true); expect(msgs.some((m) => m.includes('areas.requiredPermissions'))).toBe(true); // The gating hints must point at the enforced alternative (per-item gates), @@ -307,45 +308,29 @@ describe('lintLivenessProperties', () => { expect(findings).toEqual([]); }); - // translation.validationMessages: pointed at by #3778's own migration table, - // read by nothing — the hint must say what actually renders (rule.message). - it('warns on translation.validationMessages (#4488)', () => { - const findings = lintLivenessProperties({ - translations: [{ - name: 'zh_cn', - locale: 'zh-CN', - validationMessages: { discount_limit: '折扣不能超过40%' }, - }], - }); - const hit = findings.find((f) => f.message.includes('validationMessages')); - expect(hit).toBeDefined(); - expect(hit!.hint).toMatch(/message/); - }); - - // book: both inline translations maps are dead (the doc-level map two files - // over works, which is what makes these read alive); job.id is the other flat - // dead key. `mapping.extractQuery` used to be asserted here too — it was - // REMOVED in 17.0.0 (#4509), so the strict parse owns it now and this - // advisory lint correctly says nothing about it. - it('warns on book/job dead keys (#4488)', () => { + // The advisory lint's job is the keys the PARSE still accepts. #4667 retired + // the last batch it was warning about — book `translations` (both levels), + // `job.id`, `translation.validationMessages`, `app.homePageId`, + // `app.areas[].order` — so those warnings are gone by design: a strict + // rejection or a `retiredKey` tombstone reaches the author harder and earlier + // than an advisory line, and warning about a key that no longer parses would + // be noise. `mapping.extractQuery` left the same way in #4509. + // + // This test pins that the lint has genuinely fallen silent on them, so a + // half-reverted retirement (schema restored, ledger not, or vice versa) shows + // up here rather than as a mysteriously chatty lint. + it('is silent on the keys retired in #4509 / #4667 — the schema owns them now', () => { const findings = lintLivenessProperties({ - books: [{ - name: 'crm_guide', - label: 'CRM Guide', - translations: { 'zh-CN': { label: 'CRM 指南' } }, - groups: [{ key: 'basics', label: 'Basics', translations: { 'zh-CN': { label: '基础' } } }], - }], + books: [{ name: 'crm_guide', label: 'CRM Guide', groups: [{ key: 'basics', label: 'Basics' }] }], jobs: [{ name: 'nightly_sync', - id: 'job_nightly', schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'syncAll', }], + translations: [{ name: 'zh_cn', locale: 'zh-CN', messages: { 'common.save': '保存' } }], + apps: [{ name: 'crm', label: 'CRM', navigation: [] }], }); - const msgs = paths(findings); - expect(msgs.some((m) => m.includes('`translations`'))).toBe(true); - expect(msgs.some((m) => m.includes('groups.translations'))).toBe(true); - expect(msgs.some((m) => m.includes('`id`'))).toBe(true); + expect(findings).toEqual([]); }); // The unwarnable-default rule, and what became of it. diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 189f5c110f..2fa5f82613 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -5713,14 +5713,13 @@ "system/Book:name", "system/Book:order", "system/Book:slug", - "system/Book:translations", "system/BookGroup:include", "system/BookGroup:key", "system/BookGroup:label", "system/BookGroup:order", "system/BookGroup:package", "system/BookGroup:pages", - "system/BookGroup:translations", + "system/BookGroup:translations [RETIRED]", "system/BucketConfig:accessControl", "system/BucketConfig:bucketName", "system/BucketConfig:description", @@ -6164,7 +6163,6 @@ "system/Job:description", "system/Job:enabled", "system/Job:handler", - "system/Job:id", "system/Job:label", "system/Job:name", "system/Job:retryPolicy", @@ -7008,7 +7006,6 @@ "system/TranslationData:pages", "system/TranslationData:settings", "system/TranslationData:settingsCommon", - "system/TranslationData:validationMessages", "system/TranslationDiffItem:aiConfidence", "system/TranslationDiffItem:aiSuggested", "system/TranslationDiffItem:key", @@ -7035,7 +7032,6 @@ "system/TranslationItem:pages", "system/TranslationItem:settings", "system/TranslationItem:settingsCommon", - "system/TranslationItem:validationMessages", "system/VectorClock:clock", "system/WorkerStats:active", "system/WorkerStats:avgExecutionMs", @@ -7150,7 +7146,7 @@ "ui/App:description", "ui/App:embed [RETIRED]", "ui/App:hidden", - "ui/App:homePageId", + "ui/App:homePageId [RETIRED]", "ui/App:icon", "ui/App:isDefault", "ui/App:label", @@ -7786,7 +7782,6 @@ "ui/NavigationArea:id", "ui/NavigationArea:label", "ui/NavigationArea:navigation", - "ui/NavigationArea:order", "ui/NavigationArea:requiredPermissions", "ui/NavigationArea:visible", "ui/NavigationConfig:mode", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 3056c82d51..73e9a95416 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -508,14 +508,14 @@ for t, v in r['types'].items(): | query | 16 | 1 | 4 | – | **not a metadata type** — the REQUEST surface (`QuerySchema`: client SDK QueryBuilder output; the `POST /data/:object/query` body), governed via `SPEC_ONLY_SCHEMAS` (#4286). The gate's one-level walk resolves 1 experimental; the 7 marker-experimental search affordances sit one level deeper, below the walk — resolved from `[EXPERIMENTAL — not enforced]` describe markers, not ledger entries (search `fuzzy`/`operator`/`boost`/`minScore`/`language`/`highlight` + `aggregations[].filter` — declared engine affordances no executor receives). The #4286 sweep closed out same-release: `having` ENFORCED 2026-07-31 (engine-side post-aggregation filter, both paths; was finding 1); dead 4 = the tombstoned removals `joins`/`windowFunctions`/`cursor`/`distinct` — REMOVED 2026-07-31 (retiredKey keeps each in the walked shape so the rows stay; protocol-17 semantic migrations; the JoinNode + WindowFunctionNode clusters and the `QueryBuilder.cursor()`/`.distinct()` producers deleted with their keys; `distinct`'s mis-wired REST count suppression deleted too — finding 2) | | datasource | 30 | 0 | 0 | 0 | seeded 2026-08-01 (#4487) — the **highest dead ratio of any governed type** (20 of 43), and it was ungoverned until now, which is not a coincidence: #4410/#4465/#4481 found six inert keys here by hand, two security-shaped (`schemaMode` left an external DB constructible as `managed` with DDL ungated; `ssl` configured nothing while looking configured). Dead set = `capabilities.*` (all 11 — the engine gates pushdown on the runtime driver's `supports.*` object, a non-overlapping vocabulary), `healthCheck.*` (3 — nothing schedules a datasource probe; the 20 `healthCheck` hits in the repo all belong to the PLUGIN health monitor and other surfaces), `retryPolicy.*` (4 — `retryPolicy` IS enforced on `hook` and `job`, which is what makes this one read alive; the shapes differ), `external.label`, `external.requirePermission`. **`capabilities.readOnly` is the one to know**: it reads as a safety switch, gates nothing, and two shipped prescriptions pointed authors at it until #4487 — `external.allowWrites: false` is the enforced write gate. `config` is a `z.record`, so its per-driver keys sit outside the walk (recorded in the entry's note, not silently skipped) **批 A CLOSED 2026-08-02 (#4583)**: the `capabilities` block — 11 flags, every one dead and authorWarn'd — was REMOVED rather than bridged; pushdown comes from the runtime driver's own `supports.*`, so there was nothing to connect it to. Its rows are deleted (strict-removal route), which is why dead falls 20 → 9. `readOnly` was the reason the audit was worth doing: it read as a safety switch, gated nothing, and had already been MOVED twice toward somewhere it might be enforced (#4410, #4465) — the shipped CRM example called a datasource a read replica on the strength of it while the datasource took writes. Removing it does NOT hand the author a working alternative: `external.allowWrites` only gates FEDERATED datasources, so a managed one has no read-only gate at all (#4584). Remaining 9 = healthCheck ×3 + retryPolicy ×4 + external ×2, batches B/C/D of #4583 **BATCHES B/C/D CLOSED 2026-08-02 — datasource now has ZERO dead properties**, down from the 20 it was seeded with (the highest dead ratio of any governed type). `retryPolicy` ×4 and `healthCheck` ×3 went as whole blocks, `external.label` / `external.requirePermission` as keys. None was bridgeable: each already had a different LIVE mechanism doing the job — the boot policy, the driver handle's on-demand `ping()`/`checkHealth()`, the top-level `label`, and ordinary permission sets + RLS. The `retryPolicy` rejection deliberately refuses to offer a rename: `hook`/`job` retryPolicy ARE enforced but spell the delay `backoffMs`, and that inconsistency is itself the evidence nothing read the datasource one (#4488's sharpest trap) | | webhook | 11 | 0 | 0 | – | **not a registered metadata type** — governed via the gate's spec-only schema override (`SPEC_ONLY_SCHEMAS`), not `getMetadataTypeSchema`; folding it onto the registry is the #3490 reassessment. This row once read 0/1/16 ("the ENTIRE authoring surface is dead", #3461) and both halves of that were CLOSED same-quarter: #3489 built the materializer bridge (authored `webhooks:` entries now land as `sys_webhook` dispatcher rows) and #3494 pruned the aspirational props outright — so the surviving surface is fully live. Kept in the table as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING | -| app | 45 | – | 12 | – | seeded 2026-08-01 (#4488). Dead 12 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (the landing IS the first nav item; root landing follows `isDefault` routing) + the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — nothing evaluates them while the per-ITEM siblings are enforced server- and client-side; `filterAppForUser` never reads `item.areas` at all (rest-server.ts:1823), so a "hidden" area shows to everyone. Both authorWarn'd, the audit's most important app finding, now tracked for decision as #4651 + `areas.order`/`description`. RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 | -| book | 13 | – | 2 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | +| app | 45 | – | 11 | – | seeded 2026-08-01 (#4488). Dead 12 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (the landing IS the first nav item; root landing follows `isDefault` routing) + the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — nothing evaluates them while the per-ITEM siblings are enforced server- and client-side; `filterAppForUser` never reads `item.areas` at all (rest-server.ts:1823), so a "hidden" area shows to everyone. Both authorWarn'd, the audit's most important app finding, now tracked for decision as #4651 + `areas.order`/`description`. RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 | **#4667**: `homePageId` TOMBSTONED (row stays — retiredKey keeps it in the walked shape) and `areas.order` row DELETED (strict removal). `homePageId` was described with its own hedge ("if not set, usually defaults to the first navigation item") — that WAS the only behaviour; `areas.order` read alive because the per-ITEM `order` really is sorted (NavigationRenderer.tsx:1154) while no renderer ever sorted areas. | +| book | 20 | – | 1 | – | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant can never match (DocSchema declares no `tags`) | **#4667**: both inline translation maps retired — book-level row DELETED (BookSchema is strictObject), group-level row KEPT as a tombstone (BookGroupSchema is a plain z.object with no .strict(), so a bare delete would have zod silently strip it). No resolver read either; the trap was proximity to `doc.translations`, which is live on every doc render path. | | doc | 15 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type. **`tags` DECLARED in 17.0.0 (#4509)** — the enforce half of enforce-or-remove: the book resolver's `include: { tag }` matcher, the REST transport and `ResolverDoc.tags` all already existed, but DocSchema is strict and had no `tags` key, so authoring one was a parse error and the variant could never match. Live on arrival | | email_template | 21 | 0 | 0 | 0 | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) | -| job | 13 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) | +| job | 13 | 0 | 2 | 0 | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowOrgOverride` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) | **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. Remaining dead = label/description, KEPT deliberately (docs-shaped, ADR-0033). | | mapping | 14 | – | 0 | – | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape | | seed | 5 | – | 0 | – | seeded 2026-08-01 (#4488). Fully live via SeedLoaderService on both doors (boot/per-org replay + runtime-draft publish). `records` is the z.record walk boundary: the keys an author writes are the target object's fields, governed by that object's own definitions — recorded in the entry, not silently skipped | -| translation | 10 | – | 1 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | +| translation | 17 | – | 2 | – | seeded 2026-08-01 (#4488) — after fixing the walker: the registered schema is a z.preprocess pipe (#3778 retired-dialect guard) whose transform side the unwrap always took, so the type was literally unwalkable. 10 of 11 groups live across spec resolvers, REST localization, objectui client resolvers and plugin-audit (whose composed-key `t()` calls make `messages` easy to mis-verify as dead). Dead 1 = `validationMessages` (authorWarn): nothing resolves it, and #3778's own legacy-key migration table steers `errors:` authors into it — a shipped false signpost, the capabilities.readOnly shape | **#4667**: `validationMessages` REMOVED (row deleted) — removed from the shared translationDataShape(), so it retired at BOTH doors at once, closing the item-only asymmetry #3778's original guard had. #3778's own `errors` guidance was rewritten in the same change: it had been steering authors INTO this dead group. | | validation | 15 | 0 | 3 | 0 | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index c307c6d478..81e18b8910 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -172,13 +172,6 @@ "verifiedAt": "2026-08-01", "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:448" }, - "order": { - "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Delete it — no renderer sorts areas (AppSidebar and AppSchemaRenderer both iterate the array as authored), so declaration order is the display order. Reorder the `areas` array instead.", - "note": "Contrast with nav-item `order`, which IS sorted (NavigationRenderer.tsx:1154) — the sibling that works is what makes this one read alive." - }, "description": { "status": "dead", "verifiedAt": "2026-08-01", @@ -248,10 +241,8 @@ }, "homePageId": { "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Delete it. No shell reads it — the landing IS the first navigation item (in `order`), and the ROOT landing follows `isDefault` routing (objectui RootLandingRedirect). Reorder `navigation` or set `isDefault` instead.", - "note": "The schema's own hedge ('if not set, usually defaults to the first navigation item') describes the only behavior that exists." + "verifiedAt": "2026-08-02", + "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). No shell ever read it — the landing IS the first navigation item (by order), and the ROOT landing follows isDefault routing (objectui RootLandingRedirect). The schema own hedge (\"if not set, usually defaults to the first navigation item\") described the only behavior that existed. Reorder navigation, or set isDefault, instead. The three aliases that routed here (home / homepage / landingpage) were removed from AppSchema aliases and now carry the same prescription as guidance." }, "requiredPermissions": { "status": "live", diff --git a/packages/spec/liveness/book.json b/packages/spec/liveness/book.json index 153280b1f5..9f439a22b2 100644 --- a/packages/spec/liveness/book.json +++ b/packages/spec/liveness/book.json @@ -20,13 +20,6 @@ "evidence": "objectui @940ba24: apps/console/src/pages/book-nav.ts:341 (buildBookCards)", "note": "portal landing-card subtitle. The framework tree omits it; the portal reads it off the raw /meta/book item." }, - "translations": { - "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Delete it. No resolver reads a book's inline translations map — the tree endpoint and the portal render `label`/`description` verbatim, and the generic bundle translator covers view/action/object/app/dashboard/page only (i18n-resolver.ts METADATA_DOCUMENT_TRANSLATORS), not `book`. Locale-variant doc CONTENT belongs on `doc.translations`, which IS enforced (resolveDocLocale); book/group titles currently have no i18n mechanism at all.", - "note": "The trap is proximity: `doc.translations` two files over works on every read path, so this map reads as the same feature. It is parsed and stored and nothing ever looks at it." - }, "slug": { "status": "live", "verifiedAt": "2026-08-01", @@ -67,10 +60,8 @@ }, "translations": { "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Delete it — see the book-level `translations` entry: no resolver reads inline book/group translations; group labels render verbatim in every locale.", - "note": "Same dead map one level down." + "verifiedAt": "2026-08-02", + "note": "REMOVED 2026-08-02 (#4667) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-17 conversion. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). TOMBSTONE rather than delete because BookGroupSchema is a plain z.object with NO .strict() — a bare delete would have zod silently strip the key, trading one silent no-op for another. No resolver read inline book/group translations; group labels rendered verbatim in every locale. Localize the DOCS instead: doc.translations is live on every doc render path." }, "order": { "status": "live", diff --git a/packages/spec/liveness/job.json b/packages/spec/liveness/job.json index af7e60d4cb..6e743eacb6 100644 --- a/packages/spec/liveness/job.json +++ b/packages/spec/liveness/job.json @@ -2,13 +2,6 @@ "type": "job", "_note": "JobSchema. The file-authored path is healthy: `defineStack({ jobs })` → app-plugin kernel:ready → IJobService.schedule (packages/runtime/src/app-plugin.ts:790-855) → the service-job adapters honor every schedule shape (packages/services/service-job/src/cron-job-adapter.ts:71-88) and runWithPolicy enforces retryPolicy/timeout (#3494 — these used to be parsed-but-ignored). `retryPolicy` here is the ENFORCED spelling ({maxRetries, backoffMs, backoffMultiplier}); do not confuse it with the datasource `retryPolicy`, which is dead and spells its delay differently. TYPE-LEVEL GAP CLOSED 2026-08-02 (#4509) by CLOSING THE DOOR, not building a bridge: `job` was registered `allowRuntimeCreate: true` while only the compiled bundle's `jobs` ever reached the scheduler, so a Studio-created job saved cleanly and never ran. Unlike the webhook (#3461) and email_template (#4509 item 1) disconnects, this one could not be bridged: `handler` names a function in the compiled bundle's function table (`collectBundleFunctions`, app-plugin.ts:812), which a runtime writer does not have and cannot name — the missing piece is a handler-binding design, not an ingestion path. So `allowRuntimeCreate` AND `allowOrgOverride` are now both false (metadata-plugin.zod.ts, with the rationale block), leaving `*.job.ts` / `defineStack({ jobs })` as the supported doors. The kind stays registered: its file loader is genuinely consumed, so it still passes the ADR-0088 admission test. Evidence lines restamped 2026-08-02 — the seeded set pointed at app-plugin.ts:767-791, which had drifted ~25 lines (the gate resolves paths, not line numbers, so nothing failed; this is the rot mode `verifiedAt` exists to catch). Seeded 2026-08-01.", "props": { - "id": { - "status": "dead", - "verifiedAt": "2026-08-02", - "authorWarn": true, - "authorHint": "Delete it — `name` is the job's identity everywhere: the scheduling key (app-plugin.ts:833), the sys_job row key (db-job-adapter upserts by `name` and mints its own row id), and the JobExecution.jobId stamp. Nothing reads `id`, so two jobs differing only in `id` are the same job.", - "note": "The describe() text ('defaults to `name` when omitted') implies an identity override that does not exist." - }, "name": { "status": "live", "verifiedAt": "2026-08-02", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index fb59f4fa09..5cba3a86aa 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -36,13 +36,6 @@ "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts:562-579, :744-745", "note": "consumed via II18nService.t: plugin-audit localizes activity-feed summaries (messages.activityCreated/Updated/Deleted, framework#3039) and collaboration notifications (messages.mentionedYou). Easy to mis-verify — no resolver in i18n-resolver.ts reads it; the consumer is a t() caller with composed keys, which a literal grep for the group name never finds." }, - "validationMessages": { - "status": "dead", - "verifiedAt": "2026-08-01", - "authorWarn": true, - "authorHint": "Delete it — nothing resolves `validationMessages.` in either repo. A validation violation renders the rule's own authored `message` verbatim (rule-validator.ts), and the #3957 message-translation hook covers only the BUILT-IN field messages via the i18n service's `validation.field.*` keys. There is currently no per-locale override mechanism for rule messages; until one ships, translate by authoring per-locale rules or keep messages locale-neutral.", - "note": "The trap has the platform's own signature on it twice: the schema example shows a concrete override ({\"discount_limit\": \"折扣不能超过40%\"}), and #3778's legacy-key migration table steers retired `errors:` authors here ('use validationMessages for rule messages'). Both point at a group with no reader — the capabilities.readOnly shape. objectui's spec-translations transform passes the group through to the client tree, but no client code looks anything up under it (a passthrough is not a consumer)." - }, "globalActions": { "status": "live", "verifiedAt": "2026-08-01", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 19321f904d..dac6871614 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -147,8 +147,8 @@ "toMajor": 17 }, { - "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement", - "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' plus contextSelectors 'includeAll'/'placement' removed (liveness audits #4001, #4509 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, and includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope)", + "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", "conversionId": "app-dead-authoring-keys-removed", "toMajor": 17 }, @@ -230,6 +230,24 @@ "conversionId": "mapping-inert-keys-removed", "toMajor": 17 }, + { + "surface": "book.translations / book.groups.translations", + "to": "book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", + "conversionId": "book-translations-removed", + "toMajor": 17 + }, + { + "surface": "job.id", + "to": "job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", + "conversionId": "job-id-removed", + "toMajor": 17 + }, + { + "surface": "translation.validationMessages", + "to": "translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`)", + "conversionId": "translation-validation-messages-removed", + "toMajor": 17 + }, { "surface": "datasource.config", "to": "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation)", @@ -673,8 +691,8 @@ "toMajor": 17 }, { - "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement", - "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' plus contextSelectors 'includeAll'/'placement' removed (liveness audits #4001, #4509 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, and includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope)", + "surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order", + "to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", "conversionId": "app-dead-authoring-keys-removed", "toMajor": 17 }, @@ -756,6 +774,24 @@ "conversionId": "mapping-inert-keys-removed", "toMajor": 17 }, + { + "surface": "book.translations / book.groups.translations", + "to": "book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", + "conversionId": "book-translations-removed", + "toMajor": 17 + }, + { + "surface": "job.id", + "to": "job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", + "conversionId": "job-id-removed", + "toMajor": 17 + }, + { + "surface": "translation.validationMessages", + "to": "translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`)", + "conversionId": "translation-validation-messages-removed", + "toMajor": 17 + }, { "surface": "datasource.config", "to": "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 164e99b027..2ed09fb9fd 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -1618,31 +1618,39 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { retiredFromLoadPath: true, surface: 'app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / ' - + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement', - summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation' plus contextSelectors 'includeAll'/'placement' removed (liveness audits #4001, #4509 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, and includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope)", + + 'app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / ' + + 'app.homePageId / app.areas.order', + summary: "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — never read; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, the landing page IS the first nav item, and no renderer ever sorted areas)", apply(stack, emit) { - const RETIRED = ['version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation']; + const RETIRED = [ + 'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', + 'homePageId', + ]; const RETIRED_SELECTOR = ['includeAll', 'placement']; return mapCollection(stack, 'apps', (app, path) => { const next = stripKeys(app, RETIRED, emit, path); - // `contextSelectors` is an ARRAY one level down, so stripKeys (top-level - // only) cannot reach it. Drill in and copy-on-write at both levels, so an - // app with nothing to strip keeps its identity for change detection. - const selectors = next.contextSelectors; - if (!Array.isArray(selectors)) return next; - let touched = false; - const nextSelectors = selectors.map((sel, i) => { - if (!sel || typeof sel !== 'object' || Array.isArray(sel)) return sel; - const stripped = stripKeys( - sel as Record, - RETIRED_SELECTOR, - emit, - `${path}.contextSelectors[${i}]`, - ); - if (stripped !== sel) touched = true; - return stripped; - }); - return touched ? { ...next, contextSelectors: nextSelectors } : next; + // `contextSelectors` and `areas` are ARRAYS one level down, so stripKeys + // (top-level only) cannot reach them. Drill in and copy-on-write at both + // levels, so an app with nothing to strip keeps its identity for change + // detection. + const stripInArray = (key: string, retired: string[], src: Record) => { + const arr = src[key]; + if (!Array.isArray(arr)) return src; + let touched = false; + const mapped = arr.map((el, i) => { + if (!el || typeof el !== 'object' || Array.isArray(el)) return el; + const stripped = stripKeys( + el as Record, + retired, + emit, + `${path}.${key}[${i}]`, + ); + if (stripped !== el) touched = true; + return stripped; + }); + return touched ? { ...src, [key]: mapped } : src; + }; + return stripInArray('areas', ['order'], stripInArray('contextSelectors', RETIRED_SELECTOR, next)); }); }, fixture: { @@ -1654,6 +1662,7 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { sharing: { enabled: true }, embed: { enabled: true }, mobileNavigation: { mode: 'bottom_nav' }, + homePageId: 'nav_home', contextSelectors: [{ id: 'active_package', label: 'Package', @@ -1661,6 +1670,7 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { includeAll: true, placement: 'sidebar_header', }], + areas: [{ id: 'area_sales', label: 'Sales', order: 1, navigation: [] }], navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], }], }, @@ -1673,12 +1683,14 @@ const appDeadAuthoringKeysRemoved: MetadataConversion = { label: 'Package', optionsSource: { endpoint: '/api/v1/packages', valueKey: 'id', labelKey: 'name' }, }], + areas: [{ id: 'area_sales', label: 'Sales', navigation: [] }], navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], }], }, - // Six notices: four top-level keys (`version`, `sharing`, `embed`, - // `mobileNavigation`) plus the two on the single context selector. - expectedNotices: 6, + // Eight notices: five top-level keys (`version`, `sharing`, `embed`, + // `mobileNavigation`, `homePageId`), the two on the single context + // selector, and `order` on the single area. + expectedNotices: 8, }, }; @@ -2439,6 +2451,162 @@ const mappingInertKeysRemoved: MetadataConversion = { }, }; +/** + * Inline book translation maps removed (protocol 17, #4667, ADR-0049). + * + * `book.translations` and `book.groups[].translations` were parsed, stored and + * round-tripped, and read by nothing: the tree endpoint and the docs portal + * render `label` / `description` verbatim, and the generic bundle translator + * covers view / action / object / app / dashboard / page only. A localized book + * therefore shipped its authoring-locale strings to every reader. + * + * The trap was PROXIMITY: `doc.translations` — same name, same shape, two files + * over — is live on every doc render path, so the book-level map read as the + * same feature one level up. + * + * Split routes, because the two schemas differ: `BookSchema` is `strictObject` + * (key deleted, `guidance` carries the prescription, ledger row deleted), while + * `BookGroupSchema` is a plain `z.object` with no `.strict()` — a bare delete + * there would have zod silently strip the key, so it is tombstoned with + * `retiredKey` and its ledger row STAYS. + */ +const bookTranslationsRemoved: MetadataConversion = { + id: 'book-translations-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'book.translations / book.groups.translations', + summary: "book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live", + apply(stack, emit) { + return mapCollection(stack, 'books', (book, path) => { + const next = stripKeys(book, ['translations'], emit, path); + const groups = next.groups; + if (!Array.isArray(groups)) return next; + let touched = false; + const mapped = groups.map((g, i) => { + if (!g || typeof g !== 'object' || Array.isArray(g)) return g; + const stripped = stripKeys( + g as Record, + ['translations'], + emit, + `${path}.groups[${i}]`, + ); + if (stripped !== g) touched = true; + return stripped; + }); + return touched ? { ...next, groups: mapped } : next; + }); + }, + fixture: { + before: { + books: [{ + name: 'crm_guide', + label: 'CRM Guide', + translations: { 'zh-CN': { label: 'CRM 指南' } }, + groups: [{ + key: 'basics', + label: 'Basics', + translations: { 'zh-CN': { label: '基础' } }, + }], + }], + }, + after: { + books: [{ + name: 'crm_guide', + label: 'CRM Guide', + groups: [{ key: 'basics', label: 'Basics' }], + }], + }, + // Two notices: the book-level map and the one on its single group. + expectedNotices: 2, + }, +}; + +/** + * `job.id` removed (protocol 17, #4667, ADR-0049). + * + * Nothing read it, and its own `describe()` — "defaults to `name` when omitted" + * — advertised an identity override that never existed. `name` is the identity + * at every layer that has one: the scheduling key, the `sys_job` row key (the + * DB adapter upserts by `name` and mints its own row id), and the + * `JobExecution.jobId` stamp. Two jobs differing only in `id` were never two + * jobs — they were one job declared twice. + */ +const jobIdRemoved: MetadataConversion = { + id: 'job-id-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'job.id', + summary: "job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)", + apply(stack, emit) { + return mapCollection(stack, 'jobs', (job, path) => stripKeys(job, ['id'], emit, path)); + }, + fixture: { + before: { + jobs: [{ + name: 'nightly_sync', + id: 'job_nightly', + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: 'syncAll', + }], + }, + after: { + jobs: [{ + name: 'nightly_sync', + schedule: { type: 'cron', expression: '0 0 * * *' }, + handler: 'syncAll', + }], + }, + expectedNotices: 1, + }, +}; + +/** + * `translation.validationMessages` removed (protocol 17, #4667, ADR-0049). + * + * A translation group with no reader — objectui's spec-translations transform + * passed it through to the client tree and nothing downstream consumed it. The + * platform's own signature was on it twice: the schema example showed a + * concrete override, and #3778's legacy-key migration table steered retired + * `errors:` authors straight into it. Retiring one dead key by pointing authors + * at another is the failure this conversion also fixes — that guidance entry is + * rewritten in the same change to say rule messages are authored on the rule + * (`object.validations[].message`), not translated through a group. + * + * Removed from the shared `translationDataShape()`, so it retires at BOTH doors + * at once — the bundle entry and the registered item. #3778's original guard + * ran on the item door only, which is exactly how the key survived this long in + * file-authored bundles. + */ +const translationValidationMessagesRemoved: MetadataConversion = { + id: 'translation-validation-messages-removed', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'translation.validationMessages', + summary: "translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`)", + apply(stack, emit) { + return mapCollection(stack, 'translations', (t, path) => + stripKeys(t, ['validationMessages'], emit, path)); + }, + fixture: { + before: { + translations: [{ + name: 'zh_cn', + locale: 'zh-CN', + validationMessages: { discount_limit: '折扣不能超过40%' }, + messages: { 'common.save': '保存' }, + }], + }, + after: { + translations: [{ + name: 'zh_cn', + locale: 'zh-CN', + messages: { 'common.save': '保存' }, + }], + }, + expectedNotices: 1, + }, +}; + /** * `datasource.capabilities` removed (protocol 17, #4583). * @@ -2868,6 +3036,9 @@ export const CONVERSIONS_BY_MAJOR: Readonly { expect(docAudienceAllows(undefined, { authenticated: false })).toBe(false); }); }); + +// ── Inline translation maps retired in 17.0.0 (#4667, ADR-0049) ───────────── +describe('retired book translation maps (#4667)', () => { + it('rejects a book-level `translations` map and names the live neighbour', () => { + // The prescription has to mention `doc.translations`: that key is live on + // every doc render path and is what the author actually wanted. Without it + // the rejection reads as "localization is unsupported", which is false. + const parse = () => BookSchema.parse({ + name: 'crm_guide', label: 'CRM Guide', + translations: { 'zh-CN': { label: 'CRM 指南' } }, + groups: [{ key: 'basics', label: 'Basics' }], + }); + expect(parse).toThrow(/translations.*removed.*17\.0\.0/s); + expect(parse).toThrow(/doc\.translations/s); + }); + + it('rejects the `i18n` alias with the same prescription, not a rename', () => { + // `i18n` used to alias `translations`. An alias surviving its target would + // answer "did you mean `translations`?" — a rename onto a key that is gone. + expect(() => BookSchema.parse({ + name: 'crm_guide', label: 'CRM Guide', + i18n: { 'zh-CN': { label: 'CRM 指南' } }, + groups: [{ key: 'basics', label: 'Basics' }], + })).toThrow(/translations.*removed/s); + }); + + it('rejects a GROUP-level `translations` map — the tombstone, not a silent strip', () => { + // BookGroupSchema is a plain z.object with no .strict(), so this key is + // TOMBSTONED rather than deleted. Pinning the throw is what proves the + // tombstone is still there: a plain delete here would make zod strip the + // key silently and this test would fail with "expected to throw". + expect(() => BookSchema.parse({ + name: 'crm_guide', label: 'CRM Guide', + groups: [{ key: 'basics', label: 'Basics', translations: { 'zh-CN': { label: '基础' } } }], + })).toThrow(/translations.*removed.*17\.0\.0/s); + }); + + it('still accepts a book with no translation map', () => { + expect(() => BookSchema.parse({ + name: 'crm_guide', label: 'CRM Guide', + groups: [{ key: 'basics', label: 'Basics', include: 'crm_*' }], + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/system/book.zod.ts b/packages/spec/src/system/book.zod.ts index ac2244e52d..b385b8f418 100644 --- a/packages/spec/src/system/book.zod.ts +++ b/packages/spec/src/system/book.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +import { retiredKey } from '../shared/retired-key'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; /** @@ -51,6 +52,31 @@ export const BookIncludeSchema = lazySchema(() => ); export type BookInclude = string | { tag: string }; +/** + * Book-level and group-level inline translation maps, retired in 17.0.0 + * (#4667, ADR-0049). + * + * The trap here was PROXIMITY, not plausibility. `doc.translations` — two files + * over, the same shape, the same name — is read on every path that renders a + * doc. So a book's own map reads as the same feature switched on one level up. + * It never was: the tree endpoint and the portal render `label` / `description` + * verbatim, and the generic bundle translator covers view / action / object / + * app / dashboard / page only (`i18n-resolver.ts`). A localized book was parsed, + * stored, round-tripped — and rendered in the authoring locale to every reader. + * + * Shared by both levels deliberately: an author who localized one almost + * certainly localized the other, and splitting the wording would make the second + * rejection read like a different problem. + */ +const BOOK_TRANSLATIONS_RETIRED = + 'Inline `translations` on a book (and on a book group) was removed in @objectstack/spec ' + + '17.0.0 (#4667, ADR-0049) — no resolver ever read it. The book tree endpoint and the ' + + 'docs portal render `label` / `description` verbatim in every locale, so a localized ' + + 'book shipped its authoring-locale strings to every reader. Delete the key. NOTE the ' + + 'near neighbour that DOES work: `doc.translations` is live and read on every doc render ' + + 'path — localize the docs themselves, and the portal picks the reader\'s locale up from ' + + 'there. Run `os migrate meta --from 16` to rewrite existing sources automatically.'; + export const BookGroupSchema = lazySchema(() => z.object({ key: z @@ -58,10 +84,12 @@ export const BookGroupSchema = lazySchema(() => .regex(/^[a-z][a-z0-9_]*$/, 'group key must be lowercase snake_case') .describe('Stable group key (used by overrides, deep links, explicit `doc.group`)'), label: z.string().describe('Section title — first-class, i18n-homed'), - translations: z - .record(z.string(), z.object({ label: z.string() })) - .optional() - .describe('Per-locale label variants'), + // TOMBSTONE, not a deletion: `BookGroupSchema` is a plain `z.object` with + // no `.strict()`, so a bare delete would have zod silently STRIP the key — + // replacing one silent no-op with another. `retiredKey` types it `never` + // (a tsc error at the authoring site) and raises the prescription on parse. + // Its liveness row therefore STAYS: the key is still in the walked shape. + translations: retiredKey(BOOK_TRANSLATIONS_RETIRED), order: z.number().optional().describe('Order of THIS group within the book'), include: BookIncludeSchema.optional().describe('Rule that derives membership (glob or tag)'), package: z @@ -77,7 +105,6 @@ export const BookGroupSchema = lazySchema(() => export type BookGroup = { key: string; label: string; - translations?: Record; order?: number; include?: BookInclude; package?: string; @@ -110,7 +137,12 @@ export const BookSchema = lazySchema(() => aliases: { title: 'label', sections: 'groups', chapters: 'groups', toc: 'groups', access: 'audience', visibility: 'audience', sort: 'order', position: 'order', - url: 'slug', path: 'slug', i18n: 'translations', + url: 'slug', path: 'slug', + // `i18n: 'translations'` retired with the key it pointed at (#4667). + }, + guidance: { + translations: BOOK_TRANSLATIONS_RETIRED, + i18n: BOOK_TRANSLATIONS_RETIRED, }, }, { name: z @@ -119,9 +151,7 @@ export const BookSchema = lazySchema(() => .describe('Book name (namespace prefix recommended, like every metadata name)'), label: z.string().optional().describe('Display title'), description: z.string().optional(), - translations: z - .record(z.string(), z.object({ label: z.string().optional(), description: z.string().optional() })) - .optional(), + // `translations` removed in 17.0.0 (#4667) — see BOOK_TRANSLATIONS_RETIRED. slug: z.string().optional().describe('Portal URL segment; defaults to name sans prefix'), icon: z.string().optional(), order: z.number().optional().describe('Orders books within the portal'), @@ -141,7 +171,6 @@ export type Book = { name: string; label?: string; description?: string; - translations?: Record; slug?: string; icon?: string; order?: number; diff --git a/packages/spec/src/system/job.test.ts b/packages/spec/src/system/job.test.ts index 215a1fbd91..9072759c1d 100644 --- a/packages/spec/src/system/job.test.ts +++ b/packages/spec/src/system/job.test.ts @@ -240,7 +240,6 @@ describe('RetryPolicySchema', () => { describe('JobSchema', () => { it('should accept valid minimal job', () => { const job: Job = { - id: 'job-123', name: 'daily_cleanup', schedule: { type: 'cron', @@ -262,7 +261,6 @@ describe('JobSchema', () => { validNames.forEach(name => { const job = { - id: 'job-123', name, schedule: { type: 'cron' as const, expression: '0 0 * * *' }, handler: 'jobs/handler.ts', @@ -281,7 +279,6 @@ describe('JobSchema', () => { invalidNames.forEach(name => { expect(() => JobSchema.parse({ - id: 'job-123', name, schedule: { type: 'cron', expression: '0 0 * * *' }, handler: 'jobs/handler.ts', @@ -291,7 +288,6 @@ describe('JobSchema', () => { it('should apply default enabled value', () => { const job = JobSchema.parse({ - id: 'job-123', name: 'test_job', schedule: { type: 'interval', intervalMs: 60000 }, handler: 'jobs/handler.ts', @@ -302,7 +298,6 @@ describe('JobSchema', () => { it('should accept job with all fields', () => { const job = { - id: 'job-456', name: 'complex_job', schedule: { type: 'cron' as const, @@ -333,7 +328,6 @@ describe('JobSchema', () => { schedules.forEach(schedule => { const job = { - id: 'job-789', name: 'test_job', schedule, handler: 'jobs/handler.ts', @@ -344,7 +338,6 @@ describe('JobSchema', () => { it('should accept job with timeout', () => { const job = { - id: 'job-timeout', name: 'long_running_job', schedule: { type: 'cron' as const, expression: '0 0 * * *' }, handler: 'jobs/handler.ts', @@ -357,7 +350,6 @@ describe('JobSchema', () => { it('should accept disabled job', () => { const job = { - id: 'job-disabled', name: 'disabled_job', schedule: { type: 'interval' as const, intervalMs: 30000 }, handler: 'jobs/handler.ts', @@ -488,7 +480,6 @@ describe('JobExecutionSchema', () => { describe('Job Scheduling Integration', () => { it('should handle daily backup job', () => { const job: Job = { - id: 'backup-daily', name: 'daily_backup', schedule: { type: 'cron', @@ -510,7 +501,6 @@ describe('Job Scheduling Integration', () => { it('should handle periodic cleanup job', () => { const job: Job = { - id: 'cleanup-temp', name: 'cleanup_temp_files', schedule: { type: 'interval', @@ -525,7 +515,6 @@ describe('Job Scheduling Integration', () => { it('should handle one-time scheduled job', () => { const job: Job = { - id: 'migration-2024', name: 'data_migration', schedule: { type: 'once', @@ -572,3 +561,29 @@ describe('Job Scheduling Integration', () => { }); }); }); + +// ── `job.id` retired in 17.0.0 (#4667, ADR-0049) ──────────────────────────── +describe('retired job.id (#4667)', () => { + const base = { + name: 'nightly_sync', + schedule: { type: 'cron' as const, expression: '0 0 * * *' }, + handler: 'syncAll', + }; + + it('rejects `id` and says `name` is the identity', () => { + // The damage this key did was its own describe(): "defaults to `name` when + // omitted" advertised an identity OVERRIDE. Nothing read it, so two jobs + // differing only in `id` were one job — the second silently winning. The + // rejection has to name `name` or the author just deletes the key and keeps + // wondering how to give a job a stable identifier. + const parse = () => JobSchema.parse({ ...base, id: 'job_nightly' }); + expect(parse).toThrow(/job\.id.*removed.*17\.0\.0/s); + expect(parse).toThrow(/`name`/s); + }); + + it('a job with only `name` parses and keeps no `id`', () => { + const job = JobSchema.parse(base); + expect(job).not.toHaveProperty('id'); + expect(job.name).toBe('nightly_sync'); + }); +}); diff --git a/packages/spec/src/system/job.zod.ts b/packages/spec/src/system/job.zod.ts index 02dc744828..c2883376f1 100644 --- a/packages/spec/src/system/job.zod.ts +++ b/packages/spec/src/system/job.zod.ts @@ -87,13 +87,33 @@ export type RetryPolicy = z.infer; * } * } */ +/** + * `job.id`, retired in 17.0.0 (#4667, ADR-0049). + * + * The `describe()` did the damage: "defaults to `name` when omitted" implies an + * identity OVERRIDE that never existed. Nothing read the key. `name` is the + * job's identity at every layer that has one — the scheduling key, the `sys_job` + * row key (the DB adapter upserts by `name` and mints its own row id), and the + * `JobExecution.jobId` stamp — so two jobs differing only in `id` were never two + * jobs, they were one job declared twice, with the second silently winning. + */ +const JOB_ID_RETIRED = + '`job.id` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — nothing ever read ' + + 'it, and its own description ("defaults to `name` when omitted") advertised an identity ' + + 'override that did not exist. `name` IS the job\'s identity everywhere: the scheduling ' + + 'key, the `sys_job` row key, and the `JobExecution.jobId` stamp. Two jobs differing only ' + + 'in `id` were the same job. Delete the key; rename the job via `name` if you need a ' + + 'different identity. Run `os migrate meta --from 16` to rewrite existing sources ' + + 'automatically.'; + export const JobSchema = lazySchema(() => strictObject({ surface: 'this job', history: 'Until #4001 closed this shape these were dropped silently — the item still registered, minus whatever the key was meant to configure.', aliases: { cron: 'schedule', interval: 'schedule', fn: 'handler', function: 'handler', retry: 'retryPolicy', enabled_: 'enabled', timeoutMs: 'timeout' }, + guidance: { id: JOB_ID_RETIRED }, }, { - id: z.string().optional().describe('Unique job identifier (defaults to `name` when omitted)'), + // `id` removed in 17.0.0 (#4667) — see JOB_ID_RETIRED. `name` is the identity. name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Job name (snake_case)'), label: z.string().optional().describe('Human-readable label'), description: z.string().optional().describe('Job description / purpose'), diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 9085a2b295..cb484b9d2e 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -408,40 +408,14 @@ describe('TranslationBundleSchema', () => { }); // ============================================================================ -// Protocol Improvement Tests: Translation validationMessages +// Translation validationMessages — RETIRED in 17.0.0 (#4667) // ============================================================================ - -describe('TranslationDataSchema - validationMessages', () => { - it('should accept translation data with validationMessages', () => { - const data = TranslationDataSchema.parse({ - validationMessages: { - 'discount_limit': 'Discount cannot exceed 40%', - 'amount_required': 'Amount is required for closed deals', - }, - }); - expect(data.validationMessages?.['discount_limit']).toBe('Discount cannot exceed 40%'); - expect(data.validationMessages?.['amount_required']).toBe('Amount is required for closed deals'); - }); - - it('should accept Chinese validation messages', () => { - const bundle = TranslationBundleSchema.parse({ - 'zh-CN': { - validationMessages: { - 'discount_limit': '折扣不能超过40%', - 'end_date_check': '结束日期必须大于开始日期', - }, - }, - }); - expect(bundle['zh-CN'].validationMessages?.['discount_limit']).toBe('折扣不能超过40%'); - }); - - it('should accept translation data without validationMessages (optional)', () => { - const data = TranslationDataSchema.parse({ - messages: { 'save': 'Save' }, - }); - expect(data.validationMessages).toBeUndefined(); - }); -}); +// +// This block used to assert the group parsed at both doors. It was never read +// by any resolver, so a translated rule message was stored and never shown; +// the rejection now lives in the "retired translation.validationMessages" +// describe at the bottom of this file. Validation messages are authored on the +// rule itself (`object.validations[].message`). // ============================================================================ // FieldTranslationSchema @@ -859,7 +833,6 @@ describe('translation unknown-key strictness (#4001)', () => { objects: { account: { label: 'Account', _views: { all: { label: 'All', emptyState: { title: 'None' } } } } }, apps: { crm: { label: 'CRM', navigation: { sales: { label: 'Sales' } } } }, messages: { 'common.save': 'Save' }, - validationMessages: { discount_limit: 'Too high' }, globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue' } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, @@ -1151,3 +1124,42 @@ describe('CoverageBreakdownEntrySchema', () => { ).toThrow(); }); }); + +// ── `validationMessages` retired in 17.0.0 (#4667, ADR-0049) ──────────────── +describe('retired translation.validationMessages (#4667)', () => { + it('rejects the group at BOTH doors — bundle entry and registered item', () => { + // #3778's original guard ran on the item door only, which is exactly how + // this key survived in file-authored bundles. It now lives in the shared + // `translationDataShape()`, so retiring it closes both at once — that + // symmetry is what these two assertions pin. + const payload = { validationMessages: { discount_limit: '折扣不能超过40%' } }; + expect(() => TranslationDataSchema.parse(payload)) + .toThrow(/validationMessages.*removed.*17\.0\.0/s); + expect(() => TranslationItemSchema.parse({ name: 'zh_cn', locale: 'zh-CN', ...payload })) + .toThrow(/validationMessages.*removed.*17\.0\.0/s); + }); + + it('points at the rule, which is where a validation message actually renders', () => { + expect(() => TranslationDataSchema.parse({ validationMessages: { x: 'y' } })) + .toThrow(/object\.validations\[\]\.message/s); + }); + + it('the retired `errors` dialect no longer signposts a dead key', () => { + // #3778 retired `errors` by telling authors to use `validationMessages` — + // a signpost into another unread group. Taking that advice moved content + // from one dead place to another. The replacement guidance must NOT name + // `validationMessages`. + let msg = ''; + try { TranslationDataSchema.parse({ errors: { x: 'y' } }); } catch (e) { msg = String(e); } + expect(msg).toMatch(/`errors` is the retired object-first dialect/s); + expect(msg).not.toMatch(/use 'validationMessages'/s); + expect(msg).toMatch(/object\.validations\[\]\.message/s); + }); + + it('still accepts the live sibling groups', () => { + expect(() => TranslationDataSchema.parse({ + messages: { 'common.save': '保存' }, + apps: { crm: { label: 'CRM' } }, + })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index e8658bd0d5..27f3416b57 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -281,14 +281,35 @@ export type LegacyObjectFirstKey = (typeof LEGACY_OBJECT_FIRST_KEYS)[number]; * when the two carry different inner shapes and the content has to be rewritten, * not renamed. */ -const TRANSLATION_KEY_GUIDANCE: Record = { +const TRANSLATION_KEY_GUIDANCE: Record = { + // Not a legacy object-first key — a group that was live-looking and unread + // until 17.0.0 (#4667, ADR-0049). The platform's own signature was on it + // twice: the schema example showed a concrete override + // ({"discount_limit": "折扣不能超过40%"}), and #3778's migration table steered + // retired `errors:` authors straight into it. Both signposts pointed at a + // group no resolver read — objectui's spec-translations transform passed it + // through to the client tree and nothing downstream consumed it. + validationMessages: + '`validationMessages` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no ' + + 'resolver ever read it, so a translated rule message was stored and never shown. ' + + 'Validation messages are not translated through a translation group: author the ' + + 'message on the rule itself (`object.validations[].message`), which the engine ' + + 'evaluates and returns on every rejected write. Delete the key. Run ' + + '`os migrate meta --from 16` to rewrite existing sources automatically.', o: "`o` is the retired object-first dialect, which no resolver reads — use 'objects.'", app: "`app` is the retired object-first dialect, which no resolver reads — use 'apps.'", nav: "`nav` is the retired object-first dialect, which no resolver reads — use 'apps..navigation..label'", dashboard: "`dashboard` is the retired object-first dialect, which no resolver reads — use 'dashboards.' (plural)", reports: '`reports` is the retired object-first dialect — reports have no translation group, omit them', notifications: '`notifications` is the retired object-first dialect — notifications have no translation group, omit them', - errors: "`errors` is the retired object-first dialect — use 'validationMessages' for rule messages; other errors have no translation group", + // Was: "use 'validationMessages' for rule messages". That was a signpost to a + // key with no reader — #3778 retired `errors` by pointing authors at + // `validationMessages`, which was itself dead, so taking the advice moved the + // content from one unread group to another. Both are gone now (#4667). + errors: + '`errors` is the retired object-first dialect and has no replacement — rule messages are ' + + 'not translated through a translation group at all. Author the message on the rule ' + + "itself (`object.validations[].message`); omit `errors`.", _globalOptions: "`_globalOptions` is the retired object-first dialect — use 'objects..fields..options'", _meta: "`_meta` is the retired object-first dialect — use the top-level 'locale' field (on a bundle, the locale is the map key)", namespace: '`namespace` is not part of the translation contract — omit it (ADR-0006 D4 retired namespaces platform-wide)', @@ -352,8 +373,10 @@ const translationDataShape = () => ({ /** UI Messages */ messages: z.record(z.string(), z.string()).optional().describe('UI message translations keyed by message ID'), - /** Validation Error Messages */ - validationMessages: z.record(z.string(), z.string()).optional().describe('Translatable validation error messages keyed by rule name (e.g., {"discount_limit": "折扣不能超过40%"})'), + // `validationMessages` removed in 17.0.0 (#4667) — see the + // TRANSLATION_KEY_GUIDANCE entry. Removing it from this shared shape retires + // it at BOTH doors at once (bundle entry + registered item), which is the + // asymmetry #3778's item-only guard got wrong. /** * Global (object-less) action translations keyed by action name (snake_case). diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index fd59e3bb8b..91bc111d17 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -388,7 +388,6 @@ describe('AppSchema', () => { const app: App = { name: 'portal', label: 'Customer Portal', - homePageId: 'nav_dashboard', navigation: [ { id: 'nav_dashboard', @@ -424,7 +423,6 @@ describe('AppSchema', () => { branding: { primaryColor: '#0070F3', }, - homePageId: 'nav_home', navigation: [ { id: 'nav_home', @@ -618,7 +616,6 @@ describe('AppSchema', () => { { id: 'nav_settings', type: 'page', label: 'Settings', icon: 'settings', pageName: 'admin_settings' }, { id: 'nav_help', type: 'url', label: 'Help', icon: 'help-circle', url: 'https://help.example.com', target: '_blank' }, ], - homePageId: 'nav_pipeline', requiredPermissions: ['app.access.crm'], }); @@ -895,7 +892,6 @@ describe('NavigationAreaSchema', () => { id: 'area_service', label: 'Service', icon: 'headset', - order: 2, description: 'Customer service management', visible: 'user.has_permission("service.access")', requiredPermissions: ['service.access'], @@ -906,7 +902,6 @@ describe('NavigationAreaSchema', () => { }); expect(area.id).toBe('area_service'); expect(area.icon).toBe('headset'); - expect(area.order).toBe(2); expect(area.requiredPermissions).toEqual(['service.access']); expect(area.navigation).toHaveLength(2); }); @@ -930,7 +925,6 @@ describe('AppSchema with areas', () => { id: 'area_sales', label: 'Sales', icon: 'briefcase', - order: 1, navigation: [ { id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' }, { id: 'nav_opportunities', type: 'object', label: 'Opportunities', objectName: 'opportunity' }, @@ -940,7 +934,6 @@ describe('AppSchema with areas', () => { id: 'area_service', label: 'Service', icon: 'headset', - order: 2, navigation: [ { id: 'nav_cases', type: 'object', label: 'Cases', objectName: 'case' }, ], @@ -949,7 +942,6 @@ describe('AppSchema with areas', () => { id: 'area_settings', label: 'Settings', icon: 'settings', - order: 99, requiredPermissions: ['admin.access'], navigation: [ { id: 'nav_users', type: 'object', label: 'Users', objectName: 'user' }, @@ -1005,7 +997,6 @@ describe('AppSchema with areas', () => { { id: 'area_main', label: 'Main', - order: 1, navigation: [ { id: 'nav_home', type: 'dashboard', label: 'Home', dashboardName: 'home' }, { id: 'nav_accounts', type: 'object', label: 'Accounts', objectName: 'account', order: 1 }, @@ -1108,7 +1099,7 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { navigation: [{ id: 'nav_a', label: 'A', type: 'object', objectName: 'account' }], areas: [{ id: 'area_a', label: 'A', navigation: [] }], contextSelectors: [{ id: 'pkg', label: 'Package', optionsSource: { endpoint: '/api/v1/packages' } }], - homePageId: 'nav_a', requiredPermissions: ['app.access.x'], + requiredPermissions: ['app.access.x'], defaultAgent: 'ask', protection: { lock: 'none' }, }; for (const [key, value] of Object.entries(probes)) { @@ -1291,4 +1282,57 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { expect(parsed.contextSelectors![0]).not.toHaveProperty('placement'); }); }); + // ── homePageId / areas[].order retired in 17.0.0 (#4667, ADR-0049) ──────── + describe('retired app keys (#4667)', () => { + it('rejects `homePageId` and names what actually decides the landing page', () => { + // Tombstoned (retiredKey), matching the seven #4142 retirements on this + // schema — so it is a tsc error as well as a parse error. The schema's own + // hedge ("if not set, usually defaults to the first navigation item") had + // been describing the only behaviour that ever existed. + const parse = () => AppSchema.parse({ + name: 'crm', label: 'CRM', homePageId: 'nav_pipeline', + navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], + }); + expect(parse).toThrow(/homePageId.*removed.*17\.0\.0/s); + expect(parse).toThrow(/first navigation item/s); + expect(parse).toThrow(/isDefault/s); + }); + + it('routes the three retired homePageId aliases to the same prescription', () => { + for (const alias of ['home', 'homepage', 'landingpage']) { + expect(unknownKeyIssue(AppSchema, { name: 'crm', label: 'CRM', [alias]: 'nav_x' })!.message) + .toMatch(/homePageId.*removed/s); + } + }); + + it('rejects `areas[].order` and distinguishes it from the nav-item `order` that IS sorted', () => { + // The sibling that works is why this one read alive. The message must say + // so, or an author concludes ordering is unsupported and restructures the + // navigation tree for no reason. + const msg = unknownKeyIssue(AppSchema, { + name: 'crm', label: 'CRM', + areas: [{ id: 'area_sales', label: 'Sales', order: 1, navigation: [] }], + })!.message; + expect(msg).toMatch(/order.*removed.*17\.0\.0/s); + expect(msg).toMatch(/reorder the\s+`areas` array/s); + expect(msg).toMatch(/navigation ITEM/s); + }); + + it('the `sort` alias on an area carries the same prescription', () => { + expect(unknownKeyIssue(AppSchema, { + name: 'crm', label: 'CRM', + areas: [{ id: 'a', label: 'A', sort: 1, navigation: [] }], + })!.message).toMatch(/order.*removed/s); + }); + + it('an app without either key still parses', () => { + const app = AppSchema.parse({ + name: 'crm', label: 'CRM', + areas: [{ id: 'area_sales', label: 'Sales', navigation: [] }], + navigation: [{ id: 'nav_home', type: 'object', label: 'Home', objectName: 'account' }], + }); + expect(app).not.toHaveProperty('homePageId'); + expect(app.areas![0]).not.toHaveProperty('order'); + }); + }); }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 6116abc153..d1d601638b 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -597,6 +597,23 @@ export const AppBrandingSchema = lazySchema(() => z.object({ }), }).strict()); +/** + * `app.areas[].order`, retired in 17.0.0 (#4667, ADR-0049). + * + * The sibling that works is what made this one read alive: nav-item `order` IS + * sorted (`NavigationRenderer.tsx:1154`). Area-level order is not — `AppSidebar` + * and `AppSchemaRenderer` both iterate the `areas` array as authored — so + * declaration order has always been display order, and an author who set + * `order` to rearrange areas saw nothing move. + */ +const AREA_ORDER_RETIRED = + '`areas[].order` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no renderer ' + + 'ever sorted areas; both the sidebar and the schema renderer iterate the array as ' + + 'authored, so declaration order already IS display order. Delete the key and reorder the ' + + '`areas` array itself. NOTE the neighbour that behaves differently: a navigation ITEM\'s ' + + '`order` is genuinely sorted — this removal does not touch it. Run ' + + '`os migrate meta --from 16` to rewrite existing sources automatically.'; + /** * Navigation Area Schema * @@ -632,8 +649,8 @@ export const NavigationAreaSchema = lazySchema(() => z.object({ /** Icon name (Lucide) */ icon: z.string().optional().describe('Area icon name'), - /** Sort order among areas (lower = first) */ - order: z.number().optional().describe('Sort order among areas (lower = first)'), + // `order` removed in 17.0.0 (#4667) — see AREA_ORDER_RETIRED. Reorder the + // `areas` array instead; declaration order is display order. /** Area description */ description: I18nLabelSchema.optional().describe('Area description'), @@ -652,8 +669,10 @@ export const NavigationAreaSchema = lazySchema(() => z.object({ }, { error: strictUnknownKeyError({ surface: 'this navigation area', - knownKeys: ['id', 'label', 'icon', 'order', 'description', 'visible', 'requiredPermissions', 'navigation'], - aliases: { visiblewhen: 'visible', visibleon: 'visible', title: 'label', name: 'id', sort: 'order', permissions: 'requiredPermissions', items: 'navigation', children: 'navigation' }, + knownKeys: ['id', 'label', 'icon', 'description', 'visible', 'requiredPermissions', 'navigation'], + // `sort: 'order'` retired with the key it pointed at (#4667). + aliases: { visiblewhen: 'visible', visibleon: 'visible', title: 'label', name: 'id', permissions: 'requiredPermissions', items: 'navigation', children: 'navigation' }, + guidance: { order: AREA_ORDER_RETIRED, sort: AREA_ORDER_RETIRED }, history: 'Until #4001 these were dropped silently — the area still parsed, so its gating or ' + 'ordering was quietly ignored.', @@ -888,6 +907,25 @@ const APP_KEYS = [ 'version', 'aria', 'objects', 'apis', 'sharing', 'embed', 'mobileNavigation', ] as const; +/** + * `app.homePageId`, retired in 17.0.0 (#4667, ADR-0049). + * + * The schema's own hedge gave it away — "if not set, usually defaults to the + * first navigation item" describes the ONLY behaviour that exists. No shell + * reads the key: an app's landing page is its first navigation item in `order`, + * and the ROOT landing follows `isDefault` routing (objectui's + * `RootLandingRedirect`). So an author pinning a home page got the first nav + * item anyway, and "usually" was doing the work of "always". + */ +const HOME_PAGE_ID_RETIRED = + '`app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, ADR-0049) — no shell ' + + 'ever read it. An app\'s landing page IS its first navigation item (by `order`), and the ' + + 'root landing follows `isDefault` routing. Delete the key; to change where an app opens, ' + + 'reorder `navigation` so the intended entry is first, and set `isDefault` on the app that ' + + 'should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources ' + + 'automatically.'; + + const appUnknownKeyError = strictUnknownKeyError({ surface: 'this app', knownKeys: APP_KEYS, @@ -902,9 +940,9 @@ const appUnknownKeyError = strictUnknownKeyError({ sections: 'areas', groups: 'areas', permissions: 'requiredPermissions', - home: 'homePageId', - homepage: 'homePageId', - landingpage: 'homePageId', + // `home` / `homepage` / `landingpage` aliased `homePageId`, retired in + // 17.0.0 (#4667). They fall through to the tombstone's own prescription + // rather than renaming onto a key that no longer exists. agent: 'defaultAgent', logo: 'branding', theme: 'branding', @@ -922,6 +960,11 @@ const appUnknownKeyError = strictUnknownKeyError({ flows: '`flows` is not an App field — flows are top-level stack metadata ' + '(`defineStack({ flows })`), not app-scoped.', + // The three retired `homePageId` aliases. `retiredKey` already answers the + // canonical spelling; these cover the spellings that used to route to it. + home: HOME_PAGE_ID_RETIRED, + homepage: HOME_PAGE_ID_RETIRED, + landingpage: HOME_PAGE_ID_RETIRED, }, history: 'Until #4001 these were dropped silently — the app still parsed, so navigation or ' + @@ -1014,12 +1057,13 @@ export const AppSchema = lazySchema(() => z.object({ contextSelectors: z.array(AppContextSelectorSchema).optional() .describe('App-level scope dropdowns whose value is injected into nav items as {} template vars'), - /** - * App-level Home Page Override - * ID of the navigation item to act as the landing page. - * If not set, usually defaults to the first navigation item. + /** + * REMOVED in 17.0.0 (#4667) — see {@link HOME_PAGE_ID_RETIRED}. Tombstoned + * rather than deleted, matching the seven #4142 retirements on this schema: + * `retiredKey` types it `never`, so an app still authoring it fails to + * compile as well as to parse. */ - homePageId: z.string().optional().describe('ID of the navigation item to serve as landing page'), + homePageId: retiredKey(HOME_PAGE_ID_RETIRED), /** * Access Control diff --git a/skills/objectstack-i18n/SKILL.md b/skills/objectstack-i18n/SKILL.md index 84da042da2..b135e0c384 100644 --- a/skills/objectstack-i18n/SKILL.md +++ b/skills/objectstack-i18n/SKILL.md @@ -48,8 +48,8 @@ and integration with the I18nService. 1. **Runtime format — `objects.*` (`TranslationData`)**: each locale is authored as one `TranslationData` value. All translatable content for an object (label, fields, options, views, sections, actions) is grouped under `objects.{object_name}`, with - global groups (`apps`, `messages`, `validationMessages`, `globalActions`, - `dashboards`, `settings`, `metadataForms`) at the top level. + global groups (`apps`, `messages`, `globalActions`, `dashboards`, `settings`, + `metadataForms`) at the top level. 2. **Bundle registration**: per-locale files are assembled with `defineTranslationBundle({ en, 'zh-CN': … })` into a `TranslationBundle` @@ -203,9 +203,6 @@ const en: TranslationData = { 'common.cancel': 'Cancel', 'welcome.user': 'Welcome, {{userName}}!', }, - validationMessages: { - completed_date_required: 'Completed date is required when status is Completed', - }, }; // src/translations/zh-CN.ts — same shape, translated values @@ -266,8 +263,14 @@ All translatable content for a single object is aggregated under | `_sections.{section_name}` | Form section / tab `label`, `description` | Top-level groups alongside `objects`: `apps` (label, description, navigation), -`messages`, `validationMessages`, `globalActions` (object-less actions), -`dashboards`, `settings`, `metadataForms`, `settingsCommon`. +`messages`, `globalActions` (object-less actions), `dashboards`, `settings`, +`metadataForms`, `settingsCommon`. + +> **Validation messages are not a translation group.** `validationMessages` was +> removed in spec 17.0.0 (#4667) — nothing ever read it, so a translated rule +> message was stored and never shown. Author the message on the rule itself +> (`object.validations[].message`), which the engine returns on every rejected +> write. For the exact Zod shape (and any field that may have been added since), read `node_modules/@objectstack/spec/src/system/translation.zod.ts` —