From 046854b8723fdae9f732befb19b9882d7deb4c96 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 12 Aug 2026 11:37:40 +0000
Subject: [PATCH] chore: release packages
---
.../accept-invitation-single-route-3811.md | 15 -
.../action-confirm-text-translation-4265.md | 25 -
...tion-forward-whitelist-parity-4050-4192.md | 13 -
.../action-menu-autotrigger-overflow-4162.md | 13 -
.../action-typing-integrity-4418-4422.md | 18 -
.../actiondef-close-the-index-signature.md | 51 --
.changeset/address-display-formatter-4037.md | 15 -
...ild-thread-survives-preview-switch-2627.md | 15 -
.../aichatpage-runtime-message-seam-4437.md | 13 -
.../analytics-label-net-shared-glue-4389.md | 16 -
.../analytics-option-label-i18n-4030.md | 17 -
.../api-console-storage-slot-key-4240.md | 11 -
.changeset/app-management-page-i18n-4307.md | 35 -
.../app-management-search-keyed-label-4343.md | 19 -
.../autonumber-textual-ref-fallback-4219.md | 13 -
.../bell-approvals-activity-off-app-4197.md | 13 -
.changeset/bell-panel-render-oracle-4230.md | 20 -
.../bridge-density-prototype-guard-4442.md | 24 -
.changeset/button-has-type-lint-4045.md | 23 -
.changeset/calm-schools-tease.md | 22 -
.changeset/chatmessage-one-contract-4383.md | 11 -
.changeset/chatmessage-seam-adapter.md | 20 -
...bo-dataset-path-presentation-merge-4229.md | 17 -
.../commit-store-unreachable-copy-3529.md | 14 -
.changeset/components-no-implicit-any-4353.md | 17 -
.../components-type-checks-its-tests.md | 12 -
.../core-app-shell-type-check-their-tests.md | 16 -
.../currency-iso-4217-minor-units-4361.md | 49 -
.changeset/curvy-donkeys-shave.md | 22 -
...ta-table-row-menu-predicate-derive-4354.md | 11 -
.../dataset-result-field-derive-3815.md | 11 -
...widget-option-color-probe-apifetch-4121.md | 24 -
...tasource-preview-strict-key-groups-4131.md | 24 -
.changeset/dead-currency-symbol-map.md | 18 -
.../dead-surface-deletions-batch3-4328.md | 40 -
.changeset/dead-surface-pair-4364-4368.md | 39 -
...clared-actions-bar-drop-predicate-casts.md | 18 -
...lared-actions-bar-predicate-helper-4080.md | 11 -
...lt-app-landing-and-real-app-writes-4233.md | 15 -
.changeset/designer-table-ellipsis-4377.md | 15 -
.../designer-table-key-collapse-4387.md | 20 -
...otted-dimension-labels-table-pivot-4263.md | 28 -
.../dotted-dimension-option-labels-4053.md | 19 -
...tton-forward-excess-property-check-4321.md | 15 -
...ment-deeplink-arm-on-create-action-4123.md | 13 -
.changeset/fields-subpath-surface-4325.md | 35 -
.../filter-panel-no-view-overlay-4155.md | 17 -
.changeset/form-route-edit-recordid-4278.md | 16 -
.changeset/fullscreen-editor-hoist-3398.md | 18 -
.changeset/gantt-count-interpolation-4157.md | 14 -
.../gantt-link-rejection-feedback-4158.md | 16 -
.../gated-options-keep-stored-value-4247.md | 14 -
.changeset/global-filter-preset-alias-4165.md | 15 -
.changeset/global-nav-studio-retire-rc6.md | 35 -
.changeset/great-mails-repeat.md | 9 -
...d-bulk-bar-clear-resets-checkboxes-4140.md | 9 -
.../grid-row-menu-predicate-derive-4429.md | 13 -
.../handrolled-interpolators-literal-4370.md | 23 -
...on-centre-badges-full-unread-count-4329.md | 15 -
...home-action-centre-needs-an-answer-4235.md | 26 -
.../host-app-resolver-root-export-4280.md | 14 -
.changeset/i18n-copy-trio-3878-3875-3877.md | 27 -
.changeset/i18n-stragglers-4375-4376.md | 23 -
.changeset/i18nlabel-render-sites-4163.md | 26 -
.changeset/image-field-maxsize-guard-4141.md | 13 -
.../inbox-activity-links-host-app-4074.md | 15 -
.../inbox-popover-bell-polls-off-app-4110.md | 14 -
.changeset/init-scaffold-vite-env-tsc-4111.md | 11 -
.changeset/inline-address-input-4216.md | 15 -
.changeset/inline-credential-gate-4221.md | 15 -
.../inline-default-value-drift-gate-3810.md | 39 -
.changeset/inline-edit-delegate-4220.md | 33 -
.changeset/internal-form-in-shell-4109.md | 13 -
.changeset/interpolation-parity-gate-3845.md | 19 -
.../kanban-rejected-move-rollback-4138.md | 13 -
.changeset/list-filtered-empty-copy-4155.md | 11 -
...list-sort-field-fallback-and-reset-4243.md | 14 -
.../listview-shared-datasource-gate-4038.md | 47 -
.../local-select-dimension-i18n-4330.md | 14 -
.changeset/locale-menu-from-app-4039.md | 17 -
.changeset/lucky-buttons-shave.md | 13 -
.changeset/map-pack-mirror-gate-4401.md | 18 -
...apdensity-dead-rowheight-spellings-4352.md | 36 -
.changeset/metric-kpi-i18n-channel-4032.md | 45 -
.../metric-props-dom-passthrough-4426.md | 20 -
.changeset/metric-widget-dom-spread-4357.md | 47 -
.changeset/nervous-pans-repeat.md | 33 -
...ber-format-locale-ordinal-grouping-4033.md | 20 -
.../objectchart-label-net-third-copy-4405.md | 13 -
...tchart-option-color-probe-apifetch-4114.md | 21 -
...jectgrid-external-pagination-props-4277.md | 13 -
.../objectgrid-rowheight-boundary-4443.md | 18 -
.changeset/objectmap-filter-config-4034.md | 15 -
.changeset/olive-donkeys-shake.md | 22 -
.changeset/olive-eels-shave.md | 8 -
.changeset/phantom-dependency-gate-4394.md | 11 -
.changeset/picklocalized-own-props-3907.md | 11 -
.changeset/pivot-null-bucket-4056.md | 50 --
.changeset/react-type-checks-its-tests.md | 10 -
...record-details-retire-layout-input-3818.md | 15 -
.changeset/record-picker-filter-input-3830.md | 41 -
...ecord-picker-sort-limit-empty-text-4167.md | 46 -
.../render-save-advisory-findings-4133.md | 17 -
.../required-when-submit-verdict-4161.md | 11 -
.../reserved-auth-feature-flags-2514.md | 15 -
...retire-action-engine-event-mapping-3368.md | 25 -
.../retire-cloud-operations-surface-4152.md | 71 --
.changeset/retire-common-search-key-4392.md | 43 -
.../retire-narrow-typetests-projects-4291.md | 12 -
.../retire-object-detail-factory-3731-3736.md | 11 -
.../retire-report-editor-namespace-4145.md | 31 -
...ire-standalone-validation-resource-4132.md | 60 --
.../retire-url-action-params-newtab-4097.md | 11 -
.changeset/richtext-spec-spelling-4250.md | 15 -
.changeset/rotten-plums-smash.md | 6 -
.../rowheight-density-one-answer-4440.md | 42 -
.../safe-translation-inline-default-3865.md | 22 -
.../second-client-save-advisories-4237.md | 14 -
.../set-default-view-tab-identity-4211.md | 15 -
.changeset/setup-deep-link-2794.md | 18 -
.../setup-exit-console-basename-4181.md | 13 -
.changeset/shared-inbox-feed-4225.md | 35 -
...show-empty-related-plural-base-key-3863.md | 14 -
.../sidebar-collapse-survives-reload-4234.md | 15 -
.changeset/six-bare-keys-defaults-4396.md | 11 -
.../sort-relational-hint-stored-field-4294.md | 12 -
.changeset/spec-symbol-collisions-rc6-4167.md | 92 --
.../sso-landing-setup-only-home-4048.md | 36 -
.changeset/studio-dock-polish-pins.md | 10 -
.../studio-readonly-affordances-4036.md | 15 -
.../synth-page-canonical-properties-4232.md | 28 -
.changeset/tall-eagles-repeat.md | 21 -
.../tenant-locale-seeds-ui-language-4035.md | 34 -
.changeset/tidy-eels-invite.md | 27 -
.changeset/type-check-tests-auth-4040.md | 16 -
.changeset/type-check-tests-i18n-4040.md | 32 -
.../type-check-tests-permissions-4040.md | 15 -
.../type-check-tests-plugin-chatbot-4040.md | 21 -
.../type-check-tests-plugin-form-4040.md | 28 -
.../type-check-tests-plugin-gantt-4040.md | 19 -
.../type-check-tests-plugin-map-4040.md | 16 -
...ished-banner-reads-unpublished-key-6955.md | 18 -
.../updateview-draft-addressing-4139.md | 13 -
.../useobjectchat-honest-message-type-4424.md | 16 -
.changeset/view-invalidation-seam-4373.md | 14 -
.../view-overrides-invalidation-4363.md | 13 -
.changeset/widget-dom-leak-sweep-gate-4425.md | 29 -
.changeset/wild-pugs-smoke.md | 5 -
.changeset/wise-pumas-attack.md | 19 -
.changeset/wrong-slot-i18n-keys-4118.md | 15 -
apps/console/CHANGELOG.md | 335 +++++++
apps/console/package.json | 2 +-
packages/app-shell/CHANGELOG.md | 835 ++++++++++++++++++
packages/app-shell/package.json | 2 +-
packages/auth/CHANGELOG.md | 24 +
packages/auth/package.json | 2 +-
packages/cli/CHANGELOG.md | 52 ++
packages/cli/package.json | 2 +-
packages/collaboration/CHANGELOG.md | 48 +
packages/collaboration/package.json | 2 +-
packages/components/CHANGELOG.md | 418 +++++++++
packages/components/package.json | 2 +-
packages/core/CHANGELOG.md | 430 +++++++++
packages/core/package.json | 2 +-
packages/create-plugin/CHANGELOG.md | 2 +
packages/create-plugin/package.json | 2 +-
packages/data-objectstack/CHANGELOG.md | 179 ++++
packages/data-objectstack/package.json | 2 +-
packages/fields/CHANGELOG.md | 389 ++++++++
packages/fields/package.json | 2 +-
packages/i18n/CHANGELOG.md | 313 +++++++
packages/i18n/package.json | 2 +-
packages/layout/CHANGELOG.md | 147 +++
packages/layout/package.json | 2 +-
packages/mobile/CHANGELOG.md | 12 +
packages/mobile/package.json | 2 +-
packages/permissions/CHANGELOG.md | 48 +
packages/permissions/package.json | 2 +-
packages/plugin-ai/CHANGELOG.md | 55 ++
packages/plugin-ai/package.json | 2 +-
packages/plugin-calendar/CHANGELOG.md | 101 +++
packages/plugin-calendar/package.json | 2 +-
packages/plugin-charts/CHANGELOG.md | 116 +++
packages/plugin-charts/package.json | 2 +-
packages/plugin-chatbot/CHANGELOG.md | 101 +++
packages/plugin-chatbot/package.json | 2 +-
packages/plugin-dashboard/CHANGELOG.md | 355 ++++++++
packages/plugin-dashboard/package.json | 2 +-
packages/plugin-designer/CHANGELOG.md | 224 +++++
packages/plugin-designer/package.json | 2 +-
packages/plugin-detail/CHANGELOG.md | 246 ++++++
packages/plugin-detail/package.json | 2 +-
packages/plugin-editor/CHANGELOG.md | 44 +
packages/plugin-editor/package.json | 2 +-
packages/plugin-form/CHANGELOG.md | 80 ++
packages/plugin-form/package.json | 2 +-
packages/plugin-gantt/CHANGELOG.md | 122 +++
packages/plugin-gantt/package.json | 2 +-
packages/plugin-grid/CHANGELOG.md | 149 ++++
packages/plugin-grid/package.json | 2 +-
packages/plugin-kanban/CHANGELOG.md | 118 +++
packages/plugin-kanban/package.json | 2 +-
packages/plugin-list/CHANGELOG.md | 216 +++++
packages/plugin-list/package.json | 2 +-
packages/plugin-map/CHANGELOG.md | 67 ++
packages/plugin-map/package.json | 2 +-
packages/plugin-markdown/CHANGELOG.md | 44 +
packages/plugin-markdown/package.json | 2 +-
packages/plugin-report/CHANGELOG.md | 140 +++
packages/plugin-report/package.json | 2 +-
packages/plugin-timeline/CHANGELOG.md | 63 ++
packages/plugin-timeline/package.json | 2 +-
packages/plugin-tree/CHANGELOG.md | 62 ++
packages/plugin-tree/package.json | 2 +-
packages/plugin-view/CHANGELOG.md | 81 ++
packages/plugin-view/package.json | 2 +-
packages/providers/CHANGELOG.md | 12 +
packages/providers/package.json | 2 +-
packages/react-runtime/CHANGELOG.md | 2 +
packages/react-runtime/package.json | 2 +-
packages/react/CHANGELOG.md | 261 ++++++
packages/react/package.json | 2 +-
packages/runner/CHANGELOG.md | 60 ++
packages/runner/package.json | 2 +-
packages/sdui-parser/CHANGELOG.md | 2 +
packages/sdui-parser/package.json | 2 +-
packages/types/CHANGELOG.md | 234 +++++
packages/types/package.json | 2 +-
packages/vscode-extension/CHANGELOG.md | 23 +
packages/vscode-extension/package.json | 2 +-
230 files changed, 6250 insertions(+), 3215 deletions(-)
delete mode 100644 .changeset/accept-invitation-single-route-3811.md
delete mode 100644 .changeset/action-confirm-text-translation-4265.md
delete mode 100644 .changeset/action-forward-whitelist-parity-4050-4192.md
delete mode 100644 .changeset/action-menu-autotrigger-overflow-4162.md
delete mode 100644 .changeset/action-typing-integrity-4418-4422.md
delete mode 100644 .changeset/actiondef-close-the-index-signature.md
delete mode 100644 .changeset/address-display-formatter-4037.md
delete mode 100644 .changeset/ai-build-thread-survives-preview-switch-2627.md
delete mode 100644 .changeset/aichatpage-runtime-message-seam-4437.md
delete mode 100644 .changeset/analytics-label-net-shared-glue-4389.md
delete mode 100644 .changeset/analytics-option-label-i18n-4030.md
delete mode 100644 .changeset/api-console-storage-slot-key-4240.md
delete mode 100644 .changeset/app-management-page-i18n-4307.md
delete mode 100644 .changeset/app-management-search-keyed-label-4343.md
delete mode 100644 .changeset/autonumber-textual-ref-fallback-4219.md
delete mode 100644 .changeset/bell-approvals-activity-off-app-4197.md
delete mode 100644 .changeset/bell-panel-render-oracle-4230.md
delete mode 100644 .changeset/bridge-density-prototype-guard-4442.md
delete mode 100644 .changeset/button-has-type-lint-4045.md
delete mode 100644 .changeset/calm-schools-tease.md
delete mode 100644 .changeset/chatmessage-one-contract-4383.md
delete mode 100644 .changeset/chatmessage-seam-adapter.md
delete mode 100644 .changeset/combo-dataset-path-presentation-merge-4229.md
delete mode 100644 .changeset/commit-store-unreachable-copy-3529.md
delete mode 100644 .changeset/components-no-implicit-any-4353.md
delete mode 100644 .changeset/components-type-checks-its-tests.md
delete mode 100644 .changeset/core-app-shell-type-check-their-tests.md
delete mode 100644 .changeset/currency-iso-4217-minor-units-4361.md
delete mode 100644 .changeset/curvy-donkeys-shave.md
delete mode 100644 .changeset/data-table-row-menu-predicate-derive-4354.md
delete mode 100644 .changeset/dataset-result-field-derive-3815.md
delete mode 100644 .changeset/datasetwidget-option-color-probe-apifetch-4121.md
delete mode 100644 .changeset/datasource-preview-strict-key-groups-4131.md
delete mode 100644 .changeset/dead-currency-symbol-map.md
delete mode 100644 .changeset/dead-surface-deletions-batch3-4328.md
delete mode 100644 .changeset/dead-surface-pair-4364-4368.md
delete mode 100644 .changeset/declared-actions-bar-drop-predicate-casts.md
delete mode 100644 .changeset/declared-actions-bar-predicate-helper-4080.md
delete mode 100644 .changeset/default-app-landing-and-real-app-writes-4233.md
delete mode 100644 .changeset/designer-table-ellipsis-4377.md
delete mode 100644 .changeset/designer-table-key-collapse-4387.md
delete mode 100644 .changeset/dotted-dimension-labels-table-pivot-4263.md
delete mode 100644 .changeset/dotted-dimension-option-labels-4053.md
delete mode 100644 .changeset/element-button-forward-excess-property-check-4321.md
delete mode 100644 .changeset/environment-deeplink-arm-on-create-action-4123.md
delete mode 100644 .changeset/fields-subpath-surface-4325.md
delete mode 100644 .changeset/filter-panel-no-view-overlay-4155.md
delete mode 100644 .changeset/form-route-edit-recordid-4278.md
delete mode 100644 .changeset/fullscreen-editor-hoist-3398.md
delete mode 100644 .changeset/gantt-count-interpolation-4157.md
delete mode 100644 .changeset/gantt-link-rejection-feedback-4158.md
delete mode 100644 .changeset/gated-options-keep-stored-value-4247.md
delete mode 100644 .changeset/global-filter-preset-alias-4165.md
delete mode 100644 .changeset/global-nav-studio-retire-rc6.md
delete mode 100644 .changeset/great-mails-repeat.md
delete mode 100644 .changeset/grid-bulk-bar-clear-resets-checkboxes-4140.md
delete mode 100644 .changeset/grid-row-menu-predicate-derive-4429.md
delete mode 100644 .changeset/handrolled-interpolators-literal-4370.md
delete mode 100644 .changeset/home-action-centre-badges-full-unread-count-4329.md
delete mode 100644 .changeset/home-action-centre-needs-an-answer-4235.md
delete mode 100644 .changeset/host-app-resolver-root-export-4280.md
delete mode 100644 .changeset/i18n-copy-trio-3878-3875-3877.md
delete mode 100644 .changeset/i18n-stragglers-4375-4376.md
delete mode 100644 .changeset/i18nlabel-render-sites-4163.md
delete mode 100644 .changeset/image-field-maxsize-guard-4141.md
delete mode 100644 .changeset/inbox-activity-links-host-app-4074.md
delete mode 100644 .changeset/inbox-popover-bell-polls-off-app-4110.md
delete mode 100644 .changeset/init-scaffold-vite-env-tsc-4111.md
delete mode 100644 .changeset/inline-address-input-4216.md
delete mode 100644 .changeset/inline-credential-gate-4221.md
delete mode 100644 .changeset/inline-default-value-drift-gate-3810.md
delete mode 100644 .changeset/inline-edit-delegate-4220.md
delete mode 100644 .changeset/internal-form-in-shell-4109.md
delete mode 100644 .changeset/interpolation-parity-gate-3845.md
delete mode 100644 .changeset/kanban-rejected-move-rollback-4138.md
delete mode 100644 .changeset/list-filtered-empty-copy-4155.md
delete mode 100644 .changeset/list-sort-field-fallback-and-reset-4243.md
delete mode 100644 .changeset/listview-shared-datasource-gate-4038.md
delete mode 100644 .changeset/local-select-dimension-i18n-4330.md
delete mode 100644 .changeset/locale-menu-from-app-4039.md
delete mode 100644 .changeset/lucky-buttons-shave.md
delete mode 100644 .changeset/map-pack-mirror-gate-4401.md
delete mode 100644 .changeset/mapdensity-dead-rowheight-spellings-4352.md
delete mode 100644 .changeset/metric-kpi-i18n-channel-4032.md
delete mode 100644 .changeset/metric-props-dom-passthrough-4426.md
delete mode 100644 .changeset/metric-widget-dom-spread-4357.md
delete mode 100644 .changeset/nervous-pans-repeat.md
delete mode 100644 .changeset/number-format-locale-ordinal-grouping-4033.md
delete mode 100644 .changeset/objectchart-label-net-third-copy-4405.md
delete mode 100644 .changeset/objectchart-option-color-probe-apifetch-4114.md
delete mode 100644 .changeset/objectgrid-external-pagination-props-4277.md
delete mode 100644 .changeset/objectgrid-rowheight-boundary-4443.md
delete mode 100644 .changeset/objectmap-filter-config-4034.md
delete mode 100644 .changeset/olive-donkeys-shake.md
delete mode 100644 .changeset/olive-eels-shave.md
delete mode 100644 .changeset/phantom-dependency-gate-4394.md
delete mode 100644 .changeset/picklocalized-own-props-3907.md
delete mode 100644 .changeset/pivot-null-bucket-4056.md
delete mode 100644 .changeset/react-type-checks-its-tests.md
delete mode 100644 .changeset/record-details-retire-layout-input-3818.md
delete mode 100644 .changeset/record-picker-filter-input-3830.md
delete mode 100644 .changeset/record-picker-sort-limit-empty-text-4167.md
delete mode 100644 .changeset/render-save-advisory-findings-4133.md
delete mode 100644 .changeset/required-when-submit-verdict-4161.md
delete mode 100644 .changeset/reserved-auth-feature-flags-2514.md
delete mode 100644 .changeset/retire-action-engine-event-mapping-3368.md
delete mode 100644 .changeset/retire-cloud-operations-surface-4152.md
delete mode 100644 .changeset/retire-common-search-key-4392.md
delete mode 100644 .changeset/retire-narrow-typetests-projects-4291.md
delete mode 100644 .changeset/retire-object-detail-factory-3731-3736.md
delete mode 100644 .changeset/retire-report-editor-namespace-4145.md
delete mode 100644 .changeset/retire-standalone-validation-resource-4132.md
delete mode 100644 .changeset/retire-url-action-params-newtab-4097.md
delete mode 100644 .changeset/richtext-spec-spelling-4250.md
delete mode 100644 .changeset/rotten-plums-smash.md
delete mode 100644 .changeset/rowheight-density-one-answer-4440.md
delete mode 100644 .changeset/safe-translation-inline-default-3865.md
delete mode 100644 .changeset/second-client-save-advisories-4237.md
delete mode 100644 .changeset/set-default-view-tab-identity-4211.md
delete mode 100644 .changeset/setup-deep-link-2794.md
delete mode 100644 .changeset/setup-exit-console-basename-4181.md
delete mode 100644 .changeset/shared-inbox-feed-4225.md
delete mode 100644 .changeset/show-empty-related-plural-base-key-3863.md
delete mode 100644 .changeset/sidebar-collapse-survives-reload-4234.md
delete mode 100644 .changeset/six-bare-keys-defaults-4396.md
delete mode 100644 .changeset/sort-relational-hint-stored-field-4294.md
delete mode 100644 .changeset/spec-symbol-collisions-rc6-4167.md
delete mode 100644 .changeset/sso-landing-setup-only-home-4048.md
delete mode 100644 .changeset/studio-dock-polish-pins.md
delete mode 100644 .changeset/studio-readonly-affordances-4036.md
delete mode 100644 .changeset/synth-page-canonical-properties-4232.md
delete mode 100644 .changeset/tall-eagles-repeat.md
delete mode 100644 .changeset/tenant-locale-seeds-ui-language-4035.md
delete mode 100644 .changeset/tidy-eels-invite.md
delete mode 100644 .changeset/type-check-tests-auth-4040.md
delete mode 100644 .changeset/type-check-tests-i18n-4040.md
delete mode 100644 .changeset/type-check-tests-permissions-4040.md
delete mode 100644 .changeset/type-check-tests-plugin-chatbot-4040.md
delete mode 100644 .changeset/type-check-tests-plugin-form-4040.md
delete mode 100644 .changeset/type-check-tests-plugin-gantt-4040.md
delete mode 100644 .changeset/type-check-tests-plugin-map-4040.md
delete mode 100644 .changeset/unpublished-banner-reads-unpublished-key-6955.md
delete mode 100644 .changeset/updateview-draft-addressing-4139.md
delete mode 100644 .changeset/useobjectchat-honest-message-type-4424.md
delete mode 100644 .changeset/view-invalidation-seam-4373.md
delete mode 100644 .changeset/view-overrides-invalidation-4363.md
delete mode 100644 .changeset/widget-dom-leak-sweep-gate-4425.md
delete mode 100644 .changeset/wild-pugs-smoke.md
delete mode 100644 .changeset/wise-pumas-attack.md
delete mode 100644 .changeset/wrong-slot-i18n-keys-4118.md
diff --git a/.changeset/accept-invitation-single-route-3811.md b/.changeset/accept-invitation-single-route-3811.md
deleted file mode 100644
index e4e0ce0313..0000000000
--- a/.changeset/accept-invitation-single-route-3811.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/console': minor
-'@object-ui/i18n': minor
-'@object-ui/app-shell': patch
----
-
-`/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
-
-Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
-
-Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
-
-One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a ` ` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
-
-Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
diff --git a/.changeset/action-confirm-text-translation-4265.md b/.changeset/action-confirm-text-translation-4265.md
deleted file mode 100644
index 73cbc80485..0000000000
--- a/.changeset/action-confirm-text-translation-4265.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@object-ui/react': patch
-'@object-ui/components': patch
-'@object-ui/plugin-detail': patch
-'@object-ui/app-shell': patch
----
-
-Action confirm dialogs and success toasts now honour the bundle's translated
-`confirmText` / `successMessage`, not just `label` (objectui#4265).
-
-A TranslationBundle entry for an action carries three keys under one
-`_actions.` node — `label`, `confirmText`, `successMessage` — and
-`useObjectLabel()` has always exposed a resolver for each. What had drifted was
-the call sites: `page:header` (authored record pages), `record:quick_actions`
-and the related-list row menu resolved the button `label` only and dispatched
-the authored `confirmText` / `successMessage` untouched. One bundle entry met
-two fates: the button rendered the translation, the confirm dialog rendered the
-authored English.
-
-All action-rendering surfaces now go through one resolver,
-`useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
-applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
-resolvers over the three keys together. Fallback is unchanged: with no bundle
-entry — or an entry lacking a key — the authored text renders. A bundle cannot
-introduce a `confirmText` or `successMessage` the metadata never declared.
diff --git a/.changeset/action-forward-whitelist-parity-4050-4192.md b/.changeset/action-forward-whitelist-parity-4050-4192.md
deleted file mode 100644
index 111d9f9b2d..0000000000
--- a/.changeset/action-forward-whitelist-parity-4050-4192.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/components': patch
----
-
-An action rendered in the overflow menu, as an icon or inside a group now reaches the runner carrying the same authored keys as the same action rendered inline — `action:menu`, `action:icon` and `action:group` forward `label` and `description`, and the two group/icon surfaces also forward `resultDialog`.
-
-Every action renderer hands the `ActionRunner` an explicit key WHITELIST rather than the action itself. That is deliberate — a key no renderer honours must not look wired — but the whitelists had drifted, and which renderer a given action gets is decided by `action:bar`'s `maxVisible` split (3 on desktop, 1 on mobile) and by `systemActions`, which are always in the overflow menu. So the same declared action behaved differently depending on the viewport.
-
-`label` and `description` are what the console's param-collection handler titles its dialog from (`title: action?.label || action?.title`, `description: actionDescription(…, action?.description)`). Dropped, an action with declared `params` opened a dialog titled "Action parameters" while the SAME declaration rendered inline named itself "Create Environment". `resultDialog` is the one-shot reveal spec (a fresh 2FA code, a newly minted OAuth secret): dropped, the runner falls back to the success toast and the value the user was meant to copy is gone — the objectui#3646 defect, still live on two of the four declared surfaces.
-
-`undoable` and `recordIdField` are deliberately NOT added. Both are read only under a `rowRecord` guard, and `rowRecord` is `params._rowRecord`, written exclusively by the spread-based hosts (`DeclaredActionsBar`, `RelatedRecordActionsBridge`, `ObjectGrid`, `page:header`), none of which dispatch through these renderers. They are unreachable on this path rather than dropped — `action:button` forwards them here inertly — so forwarding them would have added a second inert copy instead of restoring an affordance.
-
-A new repo gate, `pnpm check:action-forward-parity`, now derives each surface's owed key set (`authorable ∩ runtime-read − retired`) from the spec's own schemas and the consumers' ASTs and fails when a renderer drops one, so the seventh instance of this class fails on the pull request that introduces it rather than shipping green.
diff --git a/.changeset/action-menu-autotrigger-overflow-4162.md b/.changeset/action-menu-autotrigger-overflow-4162.md
deleted file mode 100644
index 44bbb23ac2..0000000000
--- a/.changeset/action-menu-autotrigger-overflow-4162.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/components': patch
----
-
-An `autoTrigger` action that spills past `action:bar`'s `maxVisible` now still runs — `action:menu` consumes the flag instead of dropping it.
-
-`autoTrigger` is the client-composed "run this action as soon as a renderer receives it" flag behind deep links like the welcome page's "Create your environment" CTA (#844). It was consumed only by `action:button`. `action:bar` splits its post-gate list at `maxVisible` (3 on desktop, 1 on mobile) and hands the tail to `action:menu`, which had no `autoTrigger` handling at all — so an auto-triggered action that happened to sort past that threshold was rendered as an ordinary "More" menu entry and never ran, while the caller had already spent the one-shot signal it stood for. The `?runAction=create_environment` deep link is consumed by stripping it from the URL, so the measured end state was `urlParam=null execute=0`: no dialog, and no URL left to retry from. Which actions lost their auto-trigger was partly a function of viewport width, since `maxVisible` drops to 1 on mobile, and `systemActions` — always in the overflow menu, whatever the viewport — could never fire one at all.
-
-The flag's contract is now stated and enforced as "execute once on mount by whichever renderer receives the action". `action:menu` consumes it by EXECUTING, through the same path a click on that item takes; it does not open the dropdown, so a transport flag never moves what the user sees. Consumption happens where the action provably arrives — the menu renderer receiving it — not in the menu items, which Radix mounts only once the dropdown opens and which would therefore have waited on the very click the flag exists to avoid.
-
-Once-ness has one implementation (`renderers/action/auto-trigger.ts`), now shared by both renderers rather than written twice: a guard ref per rendered action, so re-renders never re-fire it and a flag that flips true later still fires exactly once. Container visibility still governs mounting — a hidden `action:bar` or `action:menu` renders no children and auto-triggers nothing — while the action's own `visible` gate does not suppress the trigger, matching `action:button`'s long-standing behaviour so that a deep link cannot depend on where the bar happened to put the action.
-
-The `action:bar` split, the inline `action:button` path and #4166's arming pins are unchanged.
diff --git a/.changeset/action-typing-integrity-4418-4422.md b/.changeset/action-typing-integrity-4418-4422.md
deleted file mode 100644
index 8499930692..0000000000
--- a/.changeset/action-typing-integrity-4418-4422.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/components': minor
----
-
-The action renderers publish the modern `UIActionSchema`, and every `forwardRef` renderer's props parameter is annotated so its declared types survive
-
-**Breaking semantics (declared `minor` per the repo's version-alignment rule — objectui#4403 precedent — never `major`).** Six exported declarations in `@object-ui/components` change the action type they name, from the `@deprecated` legacy `ActionSchema` (`crud.ts`) to `UIActionSchema` (`ui-action.ts`):
-
-- `ActionBarSchema.actions`, `ActionBarSchema.systemActions`
-- `ActionMenuSchema.actions`
-- `ActionGroupSchema.actions`
-- `ActionButtonProps.schema`, `ActionIconProps.schema`
-
-The two types are not interchangeable in either direction. `UIActionSchema` requires `name`, which legacy inherits as optional from `BaseSchema`; legacy pins `type: 'action'` where these renderers serve `'script' | 'url' | 'modal' | 'flow' | 'api'`; and only the modern type declares `locations`, `target`, `endpoint`, `bodyExtra`, `bodyShape` and a `variant` union containing `'primary'` — all of which the implementations already read. objectui#4417 measured four compiler errors proving the VALUES were modern while the DECLARATIONS said legacy; this moves the declarations to match, so the contract and the implementation finally agree.
-
-No runtime behaviour changes, and no published surface is involved: none of the six declarations is re-exported from the package index, and the sweep found zero type-checked consumers outside each declaration's own file. Metadata that renders today renders identically — the renderers read the same keys through the same paths.
-
-Separately, all fifteen `schema`-reading `forwardRef` renderers in the package now annotate their render function's first parameter directly, and carry the pass-through index signature on that annotation rather than on the `forwardRef` type argument. `forwardRef` routes its type argument through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature — every declared property erased, silently, with `noImplicitAny` reporting clean because the `any` is supplied explicitly by the index signature. That is what hid the declaration/implementation drift above for as long as it lasted. Thirteen renderers recover a real declared type for `schema` (the two raw-tag factories keep `any`, which is what they genuinely declare), and a new structural guard, `forwardref-props-annotation.guard.test.ts`, fails on any future `forwardRef` that reintroduces either half of the trap.
diff --git a/.changeset/actiondef-close-the-index-signature.md b/.changeset/actiondef-close-the-index-signature.md
deleted file mode 100644
index 95ffedda4b..0000000000
--- a/.changeset/actiondef-close-the-index-signature.md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-"@object-ui/core": minor
----
-
-Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.
-
-`ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
-key (`execute`) both type-checked and the runner then silently bound no handler
-— the objectstack#2169 "Mark Done does nothing" shape. Step 1
-(objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
-the 18 spec-owned keys to real fields. This is **step 3**, executing the
-maintainer's 2026-08-06 ruling now that its upstream half shipped in
-`@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).
-
-- **`visible` and `disabled` now have ONE shape, derived from the spec** —
- `boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
- 采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
- the envelope is the full form. `visible` loses its hand-written `| boolean`
- (the spec adopted that arm, so restating it locally would be a second
- contract), and `disabled` gains the envelope arm it never had — it was
- `string | boolean`, which is why the envelope the spec emits could only be
- read through a cast.
-- **The index signature is gone.** `tsc` now rejects an unknown or retired key
- at any site that authors an action literal in code.
-- **Five keys the deletion surfaced, promoted to real fields.** `to`,
- `external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
- legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
- declared only as data; and `description`, which every action renderer forwards
- (`check:action-forward-parity` requires it) and the param-collection dialog
- reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
- deletion produced across the whole workspace.
-- **`ActionContext` keeps its index signature**, deliberately. It is a runtime
- data bag whose keys are genuinely open; `ActionDef` is a declared metadata
- contract. That asymmetry is the point, and it is now pinned in both
- directions.
-
-**Breaking edge, deliberate — same class as step 2's, one step further.** An
-`ActionDef` literal carrying a key this interface does not declare is now a
-compile error where it previously compiled and did nothing at runtime. That
-includes the retired `execute` (rename it to `target`; `os migrate meta --from
-16` rewrites it) and plain typos. Values that were only ever absorbed silently
-are the ones that stop compiling, so the failure moves to where it can be fixed
-rather than appearing as a button that does nothing.
-
-**What did NOT retire with the index signature**, contrary to step 1's
-expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
-`execute` rename prescription both stay. `tsc` only ever sees actions authored
-as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
-(objectstack#3903) — which is the population `execute: 'markDone'` actually
-lives in. The two mechanisms cover disjoint populations; retiring the runtime
-half would have re-opened the gap it was written for.
diff --git a/.changeset/address-display-formatter-4037.md b/.changeset/address-display-formatter-4037.md
deleted file mode 100644
index f20b94c6a2..0000000000
--- a/.changeset/address-display-formatter-4037.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/fields': patch
----
-
-A `Field.address` value now reads as a formatted postal address on the record detail page, instead of stringified JSON.
-
-The display (read) registry mapped `address` straight to `JsonCellRenderer`, so a populated address rendered as `{"street":"中策路 1 号","city":"杭州",…}` — while a `location` field sitting next to it in the same field group rendered formatted, and the create/edit dialog rendered the very same value as proper Street / City / State / ZIP / Country inputs. The gap was display-side only: the input registry has always carried `address`. Both read surfaces the detail page exposes are affected and both are fixed, because they share one `displayValue` path — read mode, and the inline-edit read state (the row carrying the pencil affordance, before a field is actually being edited).
-
-Layout is not invented for the read side. `AddressField`'s readonly branch already collapsed a stored address to a single line, and that rule — `Street, City, State ZIP, Country`, with `state` and the postal code sharing one comma group — is now the *only* implementation, moved into a pure `address-format` module that both surfaces call. A readonly form and a detail page therefore cannot spell one stored address two ways; a second copy next to the renderer would have been a rule that drifts. The module is deliberately React-free, so the eagerly-loaded barrel can format a cell without pulling `AddressField` and its inputs out of the lazy widget chunk.
-
-Partial values degrade the way the readonly line already did: absent, non-string and whitespace-only parts are dropped rather than spaced over, so a street-only address renders as `中策路 1 号` and never as `, , ,` or as the string `undefined`. Legacy records whose postal code was written under `zipCode` (objectstack#5143) still render it, matching what the input widget reads.
-
-Nothing is silently swallowed by the change: a value the formatter cannot recognize — an object carrying no known part — keeps today's compact-JSON rendering rather than disappearing, and `{}` or a null value shows the usual empty placeholder. A plain string address passes straight through. `location`, `geolocation`, and the genuinely structural `json` / `object` types are untouched.
-
-The address *input* is unchanged on every surface, including the create/edit dialog.
diff --git a/.changeset/ai-build-thread-survives-preview-switch-2627.md b/.changeset/ai-build-thread-survives-preview-switch-2627.md
deleted file mode 100644
index 87b5402d24..0000000000
--- a/.changeset/ai-build-thread-survives-preview-switch-2627.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-The AI build conversation no longer blanks itself the moment the preview opens
-
-`useChatConversation` treated every failed resolve the same way: clear the id, clear the messages. For a FIRST resolve that is right — there is nothing to lose. For a re-resolve of the conversation the hook is already holding it is destructive, and the AI build flow fires exactly such a re-resolve at the worst possible moment.
-
-The sequence is the magic-moment one. A build turn streams; `apply_blueprint`'s draft lands and the Live Canvas opens, switching the page from full-screen chat to the chat|preview split; the turn ends; ADR-0057 A1.b bind-on-create — which deliberately waits for that edge — re-keys the conversation to `app::build` and navigates to `?package=`. The scope flip re-resolves the same conversation, one GET issued at the instant the server is still finishing the heaviest turn of the session. A 502 or a dropped connection on that single request landed in the blanket catch.
-
-Clearing the id there is not a conservative fallback, because of what the host does with it: `AiChatPage` keys its chat pane on `` `${chatApi}:${conversationId ?? 'pending'}` ``, and the thread itself lives inside the chat hook's instance (`useObjectChat` seeds from `initialMessages` once per mount). So `undefined` does not re-render the pane, it REPLACES it, and the blueprint card, the build summary and the Publish button all leave with the discarded instance — the reported "the whole conversation went blank right after the build finished, and only came back after switching threads and back".
-
-A failed resolve now keeps whatever it was re-reading, when that is the conversation already held: the id is still valid and the messages are still the truth, so the surface stays as it was and the next resolve recovers. This is the other half of a guard that was already there for the empty case — the same re-resolve returning NO messages mid-turn was already refused the right to wipe hydrated history; only the failing case was still open. A resolve aimed at a DIFFERENT conversation (a sidebar switch) and a first resolve with nothing held still clear, and both are pinned negatively.
-
-Pinned at two levels: the hook, and the page driving the real build→preview→re-key sequence and asserting the pane is never remounted across it.
diff --git a/.changeset/aichatpage-runtime-message-seam-4437.md b/.changeset/aichatpage-runtime-message-seam-4437.md
deleted file mode 100644
index d53ee63a1b..0000000000
--- a/.changeset/aichatpage-runtime-message-seam-4437.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-`AiChatPage` narrows the chat hook's messages through the exported `toRuntimeMessages` adapter instead of five casts
-
-`useObjectChat` returns `ObjectChatMessage[]` — the shape both of its modes really produce (objectui#4424) — which is wide where local mode is wide: it keeps the authored `'tool'` role and the legacy `'partial-call'`/`'call'`/`'result'` tool states. This page wants the runtime shape, and said so five times with `messages as ChatMessage[]` (plus one `as unknown as` double cast). The narrowing was real and the casts were legal; what was missing is that a cast erases the whole difference rather than the intentional part of it, so nothing recorded which narrowing was meant and nothing could go red when it changed.
-
-There is now one conversion where the hook's values enter the page's runtime-typed world, memoized on `messages` exactly as the plugin's own three renderers do (objectui#4399). All five sites read it; no cast replaces them, and the `as unknown as` double cast at the bound-package derivation turned out to be unnecessary once the value is honestly typed.
-
-One behaviour changes, and it is the one the fold was measured for. `sanitizeChatMessagesForCache` declares its parameter's role as `'user' | 'assistant' | 'system'` — it has always asked for folded roles, and the cast is what let an unfolded `'tool'` past that declaration. Measured on a thread carrying one: the entry was cached as `role: 'tool'`, which the cache's own reader then rejects, so the message vanished on a cache-fallback reload; and because sanitize gates tool serialization on `role === 'assistant'`, that turn's tool invocations — including the re-serialized draft envelope behind "Review N changes / Publish" — never reached the cache at all. Folded, both survive the reload they exist to survive. The other two compute sites were measured to be fold-insensitive and are unchanged: `isConversationZh` reads `role === 'user'` and the text, which the fold can neither create nor destroy, and `deriveBoundPackageId` walks every message irrespective of role and reads keys the adapter spreads through.
-
-Nothing on screen moves today: neither a `'tool'` role nor a legacy tool state is producible on this page's own paths, since hydration yields runtime roles and API mode's values come from `mapMessages` with v6 states already. This closes the hole ahead of a value that can reach it, and makes a future vocabulary move surface as a type error at the host rather than as rendering behaviour.
diff --git a/.changeset/analytics-label-net-shared-glue-4389.md b/.changeset/analytics-label-net-shared-glue-4389.md
deleted file mode 100644
index 9e6bf8a0d2..0000000000
--- a/.changeset/analytics-label-net-shared-glue-4389.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@object-ui/core': patch
-'@object-ui/react': patch
-'@object-ui/plugin-dashboard': patch
-'@object-ui/plugin-report': patch
----
-
-Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
-
-PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
-
-It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
-
-The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
-
-Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
diff --git a/.changeset/analytics-option-label-i18n-4030.md b/.changeset/analytics-option-label-i18n-4030.md
deleted file mode 100644
index 65206812ad..0000000000
--- a/.changeset/analytics-option-label-i18n-4030.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/core': patch
-'@object-ui/plugin-dashboard': patch
-'@object-ui/plugin-charts': patch
----
-
-Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
-
-A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
-
-There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions...` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
-
-Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
-
-The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
-
-Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
diff --git a/.changeset/api-console-storage-slot-key-4240.md b/.changeset/api-console-storage-slot-key-4240.md
deleted file mode 100644
index 398a06306b..0000000000
--- a/.changeset/api-console-storage-slot-key-4240.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/console': patch
----
-
-API Console renders the Storage group again — its catalog key now names the canonical `file-storage` slot instead of the route
-
-The API Console's service-gated groups are keyed by canonical service-slot name, because the key is looked up directly in `/discovery`'s `services` map — and the framework keys that map by `CoreServiceName`. The storage group was keyed `storage`, which is the *route* (`/api/v1/storage`), not the slot (`file-storage`). So the lookup missed on every host, and because a miss is indistinguishable from "no such service" the deliberate fail-closed branch (ADR-0076 D12) hid all three storage endpoints — upload, download, delete — on every deployment, whether or not a storage service was registered and healthy.
-
-The fail-closed posture was never the defect and is unchanged; only the key moves. The group's user-facing name stays `Storage` — that is the route's name, and it was never derived from the catalog key, so no display string and no i18n resource changes.
-
-The mis-key survived because nothing tied the catalog's keys to the vocabulary they are spelled in: a wrong key produces silence, not an error, and an empty group is exactly what a legitimately absent service looks like. A tripwire now derives that vocabulary from `@objectstack/spec` itself and asserts every service-gated catalog key against it, so a rename on either side fails a test instead of quietly emptying a group. Deriving it also surfaced two further keys that name no slot and therefore can never render — `workflow` (slot retired upstream) and `feed` (never a slot) — recorded as a documented exception set pointing at objectui#4303 rather than fixed here, since neither has a correctly-spelled name to move to.
diff --git a/.changeset/app-management-page-i18n-4307.md b/.changeset/app-management-page-i18n-4307.md
deleted file mode 100644
index 78f5bfadb1..0000000000
--- a/.changeset/app-management-page-i18n-4307.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-'@object-ui/i18n': patch
-'@object-ui/console': patch
----
-
-The console's Applications page is localized — its own chrome only, never the
-server's words (objectui#4307).
-
-`AppManagementPage` was raw English end to end: headings, the search field, the
-selection and bulk controls, the six per-row actions with their tooltip/ARIA
-pairs, the status badges, and every toast. It was the last un-i18n'd system page,
-and #4233 / PR #4300 had just given it four live mutations — so the gap became
-user-visible on every non-English console at the moment operators started using
-it. 45 keys land under `appManagement.*` in all ten packs, reached through
-`useObjectTranslation` with the call site's `defaultValue` inline, which is the
-convention the neighbouring system pages already follow.
-
-The split that shapes this change is between the strings the PAGE authors and
-the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
-`manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
-required` is the server's diagnosis of one specific request; there is no fixed
-catalogue of those sentences to key against. Each failure toast is therefore a
-keyed template with a `{{reason}}` hole, and what fills the hole is passed
-through byte for byte, untranslated. The one part that IS the page's own — what
-it says when the server sent no message at all — is keyed as
-`appManagement.toast.unknownError`.
-
-Two smaller things follow from doing the conversion properly rather than
-mechanically. The per-failure entry of a bulk toast and the separator between
-entries are keys, not literals, because bracket style and list punctuation are
-locale properties (the same rule, and the same past defect, as
-`validation.formInvalidJoiner`). And the row's controls now name an app through
-the resolver the visible heading two lines away already used, with `t` passed:
-an app carrying objectui's keyed label form previously rendered `Select [object
-Object]` into its checkbox's ARIA label.
diff --git a/.changeset/app-management-search-keyed-label-4343.md b/.changeset/app-management-search-keyed-label-4343.md
deleted file mode 100644
index 62acaa3625..0000000000
--- a/.changeset/app-management-search-keyed-label-4343.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@object-ui/console': patch
----
-
-The Applications page's search box no longer takes the page out on the first keystroke when an app carries a non-string label
-
-`apps/console/src/pages/system/AppManagementPage.tsx` filtered on `(app.label || '').toLowerCase()`. `label` and `description` are `I18nLabel` in `AppSchema` — `string | Record< string, string >` in `@objectstack/spec` 17.0.0-rc.6 — so an authored non-string label is spec-legal metadata, and an object is **truthy**: the `|| ''` guard never fired for one, and `.toLowerCase()` received the object.
-
-```
-TypeError: (l || "").toLowerCase is not a function
-```
-
-That throw happened inside `filter` **during render**, so it took the whole page down rather than degrading search. It stayed invisible until someone typed, because `if (!searchQuery) return true` returns before either read — the page mounted perfectly with the very metadata that killed it one character later.
-
-Both reads now go through the resolver the rows already render with: `appTitle` (the single display-name helper objectui#4307 introduced in this file) for the label, and the identical `resolveKeyedI18nLabel(…, t)` call the description paragraph makes. This is a repair to one page's filter, not a new capability — but it does make search match what the operator can actually see: for objectui's keyed label form it now matches the pack's answer rather than the authoring `defaultValue`, and it matches `app.name` wherever the row heading itself falls back to it.
-
-Routing search through the render path also means it cannot drift out of step again. The inline locale map form (`{ en: 'Storefront', 'zh-CN': '店面' }`) is still resolved by neither path — the row heading falls back to the app name and search now matches on exactly that, instead of crashing on it — so when objectui#4163 widens the resolver, display and search gain the map form in the same commit.
-
-No first-party app ships a non-string app label today, so this was reachable through authored metadata rather than live in the shipped examples; the crash is real for anyone who authored one, and `AppSchema` accepts it with a green parse.
diff --git a/.changeset/autonumber-textual-ref-fallback-4219.md b/.changeset/autonumber-textual-ref-fallback-4219.md
deleted file mode 100644
index 147917e610..0000000000
--- a/.changeset/autonumber-textual-ref-fallback-4219.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-Inline edit no longer offers a record picker for a spec-spelled `autonumber` field that carries a `reference_to`
-
-`TEXTUAL_REF_FALLBACK_TYPES` — the detail page's one definition of "machine-computed" — spelled the auto-number type `auto_number` only. `@objectstack/spec`, the designer and the metadata importer all spell it `autonumber`, and the set is matched by RAW spelling, so it carried half the type.
-
-The reader that had no gate in front of it is `InlineFieldInput`'s reference fallback, `!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`, on exported public API. A field typed `autonumber` keeps a `reference_to` for relational metadata — which is the entire reason this set exists — so it took the lookup branch and rendered the RECORD PICKER: a searchable list of records offered as replacements for a machine-generated identity. The `auto_number` spelling of the identical field rendered the textual fallback, as intended. Both spellings are now members, matching how `plugin-form` carries both in each of its non-input sets.
-
-The editability half of the same report (objectui#4219) was already closed from another direction by #4228, whose shared exclusion resolves aliases before matching — a field typed `autonumber` offers no inline affordance in either host. The two gates are a union, so this fix also removes the union's dependence on which spelling the metadata happens to use: previously `autonumber` was held by the exclusion gate alone and `auto_number` by both, and losing either gate would have re-opened a different half of the defect depending on how the field was authored.
-
-Pins land with it: the reference fallback for `autonumber` (red before this change — the picker really did render), `auto_number` and a real `lookup` as controls in both directions, and set membership asserted directly so the union statement is checked rather than described.
diff --git a/.changeset/bell-approvals-activity-off-app-4197.md b/.changeset/bell-approvals-activity-off-app-4197.md
deleted file mode 100644
index 8f140601d4..0000000000
--- a/.changeset/bell-approvals-activity-off-app-4197.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-The bell's Approvals and Activity tabs fill in on Home, Organizations and the AI screen — from the same fetch the cards below them already use
-
-The top bar's bell renders on every console surface, but two of the three streams that fill it were gated on `variant === 'app'`: the pending-approvals poll and the `sys_activity` read. Off-app the popover therefore held nothing to show — the Approvals tab read "No pending approvals" and the Activity tab "No recent activity" — on the very page whose own To-do and activity cards were listing both, from the same endpoint and the same object. Neither stream is app-scoped: approvals are scoped to the signed-in user, activity to the tenant. Whichever app happened to be in the URL was never part of either query.
-
-The badge made the inconsistency arithmetic. It is `unreadTopics + pendingApprovalsCount`, and after objectui#4199 un-gated the inbox half, only the first addend was fetched outside an app — so one user with one set of data read 1 on Home and 3 inside an app, and the popover's own breakdown line disagreed with the number on the bell.
-
-Un-gating alone would have fixed the emptiness by paying for it twice: on `/home` the bell and the cards mount in one tree, so each owning its own effect means the approvals request and the `sys_activity` read both go out twice per page. They now share one fetch. A module-scoped store (`hooks/sharedUserFeeds`) owns each feed — one in-flight request, one 30s approvals poll, one 404-retires-the-feature rule — and both the bell and `useHomeInbox` subscribe to it. The dedupe is structural rather than agreed: there is no longer a second producer that could drift, so the badge and the card cannot show different numbers. Home's card keeps its narrower cut of the rows (human actors only, `sys_*`/`ai_*` churn dropped) by filtering the shared feed at its own call site rather than by issuing its own query.
-
-`isApp` keeps the meaning it was introduced for. The presence avatars and the connection dot are app-shell chrome and stay behind it — and presence was never a read to begin with: it is a transport-level subscription (`useTenantPresence`), which is why the effect that used to be called `fetchPresenceAndActivities` only ever fetched `sys_activity`. The boundary this draws is data scope, not surface: user- and tenant-scoped feeds follow the bell everywhere it renders, app-scoped chrome does not.
diff --git a/.changeset/bell-panel-render-oracle-4230.md b/.changeset/bell-panel-render-oracle-4230.md
deleted file mode 100644
index 88a3011743..0000000000
--- a/.changeset/bell-panel-render-oracle-4230.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
----
-
-Internal only — tests and comments, no source change, so no release.
-
-#4230 reported the console bell panel dead again ("No notifications" under both
-Unread and All while `/api/v1/notifications` returned 10 rows) and filed it as a
-regression of #4110 / PR #4199. It is not a regression: the QA build vendored
-console `09987b68` (2026-08-09 02:35:56Z) and #4199 landed as `7b0783232`
-(2026-08-10 20:39:10Z), 42 hours later — `git merge-base --is-ancestor 7b0783232
-09987b68` exits non-zero, and that console's `AppHeader.tsx` still carries the
-`isApp` gate #4199 removed. No production code needed changing.
-
-What the round did expose is a hole in #4199's own pin: every case in
-`AppHeader.inboxVariant.test.tsx` held one row and never touched the popover's
-Unread/All sub-filter, so nothing could distinguish "Unread is empty because
-every row is read" from "the panel was handed nothing" — and the second reading
-under All is the load-bearing half of the reported symptom. This adds eight
-cases driving the QA payload (ten rows, mixed read-state, including the
-`approval.reminder`) through `AppHeader` and `InboxPopover` under both filters.
diff --git a/.changeset/bridge-density-prototype-guard-4442.md b/.changeset/bridge-density-prototype-guard-4442.md
deleted file mode 100644
index ca6060e99b..0000000000
--- a/.changeset/bridge-density-prototype-guard-4442.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@object-ui/react': patch
----
-
-The spec bridge abstains on prototype-member `rowHeight` spellings instead of leaking a
-function into `density`.
-
-`bridgeListView`'s `mapDensity` indexed a plain object literal with an unchecked key, so
-the lookup reached `Object.prototype`. The parameter is typed `RowHeight`, but the
-boundary a host's stored view definition actually crosses is `SpecBridge.transformListView`,
-whose parameter is `any` — so `rowHeight: 'toString'` came back as `Object.prototype.toString`,
-a **function**, out of a read whose return type is three strings or nothing. `bridgeListView`
-then writes the key under `if (density)`, and a function is truthy, so the bad value was not
-merely returned: it was stored on a `SchemaNode` whose renderer expects
-`'compact' | 'comfortable' | 'spacious'`. Same for `constructor`, `valueOf`,
-`hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`.
-
-The lookup is now guarded with `Object.prototype.hasOwnProperty.call(...)` — the same guard
-`@object-ui/core`'s `rowHeightToDensityMode` grew in objectui#4440, and the repo's existing
-convention at eight other sites. Both `rowHeight` surfaces now abstain identically on every
-off-spec **string** spelling, and objectui#4440's agreement pin covers the prototype-member
-family instead of excluding it (objectui#4442).
-
-Runtime-only: no public type moved, and no spec-valid `rowHeight` changes its answer.
diff --git a/.changeset/button-has-type-lint-4045.md b/.changeset/button-has-type-lint-4045.md
deleted file mode 100644
index caa69eab2b..0000000000
--- a/.changeset/button-has-type-lint-4045.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@object-ui/app-shell': patch
-'@object-ui/components': patch
-'@object-ui/console': patch
-'@object-ui/plugin-ai': patch
-'@object-ui/plugin-designer': patch
-'@object-ui/plugin-kanban': patch
-'@object-ui/plugin-map': patch
-'@object-ui/plugin-view': patch
-'@object-ui/react': patch
-'@object-ui/runner': patch
----
-
-Every plain `` now declares its `type`. HTML defaults an untyped button to
-`type="submit"`, so any of these buttons would submit the form it was composed into
-instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
-`navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
-sites were converted to `type="button"`; no site was a genuine submit button, and the
-DOM is otherwise unchanged.
-
-The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
-rule (error), so the next untyped button fails CI at write time rather than being found
-by a fourth audit round (objectui#4045, closing the objectui#3344 family).
diff --git a/.changeset/calm-schools-tease.md b/.changeset/calm-schools-tease.md
deleted file mode 100644
index 99ee16d88f..0000000000
--- a/.changeset/calm-schools-tease.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-'@object-ui/plugin-calendar': patch
----
-
-fix(plugin-calendar): authoring `events` on a `calendar-view` node no longer takes the calendar down
-
-`calendar-view`'s renderer computed a `CalendarEvent[]` from `schema.data`, passed it
-as `events={…}`, then spread the remaining props **after** it. `SchemaRenderer`
-forwards a node's `events` key as a plain prop, so a node authoring `events` — the
-ordinary SDUI action metadata, legal on any node — landed its `{ onClick: [...] }`
-object on the `events` array prop: `CalendarView` iterated it and threw
-`events is not iterable`, and a spec-legal node rendered an error card instead of its
-calendar.
-
-The authored key is now destructured out before the spread, so the computed array
-always wins. This also closes the quiet half of the same collision: an authored
-`events` **array** never threw — it silently replaced the calendar's contents with
-itself.
-
-No capability is removed. Nothing in the renderer layer consumes a node's `events`
-key (the action path is `properties.action` through `ActionRunner`), and the
-component's own `onAction` channel is unaffected.
diff --git a/.changeset/chatmessage-one-contract-4383.md b/.changeset/chatmessage-one-contract-4383.md
deleted file mode 100644
index a30268db6a..0000000000
--- a/.changeset/chatmessage-one-contract-4383.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/plugin-chatbot': minor
----
-
-`@object-ui/plugin-chatbot`'s `ChatMessage` is now one type instead of two
-
-The barrel exported two different `ChatMessage` types: a minimal one it declared itself (`id` / `role` / `content` / `timestamp` / `avatar` / `avatarFallback`) and the shape `` actually renders, re-exported under the alias `ChatbotEnhancedMessage`. The natural name resolved to the narrow one, so an importer reaching for `ChatMessage` silently got the wrong contract — and the compiler could not object, because both shapes existed on purpose and every construction site spreads the extra keys conditionally, which defeats excess-property checking. That is how app-shell's `AiChatPage` ended up unable to read `toolInvocations` off its own function's return value (objectui#4040; re-pointed in PR #4379, but the collision itself was left standing). objectui#4383.
-
-**Breaking semantics** (declared `minor` per AGENTS.md §版本号策略 — objectui never declares `major` outside an `@objectstack` major sync): `ChatMessage` exported from `@object-ui/plugin-chatbot` now denotes the enhanced shape. In practice this is a widening rather than a removal — every field of the retired shape survives with the same type, and the enhanced shape adds only optional keys (`streaming`, `toolInvocations`, `reasoning`, `sources`, `traceId`, `buildProgress`, `blueprintProgress`, `charts`), so anything that was a valid `ChatMessage` still is, and ` ` keeps accepting the same values. Code that relied on the name meaning *exactly* the six-key shape (exhaustive `keyof` maps, `Equal`-style assertions) is the case that changes.
-
-`ChatbotEnhancedMessage` is kept as a `@deprecated` alias of the same type, so importers that spelled the disambiguating name keep compiling; new code should import `ChatMessage`. Pinned at compile time by `packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts`.
diff --git a/.changeset/chatmessage-seam-adapter.md b/.changeset/chatmessage-seam-adapter.md
deleted file mode 100644
index 9a6c9a8ae7..0000000000
--- a/.changeset/chatmessage-seam-adapter.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@object-ui/plugin-chatbot': patch
----
-
-Replace the three `messages as any` casts at the `@object-ui/types` ↔
-`@object-ui/plugin-chatbot` `ChatMessage` boundary with one explicit typed
-adapter (`toRuntimeMessages` / `authoredToRuntimeMessage`, now exported).
-
-The authoring contract (`ChatbotSchema['messages']`) and the runtime contract
-`` renders are both deliberate and deliberately different; the
-casts erased ALL of that drift rather than the intentional parts, so a future
-vocabulary move would have surfaced as rendering behaviour instead of a type
-error. Each narrowing is now named, documented and tested: an authored
-`role: 'tool'` message is an assistant message (unchanged rendering — the
-implicit fallthrough is now the recorded decision), a `Date` timestamp becomes
-its ISO string (one expression, consumed by both the seam and the hook's
-`normalizeMessages`), and the legacy tool-invocation states
-`'partial-call'`/`'call'`/`'result'` map to their AI SDK v6 equivalents as the
-authoring type's own documentation declares — previously they reached the tool
-chip unrecognised and rendered a status badge with no label.
diff --git a/.changeset/combo-dataset-path-presentation-merge-4229.md b/.changeset/combo-dataset-path-presentation-merge-4229.md
deleted file mode 100644
index 6be6557e8f..0000000000
--- a/.changeset/combo-dataset-path-presentation-merge-4229.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/plugin-dashboard': patch
----
-
-Dashboard `combo` widgets draw as combos on the dataset path — the dataset owns the data, the author owns the presentation
-
-A widget authoring the spec's own combo shape — `series[].type` plus `series[].yAxis: 'left'|'right'` and two `yAxis` entries — rendered as two bar series on one shared axis. Measured in the DOM: 2 bars, 0 lines, 1 y-axis, where 1 bar, 1 line and 2 axes were authored, so a percentage measure was plotted against a raw count's scale.
-
-Two halves caused it, and fixing either alone leaves a worse state than before. `CHART_TYPE_MAP` had no `combo` entry, so a `combo` widget fell through its `?? 'bar'` default — bars, whatever the series said. And `chartConfigPresentation` refused to forward `series` / `xAxis` / `yAxis` at all, on the stated grounds that they are derived from the dataset selection, so the per-series mark and the axis binding could never reach the renderer even once the family resolved.
-
-That belief was half right. The dataset does own the series MEMBERSHIP — which columns become series, which rows, which buckets — and it still does: an authored entry naming a measure the dataset did not select is ignored, and a derived series the author said nothing about keeps the family default. What the dataset never owned is the PRESENTATION carried on those same objects: the per-series mark, its left/right axis binding, label, colour, stack, and the axis definitions' title, format, min, max, step, grid and position. Those are the author's, and they now merge onto the derived bindings by name/key match with the explicit binding winning — one merge function, not a spread per attribute. The split runs through the two binding keys: `ChartSeries.name` and `ChartAxis.field` name a column and stay with the dataset; everything else on the object travels.
-
-This is objectui#2880's S2 rule, which PR #2883 landed in `ObjectChart` and which the dataset path never carried over. Dropping `ChartAxis.field` on the way through is what makes forwarding the axes safe rather than merely guarded: it is the one key by which an authored axis could have named a series, since the renderer synthesises series from `yAxis[].field` when a chart declares none.
-
-Two consequences beyond the reported bug. A non-combo widget can now declare one line series and get the combo the renderer already knew how to derive from disagreeing series types. And a `compareTo` overlay inherits its own measure's mark and axis, so the comparison of a bar-on-the-left measure no longer draws as a line on the right the moment the chart becomes a combo.
-
-Dashboards that never authored `chartConfig.series` or `chartConfig.yAxis` emit exactly what they emitted before.
diff --git a/.changeset/commit-store-unreachable-copy-3529.md b/.changeset/commit-store-unreachable-copy-3529.md
deleted file mode 100644
index b41a26d9c0..0000000000
--- a/.changeset/commit-store-unreachable-copy-3529.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/app-shell': patch
-'@object-ui/i18n': patch
----
-
-The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
-
-`packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
-
-Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a *second* revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
-
-Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is *withheld*: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
-
-Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
diff --git a/.changeset/components-no-implicit-any-4353.md b/.changeset/components-no-implicit-any-4353.md
deleted file mode 100644
index a818ba7bce..0000000000
--- a/.changeset/components-no-implicit-any-4353.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/components': patch
----
-
-`@object-ui/components` compiles under `noImplicitAny` — the workspace's last strict-relaxing package
-
-`packages/components/tsconfig.json` carried `"noImplicitAny": false`, the only place in the workspace that relaxed a `strict` sub-flag, under a comment that explained the neighbouring `rootDir` removal rather than the flag itself. `tsconfig.test.json` mirrored the one flag deliberately, so that a test project could not become the compiler of record for a source strictness decision the build config owns. Both now simply inherit `strict: true` from the root config, and the mirror's reasoning is rewritten to record why the mirror is gone rather than deleted silently.
-
-Turning the flag on reported 26 implicitly-`any` sites in five renderer source files and 2 in the package's own tests, all of which now have real types. Nothing about the runtime changed; every one of the package's 1077 tests passes untouched.
-
-Two of those signatures were typed by measurement rather than by preference, and both are worth recording:
-
-The ten `sidebar.tsx` entry points follow the convention the package's other registered renderers already use — an inline `{ schema: Schema; [key: string]: any }` annotation naming the registered component's own schema type (21 occurrences across the renderer tree, against zero uses of `ComponentRendererProps`). Only `'sidebar'` itself has a schema type in the registry map; the other ten registrations are sidebar *parts* with none of their own, so they take `BaseSchema`, the type every registered node satisfies. Annotating them `SidebarSchema` would have asserted `type: 'sidebar'` on a node whose type is `'sidebar-header'`.
-
-The action renderers' callbacks are typed from `UIActionSchema`, not the legacy `ActionSchema` those three files import for their declarations. The legacy interface (`crud.ts`, already `@deprecated`) has no `locations`, so the shared `actionRendersAt` placement predicate rejects it outright; its `variant` union has no `'primary'`, the value the objectui#2339 ordering tie-break compares against; and its `type` is the literal `'action'`, while the actions actually flowing through these renderers carry `'form' | 'script' | 'url' | 'flow' | 'api' | 'modal'`. `action:bar`'s own documented example is a `UIActionSchema`. None of this was checkable before, because the props type never reached the callbacks at all: `forwardRef` routes props through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature, so `schema` arrived as `any` and every callback under it inferred `any` too. The fix annotates each action list once where it enters and lets the `filter`/`some`/`map` chains below infer.
-
-Graded `patch`: no declaration this package publishes changes shape. The three action schema interfaces and the leaf components whose props moved to `UIActionSchema` are internal — none is re-exported from `src/index.ts`. The `actions?: ActionSchema[]` keys those interfaces still declare remain on the legacy type; reconciling that declaration with the type the implementation actually receives reaches roughly 46 sites across 12 files and is filed separately.
diff --git a/.changeset/components-type-checks-its-tests.md b/.changeset/components-type-checks-its-tests.md
deleted file mode 100644
index 151ceab484..0000000000
--- a/.changeset/components-type-checks-its-tests.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
----
-
-chore(components): `@object-ui/components` type-checks its whole test tree.
-
-No published code moves. The package gains a `tsconfig.test.json` chained from
-`type-check`, its 34 code-tier test errors are fixed in the tests themselves,
-and the narrow `tsconfig.typetests.json` — the rescue hatch for a package still
-in `TEST_DEBT` — is retired now that the full project compiles the same file
-(objectui#4040 tranche 4, under objectui#4291's ratchet). The remaining
-`TEST_DEBT` counts for `core` and `app-shell` are corrected to the tranche-4
-remeasurement.
diff --git a/.changeset/core-app-shell-type-check-their-tests.md b/.changeset/core-app-shell-type-check-their-tests.md
deleted file mode 100644
index 2f44c0a801..0000000000
--- a/.changeset/core-app-shell-type-check-their-tests.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
----
-
-chore(core,app-shell): `@object-ui/core` and `@object-ui/app-shell` type-check their whole test trees.
-
-No published behaviour moves. Each package gains a `tsconfig.test.json` chained
-from `type-check`, their 56 and 62 code-tier test errors are fixed, and the
-narrow `tsconfig.typetests.json` rescue hatches — for packages still in
-`TEST_DEBT` — are retired now that the full projects compile the same files
-(objectui#4040 tranche 5, under objectui#4291's ratchet). Three small source
-corrections ride along, each one a declaration that was narrower than the
-implementation it described: `ConsoleActionRuntime.actionProviderProps` is now
-derived from `ActionProviderProps` instead of restating it (the restatement had
-dropped `onModal` and declared one-parameter handlers), `apiHandler` declares the
-`context` parameter it has always taken, and `AiChatPage` imports the enhanced
-chat-message type it actually produces rather than the minimal legacy one.
diff --git a/.changeset/currency-iso-4217-minor-units-4361.md b/.changeset/currency-iso-4217-minor-units-4361.md
deleted file mode 100644
index 75aa188c36..0000000000
--- a/.changeset/currency-iso-4217-minor-units-4361.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-'@object-ui/fields': patch
----
-
-Currency amounts now follow each currency's own ISO 4217 fraction-digit
-convention instead of a hardcoded 2 (objectui#4361).
-
-Both currency formatting paths in `@object-ui/fields` picked a fraction-digit
-width and handed it to `Intl.NumberFormat`, which OVERRIDES the digit count
-`Intl` already knows for the currency being rendered. `formatCurrency` derived
-its width from the VALUE's wholeness alone (`isWhole ? 0 : 2` — a literal 2 for
-every currency on earth), and `CurrencyField` defaulted an undeclared
-`precision` to the same literal. So a yen amount was printed with cents the
-currency does not have and a dinar amount with one digit fewer than it does:
-
-| | before | after |
-| --- | --- | --- |
-| JPY `1234.5` | `¥1,234.50` | `¥1,235` |
-| KWD `1.5` | `KWD 1.50` | `KWD 1.500` |
-| CLP `1234.5` | `CLP 1,234.50` | `CLP 1,235` |
-| BHD `2.5` | `BHD 2.50` | `BHD 2.500` |
-| USD `1234.5` | `$1,234.50` | `$1,234.50` |
-| USD `1234` | `$1,234` | `$1,234` |
-
-Both call sites now derive the width from the currency itself
-(`Intl.NumberFormat(undefined, { style: 'currency', currency })
-.resolvedOptions().maximumFractionDigits`, memoized per code) and switch
-wholeness against THAT.
-
-**The whole-number convention is extended, not retired.** Simply dropping both
-bounds and letting `Intl` decide would have fixed the digit count while turning
-`$1,234` back into `$1,234.00` — the Salesforce convention `formatCurrency`
-documents and objectui#4033 pinned. A whole amount still drops the fraction, now
-for every currency: `KWD 1` renders `KWD 1`, not `KWD 1.000`. Two-decimal
-currencies are byte-identical to before, which is why the objectui#4033 and
-objectui#4332 pins pass unchanged.
-
-**On `CurrencyField`, an explicitly authored `precision` still wins** — it is
-authored metadata and authored metadata keeps priority, so a JPY field declaring
-`precision: 2` still renders `¥1,234.50`. Only an ABSENT `precision` derives from
-the currency; because that derivation is the widget's one precision, it also
-reaches the spinner `step` and the blur rounding, so a JPY field no longer offers
-a `0.01` step for a currency with no minor unit. Whether a declared `precision`
-that contradicts the currency's ISO 4217 digits should be REJECTED at publish
-time is a contract question, filed upstream in `@objectstack/spec` rather than
-answered here by overriding the author.
-
-Reachable wherever the resolved currency is not a 2-decimal one — the field's
-`currency`, `currencyConfig.defaultCurrency`, or the tenant default (ADR-0053).
diff --git a/.changeset/curvy-donkeys-shave.md b/.changeset/curvy-donkeys-shave.md
deleted file mode 100644
index 4156f6f0d6..0000000000
--- a/.changeset/curvy-donkeys-shave.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-'@object-ui/fields': patch
----
-
-fix(fields): `formatCurrency` keeps both cents digits on a fractional amount
-
-The symbol branch passed `minimumFractionDigits: 0` against a
-wholeness-switched `maximumFractionDigits`, which handed `Intl` the range
-`[0, 2]` — and `Intl` emits the shortest representation in range, so a real
-cents value of `.50` was printed as `.5`. Any price ending in a zero cent digit
-rendered one digit short: `$1,234.50` as `$1,234.5`, `$19.90` as `$19.9`,
-`$0.50` as `$0.5` — money on a record page and in grid cells reading as a data
-error rather than a formatting one.
-
-Both bounds now take the same wholeness-switched width, so the function
-delivers the contract its own doc comment states: a fractional amount shows
-exactly two digits, a whole amount still drops `.00` (`$1,234`). The
-no-currency branch and the bad-currency fallback already behaved this way; only
-the symbol branch disagreed.
-
-Reaches every consumer of the shared helper: `CurrencyCellRenderer`,
-`ObjectGrid`, the dashboard `recordFields` and `ObjectGantt`.
diff --git a/.changeset/data-table-row-menu-predicate-derive-4354.md b/.changeset/data-table-row-menu-predicate-derive-4354.md
deleted file mode 100644
index 85884b0261..0000000000
--- a/.changeset/data-table-row-menu-predicate-derive-4354.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/components': patch
----
-
-data-table row menu — the built-in Edit/Delete predicate parameters are derived from the authoring type, not hand-restated
-
-One authoring shape (`DataTableSchema.rowEditPredicates` / `rowDeletePredicates`, objectui#2614) had grown four separate declarations in `renderers/complex/data-table.tsx`: the shared `isBuiltinRowActionVisible` gate and `planDataTableRowMenu` each hand-wrote `{ visibleWhen?: unknown }`, and the row-menu ITEM component hand-wrote the full `{ visibleWhen?: unknown; disabledWhen?: unknown }` pair. Nothing tied any of them to the type whose values they receive, so a rename in `@object-ui/types` would have left all four compiling against a shape that no longer existed — the objectui#3009 hand-copy family, in miniature.
-
-Each one now derives. The planner keeps its deliberate visibility-only subset, `Pick`ed from the very key its caller passes (`Pick, 'visibleWhen'>` and the delete twin), so the signature still tells the truth about what the function reads while becoming structurally unable to drift from what it subsets. The two consumers that serve both built-ins share one derived alias taken from the union of the twins, so they may only read keys both schema keys declare. Measured: with `visibleWhen` renamed in `@object-ui/types`, the previous hand-written declarations still type-check clean, the derived ones fail to compile.
-
-No behavior change — no runtime code was touched, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed.
diff --git a/.changeset/dataset-result-field-derive-3815.md b/.changeset/dataset-result-field-derive-3815.md
deleted file mode 100644
index db26c5ad87..0000000000
--- a/.changeset/dataset-result-field-derive-3815.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/core': minor
----
-
-`DatasetResultField` is now `@objectstack/spec`'s `AnalyticsResult.fields[]` element itself, not a hand-written restatement of it
-
-`packages/core/src/utils/dataset-format.ts` declared its own six-key interface for the analytics result column, under a doc comment describing the server's contract. The key set happened to match the spec today, so nothing was broken — but it was the last surviving member of the derive-don't-restate family (#3613 / #3753 on the parameter side, #3752 on the adapter return side), and it was the member with no compile-time tripwire: three surfaces (`plugin-dashboard`'s `DatasetWidget`, `plugin-report`'s `DatasetReportRenderer`, app-shell's `DatasetPreview`) consume this name AS the real column type, so the next spec column key would simply never appear here and no build would complain. It is now `AnalyticsResult['fields'][number]`, so it cannot lag the contract again.
-
-**Consumer-visible type tightening (the reason this is a minor, not a patch).** The restatement had relaxed `type` to optional; the contract requires it. Anything that assigned a column literal without `type` — or a bare `{ name, label?, format? }` — to `DatasetResultField` will now fail to compile, and the fix is to supply the `type` the server always sends. Nothing in this repo needed changing: every value of this type originates in `ObjectStackAdapter.queryDataset`, which already declares the spec element, and no consumer reads `.type` at all, so the widening had bought no caller anything while advertising a `string | undefined` the wire never produces. Marked `minor` per the repo's bump policy, which reserves `major` for following `@objectstack/spec` across a major.
-
-The exported name is unchanged and the `PercentScale` re-export from this module is untouched, so existing import paths keep working. `packages/core/tsconfig.typetests.json` (chained off the package's `type-check`) compiles the new parity test, so the pins are checked by CI rather than merely written down — including a negative pin that goes red if the hand-written interface is ever restored, and the `ChartResultField` superset relationship the module's comment claims.
diff --git a/.changeset/datasetwidget-option-color-probe-apifetch-4121.md b/.changeset/datasetwidget-option-color-probe-apifetch-4121.md
deleted file mode 100644
index 17c6e1d45b..0000000000
--- a/.changeset/datasetwidget-option-color-probe-apifetch-4121.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-"@object-ui/plugin-dashboard": patch
----
-
-`DatasetWidget`'s option-color / dimension-label probe now rides the host's
-authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
-`fetch`.
-
-The one metadata read the effect makes — `GET /api/v1/meta/object/{object}` — went
-out on the global `fetch`, so in a hosted console it skipped whatever the host
-supplies on that channel (Authorization / tenant headers, base-URL rewrite,
-draft-preview params). A bearer-token session carries its credential in a header
-rather than a cookie, so `credentials: 'include'` alone left this read
-unauthenticated. The effect is best-effort and swallows every failure, which made
-the symptom silent: a dataset chart's semantic per-category colors and its
-dimensions' value → label maps simply never applied, and the widget fell back to
-the positional theme palette and the raw stored values on the axis.
-
-Standalone embeds are unaffected — with no provider (or a provider that supplies no
-`apiFetch`) the probe still uses the global `fetch`, the same documented fallback
-`useRecordEditable` and `provider: 'api'` view sources use.
-
-This is the `plugin-dashboard` twin of the same fix made to `plugin-charts`'
-`ObjectChart`.
diff --git a/.changeset/datasource-preview-strict-key-groups-4131.md b/.changeset/datasource-preview-strict-key-groups-4131.md
deleted file mode 100644
index 0e787b329a..0000000000
--- a/.changeset/datasource-preview-strict-key-groups-4131.md
+++ /dev/null
@@ -1,24 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-`DatasourcePreview` no longer renders three key groups `DatasourceSchema` rejects
-(objectui#4131). `retryPolicy`, `healthCheck` and `capabilities` were removed from the
-datasource document by objectstack#4583 under ADR-0049 enforce-or-remove — connection
-retry and health probing belong to the runtime driver, and pushdown is decided by that
-driver's own `supports.*`, never by datasource metadata. The schema is `.strict()`, so it
-refuses all three by name, while the preview kept painting a `Retry Policy` SideBlock, a
-`Health Check` SideBlock and a `Capabilities` chip strip for them. An author who typed any
-of the three saw the designer confirm a draft that cannot be saved — the preview was the
-only surface acknowledging the keys at all, so it was also the strongest signal they
-worked. `pool` and `ssl` are still declared and still render.
-
-This is the third wave of the same defect on this one file (objectui#3275 deleted
-`d.type` / `isDefault` / the `Array.isArray(capabilities)` branch; objectui#3143 deleted
-the read-replica pill), and the removals had been in `main` eight days before anyone
-noticed — nothing compared the two halves of the contract. They are now compared
-mechanically: `DatasourcePreview.spec-keys.test.ts` derives the keys the preview reads off
-the draft from the component's own AST, derives the keys the schema accepts from the
-schema object's `.keyof()`, and fails on any read the schema would reject. Neither side is
-written down as a list, so a key added or removed in `@objectstack/spec` moves the pin on
-the next dependency bump instead of leaving it stale.
diff --git a/.changeset/dead-currency-symbol-map.md b/.changeset/dead-currency-symbol-map.md
deleted file mode 100644
index bfdce3e343..0000000000
--- a/.changeset/dead-currency-symbol-map.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/fields': patch
----
-
-fields: the currency adornment has one symbol channel
-
-`CurrencyField` carried the same one-entry fact twice — a dead `CURRENCY_SYMBOLS`
-map that nothing read, and a live `currency === 'USD' ? '$' : currency` ternary
-two lines below it. Both were hand copies of knowledge `Intl` already carries,
-and both are gone: a new `currencySymbol(currency, locale)` beside
-`currencyFractionDigits()` reads the `currency` part of the very format the
-widget's readonly branch already renders amounts with.
-
-USD is unchanged at the display-locale default. Other currencies now show their
-real symbol instead of the bare ISO code — `€` for EUR, `¥` for JPY, `£` for
-GBP — which is what the same widget's readonly mode has always displayed; the
-edit adornment simply stopped disagreeing with it. Currencies CLDR has no symbol
-for (KWD, BHD, CHF, ISK, CLP) still render their code, exactly as before.
diff --git a/.changeset/dead-surface-deletions-batch3-4328.md b/.changeset/dead-surface-deletions-batch3-4328.md
deleted file mode 100644
index c28fa5dd21..0000000000
--- a/.changeset/dead-surface-deletions-batch3-4328.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-'@object-ui/types': minor
-'@object-ui/core': minor
-'@object-ui/react': minor
-'@object-ui/data-objectstack': patch
----
-
-Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
-measured as declared-but-never-read at the branch point, and each is removed rather than
-left as an authoring surface whose values nothing acts on.
-
-Breaking for anyone who typed against the removed declarations, marked `minor` per this
-repository's version-alignment convention (the major tracks `@objectstack`, never an
-API-break count):
-
-- `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
- behind by the move of that step to the provider layer, and it had drifted: it ignored a
- view container's default `list` and keyed views by the authored bare key instead of the
- composer's `.` identity. The live implementation — `MetadataProvider`'s, in
- `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
-- `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
- live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
- consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
- field was *required*, so five fixtures across three packages had been declaring an empty
- array for a value nothing would ever look at. Role-attached grants are now a compile
- error rather than silently ignored data. (#4288)
-- `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
- had zero producers and zero consumers — no host passed them, no `record:*` renderer read
- them — and only the provider's memo dependency list still named them. Record-level
- loading and error state stays where it is actually expressed: each renderer's own data
- source. (#3773)
-
-No behaviour change, no request-count change:
-
-- `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
- calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
- path has ever populated that key — `listViews` fetches directly, uncached — so all five
- were permanent no-ops. The invalidations of the keys that do have readers
- (`view::` for `getView`, `view-overrides:` for
- `listViewOverrides`) are untouched and now pinned. (#3778)
diff --git a/.changeset/dead-surface-pair-4364-4368.md b/.changeset/dead-surface-pair-4364-4368.md
deleted file mode 100644
index 743bc77f8e..0000000000
--- a/.changeset/dead-surface-pair-4364-4368.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-'@object-ui/types': minor
-'@object-ui/permissions': minor
-'@object-ui/console': patch
----
-
-Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
-branch point rather than taken from their cards, and one card's premise only half held.
-
-Breaking for anyone who typed against the removed declaration, marked `minor` per this
-repository's version-alignment convention (the major tracks `@objectstack`, never an
-API-break count):
-
-- `@object-ui/types` and `@object-ui/permissions` no longer export
- `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
- (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
- read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
- referents were its own definition and the two barrel lines. The wired home is
- `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
- what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
- now records the retirement so the surface is not re-declared. (#4364)
-
-`PermissionCondition` was proposed for retirement on the same card and is **kept**: its
-premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
-branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
-type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
-is likewise untouched — `FieldLevelPermission.effect` still reads it.
-
-No behaviour change, no public surface change:
-
-- `@object-ui/console` drops `src/utils/metadataConverters.ts` and
- `src/services/MetadataService.ts`. Both were console-local duplicates of live
- `@object-ui/app-shell` modules and lost their last importer when the bespoke
- object-detail widgets were retired (#4365). Both had already drifted behind the live
- copies they duplicate — the console converter's `referenceTo` chain never read the
- server's `reference` key, and the console service predates the view cache-invalidation
- seam (#4373) — which is precisely the imitation trap the card recorded: an author
- grepping for "the converter" could land on the unexercised copy. The app-shell copies
- and their tests are untouched. (#4368)
diff --git a/.changeset/declared-actions-bar-drop-predicate-casts.md b/.changeset/declared-actions-bar-drop-predicate-casts.md
deleted file mode 100644
index 48ead4a244..0000000000
--- a/.changeset/declared-actions-bar-drop-predicate-casts.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-"@object-ui/app-shell": patch
----
-
-`DeclaredActionsBar` reads `visible` / `disabled` off the typed action def instead of through `(action as any)`.
-
-The `disabled` cast was the one the maintainer's 2026-08-06 ruling on
-objectstack#4075 named: `ActionDef.disabled` was hand-written as
-`string | boolean` and could not describe the `{ dialect, source }` envelope the
-spec emits, so the bar had to reach around the type to evaluate it. With both
-keys now derived from the spec's unified three-arm shape (`@object-ui/core`, step
-3) there is nothing left to reach around. The adjacent `visible` casts go with
-them for the same reason — step 2 declared `visible` and deleted `ActionEngine`'s
-equivalent casts, but missed this file's.
-
-No behaviour change: `toPredicateInput` and `hasDeclaredPredicate` both take
-`unknown`, so the casts only ever affected whether the property access compiled,
-never which verdict it produced.
diff --git a/.changeset/declared-actions-bar-predicate-helper-4080.md b/.changeset/declared-actions-bar-predicate-helper-4080.md
deleted file mode 100644
index f9852ee531..0000000000
--- a/.changeset/declared-actions-bar-predicate-helper-4080.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-"@object-ui/app-shell": patch
----
-
-`DeclaredActionsBar` binds its row through the shared `usePredicateRecordContext`
-
-The bar held an inline copy of the three-way row binding — `{ ...row, record: row, data: row }`, added by objectui#4077 to fix its root-only predicate scope. objectui#4079 fixed the same fault on the four generic action renderers and gave the rule one name instead of a fifth copy: `usePredicateRecordContext`, exported from `@object-ui/react` beside `useCondition`. The bar now calls it.
-
-No verdict changes on any row: the two copies agreed wherever a row exists, which is every surface that mounts this bar today. They differed in one corner, and the helper's semantics are the ones kept — with **no** row, the bar now binds nothing where it used to bind `{ record: {}, data: {} }`. Since `useCondition` merges this context over the ambient predicate scope, the old shape blanked out a `record` a host had put in the scope itself; "this surface has no row" and "this surface's row is empty" are now distinct here too.
-
-Two implementations of one binding rule is what objectui#3367 / #3842 rule against, and this family already paid for it once at the `toPredicateInput` level (objectui#3314: two normalizations drifted, and the same `visible:` predicate reached different verdicts depending on which path surfaced the action). Nothing had drifted here yet — the next edit to the rule is what this closes off.
diff --git a/.changeset/default-app-landing-and-real-app-writes-4233.md b/.changeset/default-app-landing-and-real-app-writes-4233.md
deleted file mode 100644
index fa1a1057dc..0000000000
--- a/.changeset/default-app-landing-and-real-app-writes-4233.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/console': minor
----
-
-An unloadable app list no longer lands you on `/home` as though you had no default app — and the Applications page's Set-as-default / Disable / Delete now actually write
-
-Two defects on the same journey, reported together because the second is why the first had no workaround.
-
-**The landing.** `resolveLandingPath` reads a list of apps, and an empty list is a legitimate input with a legitimate answer: rule 3 sends you to `/home`, the multi-app launcher. What it could not see is *why* the list was empty. A failed `GET /meta/app` produces exactly the same `[]` — `MetadataProvider.ensureType` catches and resolves an empty array, so nothing rejects and `loading` goes false — and the resolver then reported "this deployment has no default app" about a deployment it never managed to ask. The wrong answer did not stay soft, either: `Navigate … replace` rewrites `/` to `/home` in history, so a reload re-enters at `/home` and the `isDefault` branch never gets a second chance; if the session also turns out to be dead, the auth guard captures `/home` into `?redirect=%2Fhome` and honors it after sign-in. An error-state fallthrough should never be fossilized as user intent.
-
-`/` now resolves a landing only from an app list that is an *answer*. The distinguishing fact already existed on the metadata context and is used as-is — `getTypeStatus('app')`, the provider's own per-type load status — so no second dialect of loading or auth state is introduced, and the landing policy itself is untouched: every existing rule, including the empty-list fallthrough and the Setup-only case, still resolves exactly as before when the list genuinely loaded. While the list is unknown the console holds at `/` and re-asks the metadata layer once, so a transient failure heals on its own and a real outage settles on a screen that is at least not a claim about which apps exist.
-
-The originally reported chain had an earlier link that is already closed: before objectui#4042 the `/` route mounted the resolver with no guard above it, so an unauthenticated visitor ran the whole resolution against a list emptied by a 401. That entry is now guarded and the bare `/` is deliberately not captured as a redirect target, and both facts — plus the legitimate deep-link capture that must keep working — are pinned here for the first time.
-
-**The Applications page.** Set as default, Disable (and the bulk toggle) and Delete each showed a success toast having issued no request at all, then called `refresh()` — which re-rendered the unchanged server state underneath the confirmation, and is what made the stubs read as a working feature. Their `// TODO: Replace with real API call when backend supports app management` was measured against `@objectstack/client` 17.0.0-rc.6 and its premise is false: the write surface exists, is gated on `manage_metadata`, and is already how app schemas are persisted elsewhere in this console. All four handlers now await a real mutation and report success only afterwards; a refusal surfaces the server's own message. Set-as-default demotes the outgoing default before promoting the new one, so the landing cannot depend on the order the server lists apps in, and the bulk toggle counts the writes that actually landed rather than the size of your selection.
diff --git a/.changeset/designer-table-ellipsis-4377.md b/.changeset/designer-table-ellipsis-4377.md
deleted file mode 100644
index 8ff41dfba7..0000000000
--- a/.changeset/designer-table-ellipsis-4377.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-fix(app-shell): the metadata-admin designer's own i18n table uses the typographic ellipsis
-
-The designer carries its own flat `en`/`zh` table rather than reading the ten locale
-packs, so objectui#3878 — which converged those packs on U+2026 `…` per the
-consistency pass on objectstack#6015 — left it behind. Ten values across five keys
-(`engine.form.select`, `.selectObjectDots`, `.addObjects`, `.selectFieldDots`,
-`.addFields`) still ended in three ASCII full stops, and the designer renders inside
-the console shell, so `Select...` sat on screen beside the packs' `Select…`.
-
-All ten now use `…`, and the table's header records the convention for the next
-author.
diff --git a/.changeset/designer-table-key-collapse-4387.md b/.changeset/designer-table-key-collapse-4387.md
deleted file mode 100644
index 620e0025b8..0000000000
--- a/.changeset/designer-table-key-collapse-4387.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-refactor(app-shell): collapse the metadata-admin designer table's byte-identical key pairs
-
-objectui#4377 converged five designer i18n values on the typographic ellipsis `…`. Three
-of the five then held values byte-identical to a sibling key in both the `en` and the `zh`
-block, so the `Dots` suffix — which used to name "the spelling *with* trailing dots" —
-named nothing any more, and one key had no consumer at all:
-
-- `engine.form.selectObjectDots` → collapsed onto `engine.form.selectObject`
-- `engine.form.selectFieldDots` → collapsed onto `engine.form.selectField`
-- `engine.form.select` → deleted (no call site; byte-identical twin of the live
- `engine.form.selectEllipsis`)
-
-The two live `Dots` call sites in `widgets.tsx` now read the surviving key. Every rendered
-string is unchanged — the collapsed values were byte-identical — so this is a table-shape
-change only: the designer no longer names one placeholder twice, which is where the next
-author would otherwise re-split the dialect.
diff --git a/.changeset/dotted-dimension-labels-table-pivot-4263.md b/.changeset/dotted-dimension-labels-table-pivot-4263.md
deleted file mode 100644
index ba397d4dcf..0000000000
--- a/.changeset/dotted-dimension-labels-table-pivot-4263.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-'@object-ui/plugin-dashboard': patch
----
-
-fix(dashboard): resolve a dotted dimension's labels on table and pivot dataset widgets
-
-A dataset widget's client-side dimension-label safety net returned early for
-`table` / `pivot` / metric widgets, so a DOTTED dimension (`crm_account.industry`)
-rendered the raw stored enum (`education`) there — the same symptom objectui#4053
-fixed for charts, on the widget types its fix did not reach.
-
-The early return stays for LOCAL dimensions, which is what made it correct in the
-first place: on a table the server resolves those labels (ADR-0021), so running
-the client net for them would be a second resolution of an already-resolved
-value. It now opens only for dotted paths — the case the server is silent on too —
-reusing the existing `resolveDimensionFieldOptions` walk unchanged, multi-hop
-paths included. A table with no dotted dimension resolves nothing and issues no
-metadata read at all, so those widgets render byte-identically.
-
-A pivot's marginal totals take the same relabel as its rows, because their bucket
-ids are re-derived from the dimension values that the headers are built from; the
-CSV export follows the table's cells for the same reason. Drill-through still
-filters by the stored value — the relabel preserves row order and count, so the
-raw rows it indexes stay aligned.
-
-Metric widgets are unaffected by design: that branch renders one measure value
-and its header label and puts no dimension value on screen, so it has nothing to
-resolve.
diff --git a/.changeset/dotted-dimension-option-labels-4053.md b/.changeset/dotted-dimension-option-labels-4053.md
deleted file mode 100644
index 6055fa1d13..0000000000
--- a/.changeset/dotted-dimension-option-labels-4053.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@object-ui/core': patch
-'@object-ui/plugin-dashboard': patch
-'@object-ui/plugin-charts': patch
----
-
-A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
-
-A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
-
-The label lookup read options as `baseObject.fields[]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
-
-The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
-
-Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
-
-Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
-
-Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
diff --git a/.changeset/element-button-forward-excess-property-check-4321.md b/.changeset/element-button-forward-excess-property-check-4321.md
deleted file mode 100644
index 6c56132c2b..0000000000
--- a/.changeset/element-button-forward-excess-property-check-4321.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/components': patch
----
-
-`element:button`'s action forward is excess-property checked again — a misspelled key on that payload is now a compile error, not silence
-
-The payload `element:button` hands to `execute(…)` closed with `as any`. An assertion asks only for comparability, so it switched TypeScript's excess-property (freshness) check off for the whole literal: all sixteen keys the renderer forwards rode unchecked, and `ActionDef` being a closed type (objectui#4046) bought this surface nothing. A typo added to that list — `refreshAftr`, `confirmTxt` — would have compiled, published, and reached a runner that silently does nothing, which is the objectstack#2169 "Mark Done does nothing" shape the closed type exists to prevent.
-
-The exemption that recorded this assumed the cast was load-bearing, on the reasoning that `element:button` receives a bare `action?: Record< string, any >` prop rather than a typed action, so removing the cast would be a contract change. Measured on TypeScript 6.0.3, that was wrong on the point that mattered: dropping the cast type-checks clean as-is, because every key the literal writes is already declared on `ActionDef`. The prop's type is a separate question and is deliberately untouched here — the forward literal never needed the cast to compile.
-
-Two literals needed it, not one. The `execute(…)` argument is contextually typed by `execute(action: ActionDef)`, so dropping the cast is enough for the explicit keys. The `paramsPayload` binding spread into it is the second, easily-missed half: a spread source's own keys are not checked *through* the spread, so `{ actionParams }` / `{ params }` could still invent a key while the payload around them was checked. That binding is now annotated `ActionDef` too.
-
-Runtime behaviour is unchanged — the object reaching `execute` is identical, key order included, and the emitted `.d.ts` does not move. What changes is that the compiler now rejects an invented key here, verified in both directions: the same probe key produces `TS2353` after this change and no diagnostic at all before it.
-
-With this surface fixed, `check:action-forward-parity` has no payloads exempt from its freshness rule: all five action-forwarding renderers write into a literal the compiler checks, and the gate's ratchet removed the exemption entry itself as designed.
diff --git a/.changeset/environment-deeplink-arm-on-create-action-4123.md b/.changeset/environment-deeplink-arm-on-create-action-4123.md
deleted file mode 100644
index ffe7c12ab3..0000000000
--- a/.changeset/environment-deeplink-arm-on-create-action-4123.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-`?runAction=create_environment` is no longer consumed when the environments toolbar has no create action to run it on.
-
-`EnvironmentListToolbar` armed the deep link on `toolbarActions.length > 0` — the presence of *any* toolbar action. Consumption of this deep link is modelled as stripping the param from the URL, so arming is destructive: a toolbar carrying some other action stripped `?runAction=create_environment` and triggered nothing (measured with the real `action:bar` + `action:button` runner: `urlParam=null execute=0`). Because the strip *is* the consumption, the user's intent was unrecoverable — reloading could not retry it, and the welcome page's "Create your environment" CTA (#844) simply landed on the list with no dialog and no way to ask again.
-
-Arming now keys on the create action actually being present. When it is not, the URL is left alone, so the next mount that can act on the deep link still does — a reload, or the action arriving with fresh metadata.
-
-The two lists that used to disagree are now one. The toolbar filtered placement only (`locations`), while `action:bar` additionally applies the ADR-0066 D4 capability gate (`requiredPermissions`) to what it renders — so a create action the caller may not invoke was counted here and dropped there, which is the divergence that made the bug reachable without any change to cloud metadata. The toolbar now builds its list with both of the bar's predicates (`actionRendersAt` + `useCapabilityGate`, the shared hook that exists precisely to keep self-filtering surfaces from drifting), and every affordance derived from that list follows: the loading skeleton is held only for a create button that can actually arrive, and the plan-locked "Add environment" upgrade CTA — which stands in for the create action — is not offered when there is no create action to stand in for.
-
-The `#3803`-verified consumption ordering is untouched and re-pinned: the runner still consumes `autoTrigger` before the parent strips the param.
diff --git a/.changeset/fields-subpath-surface-4325.md b/.changeset/fields-subpath-surface-4325.md
deleted file mode 100644
index 2d566e9ebd..0000000000
--- a/.changeset/fields-subpath-surface-4325.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
----
-
-Internal only — a test and a test-only tsconfig, no source change, so no release.
-
-#4325 asked what `@object-ui/fields` publishes. `packages/fields/package.json`
-exports exactly `.` and `./style.css`, but
-`plugin-detail`'s `RelatedList.longFormColumns.test.tsx` side-effect-imported a
-third path, `@object-ui/fields/widgets/MarkdownContent`, to pre-resolve the lazy
-markdown chunk before asserting a column's ABSENCE. That specifier resolved only
-through this repo's vitest source alias — under Node's own resolution it is
-`ERR_PACKAGE_PATH_NOT_EXPORTED`, and under `tsc` with the standard `paths: {}`
-template it was TS2882, which is why `plugin-detail/tsconfig.test.json` carried
-the repo's only source-tree `paths` entry to restate the alias.
-
-The ruling was to NOT publish the subpath: the package's surface is its index,
-and one test's preload does not justify minting permanent public API plus a
-matching `dist` layout to maintain forever. So the import is gone, the tsconfig's
-`paths` is now `{}` like every other wired-up package's, and the test's anti-race
-guarantee was rebuilt rather than dropped.
-
-Preloading through the sanctioned entry was measured, not assumed, and does not
-work: importing `@object-ui/fields` only evaluates the module that DECLARES
-`React.lazy(() => import('./widgets/MarkdownContent'))` — the factory does not
-run, the chunk stays cold, and a probe waited 321.8ms for it on an idle
-container (AGENTS.md records up to 976ms under load, against RTL's 1000ms
-default). The race is therefore removed instead of won: the cases now assert on
-a witness present in every state of the lazy boundary — the Suspense fallback's
-raw value, the resolved markup, and the data-table cell wrapper's `title=`.
-
-Measured three ways with the `#4250` derivation scratch-broken: the new witness
-reports 2 document cells and goes red; the OLD assertions
-(`queryByText('Heading')`, `td h1`) pass all 5 cases against that same broken
-derivation once the preload is gone — the green-for-wrong-reason trap the issue
-predicted, now pinned shut.
diff --git a/.changeset/filter-panel-no-view-overlay-4155.md b/.changeset/filter-panel-no-view-overlay-4155.md
deleted file mode 100644
index fba6ad6f5c..0000000000
--- a/.changeset/filter-panel-no-view-overlay-4155.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Using a list's filter panel no longer overwrites the view's source-declared `filter` for everyone
-
-Opening a Console list view's filter panel and clicking **Add filter** wrote a view-customization overlay into `sys_metadata`. The row that button inserts is incomplete by construction — `{ field: , operator: 'equals', value: '' }` — and a view override is merged over the source-declared view key-wise (`{ ...source, ...override }`), so that one stray condition became the view's *entire* `filter`. The list then came back `total: 0` on every subsequent visit, for every user of the view, and the panel's own **Clear all** could not undo it: clearing wrote `filter: []`, which is still an override that deletes the source declaration. Recovery required deleting the `sys_metadata` row and restarting the service.
-
-Two independent changes, because the defect had a write half and a read half.
-
-**The write is gone.** `persistViewFilter` and both `onFilterChange` persist bindings are removed: a filter-panel interaction is transient state belonging to the session and to `writeListFilterState`'s per-browser restore, never to the view's stored body. The threshold for writing a view overlay is now an explicit save — `handleViewConfigSave`, and `ObjectDataPage`'s "Save as view". `ObjectDataPage` had already made exactly this call for the sibling surface ("Deliberately NO onSortChange/onFilterChange persistence hooks", #2251); this surface was the outlier. The surviving handler still writes localStorage, so the panel is not amnesiac — it just no longer speaks for every other user of the view. `foldFilterGroupToSpecRules` (objectstack#5159) survives untouched and is still the one dialect for the explicit paths; what was removed is the automatic write, not the fold.
-
-**Already-poisoned installs self-heal on read.** `sanitizeViewOverride` runs on both branches of `loadViewOverrides` and strips conditions an overlay must never impose — an empty value against an operator that wants one, in both the spec `ViewFilterRule` shape and the legacy runtime triple — then drops the `filter` **key** entirely when nothing effective survives. Dropping the key rather than writing `[]` is the whole point, and the merge semantics are measured in the tests rather than assumed: `filter: []` wins the spread and blanks the declaration, whereas no `filter` key falls through to it. So a stored `field equals ""` and a stored `filter: []` both stop overriding, and the source filter wins again on the next load — no `sys_metadata` surgery, no restart.
-
-One consequence is deliberate and worth naming: an overlay can no longer express "this view has NO filter" over a source view that declares one. That is the strictly safer side of the trade, because the shape that expressed it is the same shape that silently erased source declarations; an author who genuinely wants no filter edits the view, which writes the view body rather than an overlay.
-
-The existing objectstack#5159 ratchet is retargeted rather than deleted — it now asserts that *no* filter reaches the view-config persist path, that the `persistViewFilter` seam does not exist to be called, and that no `onFilterChange` handler reaches any persist call, with the explicit-save path pinned alongside as a control.
diff --git a/.changeset/form-route-edit-recordid-4278.md b/.changeset/form-route-edit-recordid-4278.md
deleted file mode 100644
index c046656222..0000000000
--- a/.changeset/form-route-edit-recordid-4278.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@object-ui/console': patch
-'@object-ui/app-shell': patch
----
-
-A `type: 'form'` action fired from a record now EDITS that record instead of creating a duplicate
-
-`ActionRunner.executeForm` forwards the record an action was fired from as `/forms/:name?recordId=`, but the console's internal form route never read that param. The only query params it consumed were the `prefill_` ones, so the route rendered EMPTY inputs, and its submit was an unconditional `POST /api/v1/data/:object` — an insert. An "edit this record" action therefore opened a blank form and, on Submit, created a second record while leaving the original untouched. In the showcase app: open any Task, click **Log Time**, fill it in, Submit — a NEW Task appeared. Until objectui#4109 the damage was hidden behind the anonymous "Your submission has been received" panel; once an internal submit started landing on the record it wrote, the duplicate became visible immediately.
-
-`?recordId=` now selects the whole read/write pair. The route loads the record with `GET /api/v1/data/:object/:id`, prefills the inputs with its stored values, and saves with `PATCH /api/v1/data/:object/:id` — the verb the data plugin declares (`plugin-rest-api.zod.ts`), the one `packages/rest` registers, and the one every other update client in this workspace already spells. After a successful save the user lands back on the record they edited.
-
-A `recordId` the route cannot honour now fails closed. A record that 404s or 403s, a payload whose object contradicts the form's target, and a present-but-blank `?recordId=` each render the form's error state; none of them falls back to create mode, because a blank form whose submit inserts a duplicate is this bug's exact harm and silently degrading into it would just re-arm it. A `recordId` naming a record of a different object is not found under the form's own object, so it takes the same refusal path.
-
-When a URL carries both a `recordId` and `prefill_` params, the explicit params win for the fields they name and the record's stored values fill the rest — a producer that forwards both is expressing intent, and the per-field instruction is the more specific one. Stored nulls and empty strings count as real values and beat a field's create-time `defaultValue`, so opening an edit form never silently proposes a change the user did not make.
-
-Two surfaces are deliberately untouched. Create mode — no `recordId` — behaves exactly as before, and the public `/f/:slug` path ignores `recordId` entirely: an anonymous visitor controls the URL, so honouring it there would turn a public form into an arbitrary-record reader and writer. In `@object-ui/app-shell` only the URL-param registry's documentation changed, recording that `recordId` now has a second reader on a route that can never match the same URL as the record drawer's.
diff --git a/.changeset/fullscreen-editor-hoist-3398.md b/.changeset/fullscreen-editor-hoist-3398.md
deleted file mode 100644
index 0d7b7ebca1..0000000000
--- a/.changeset/fullscreen-editor-hoist-3398.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/components': minor
-'@object-ui/fields': patch
----
-
-One fullscreen long-text editor, hoisted to the package both render paths may import
-
-The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
-
-Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
-
-`@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
-
-The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
-
-No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
-
-`toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
diff --git a/.changeset/gantt-count-interpolation-4157.md b/.changeset/gantt-count-interpolation-4157.md
deleted file mode 100644
index 20fc1ddb8b..0000000000
--- a/.changeset/gantt-count-interpolation-4157.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/plugin-gantt': patch
-'@object-ui/i18n': patch
----
-
-The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
-
-`gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
-
-The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
-
-`gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
-
-Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
diff --git a/.changeset/gantt-link-rejection-feedback-4158.md b/.changeset/gantt-link-rejection-feedback-4158.md
deleted file mode 100644
index a3258cb302..0000000000
--- a/.changeset/gantt-link-rejection-feedback-4158.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@object-ui/plugin-gantt': patch
-'@object-ui/i18n': patch
----
-
-An illegal gantt dependency link now says why it was refused, instead of doing nothing
-
-Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target *had* been registered, so the drop itself was a no-op.
-
-Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
-
-During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
-
-Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
-
-One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
diff --git a/.changeset/gated-options-keep-stored-value-4247.md b/.changeset/gated-options-keep-stored-value-4247.md
deleted file mode 100644
index b8b62852f4..0000000000
--- a/.changeset/gated-options-keep-stored-value-4247.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/fields': patch
-'@object-ui/components': patch
----
-
-A dependency-gated option list no longer deletes the field's stored value on mount
-
-The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is *gated* — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
-
-Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
-
-Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
-
-Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
diff --git a/.changeset/global-filter-preset-alias-4165.md b/.changeset/global-filter-preset-alias-4165.md
deleted file mode 100644
index 315cafb2f7..0000000000
--- a/.changeset/global-filter-preset-alias-4165.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/types': patch
-'@object-ui/core': patch
-'@object-ui/plugin-designer': patch
----
-
-A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
-
-`@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
-
-Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
-
-No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
-
-The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
diff --git a/.changeset/global-nav-studio-retire-rc6.md b/.changeset/global-nav-studio-retire-rc6.md
deleted file mode 100644
index e3269a1189..0000000000
--- a/.changeset/global-nav-studio-retire-rc6.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-"@object-ui/app-shell": minor
-"@object-ui/components": minor
-"@object-ui/types": minor
-"@object-ui/core": minor
----
-
-Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
-
-## The retirement
-
-`global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
-
-The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
-
-- `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
-- `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
-- `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
-- `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
-
-`@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
-
-## The dependency move
-
-All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
-
-Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
-
-## Breaking, in FROM → TO form
-
-- **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
-- **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
-- **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
-- **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
-- **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
diff --git a/.changeset/great-mails-repeat.md b/.changeset/great-mails-repeat.md
deleted file mode 100644
index e227946e9f..0000000000
--- a/.changeset/great-mails-repeat.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-"@object-ui/plugin-detail": patch
----
-
-`record:related_list` and the detail synthesizer now declare two shapes they already accepted at runtime.
-
-`RecordRelatedListRenderer`'s `schema` prop made `objectName` required, which rejected the exact authoring shape the per-element `dataSource` binding exists to support (`{ relationshipField, dataSource: { object, view } }`) — the gate maps the binding onto `objectName` before the body reads it, so the key is supplied, not missing. It is optional on the wrapper's input now, and required everywhere else.
-
-`ObjectDefLike.fieldGroups` is derived from the spec's authorable field group instead of restating it. The hand-written list had drifted: it omitted `icon` and `description`, both of which the synthesizer passes through to detail section descriptors, so an object definition declaring the group icon the code honours did not type-check against it.
diff --git a/.changeset/grid-bulk-bar-clear-resets-checkboxes-4140.md b/.changeset/grid-bulk-bar-clear-resets-checkboxes-4140.md
deleted file mode 100644
index bddce87180..0000000000
--- a/.changeset/grid-bulk-bar-clear-resets-checkboxes-4140.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-'@object-ui/plugin-grid': patch
----
-
-ObjectGrid's bulk-bar **Clear** now unticks the row checkboxes, instead of only removing the toolbar
-
-Selecting rows and pressing Clear emptied the bulk-actions bar but left every row checkbox at `data-state="checked"` (the header checkbox stuck at `indeterminate` on a partial pick). The user was stranded on a page of ticked rows with no toolbar left to act on them, and the only way out was a reload or re-selecting and clearing through some other path.
-
-The selection lives in two places: `selectedRows`, which is the grid's own state and drives the toolbar, and the row checkboxes, which live inside the embedded data-table and only clear when `selectionResetKey` moves. `resetSelection()` writes all three, and the delete / dispatch / dialog-close paths have gone through it since the reset-key mechanism was introduced. Both `BulkActionBar` mount sites, however, hand-wrote their `onClearSelection` as `setSelectedRows([]); setSelectAllMatching(false);` — exactly `resetSelection()` minus the key bump — so Clear updated one source and left the other ticked. Both sites now call `resetSelection()`, so there is one reset for every path that clears a selection rather than three hand-copied ones, and the cross-page "all matching" state drops with it.
diff --git a/.changeset/grid-row-menu-predicate-derive-4429.md b/.changeset/grid-row-menu-predicate-derive-4429.md
deleted file mode 100644
index b8fc9dc150..0000000000
--- a/.changeset/grid-row-menu-predicate-derive-4429.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/plugin-grid': minor
----
-
-grid row menu — the built-in Edit/Delete predicate declarations are derived from the spec-owned authoring type, not hand-restated
-
-`packages/plugin-grid/src/components/RowActionMenu.tsx` carried its own `BuiltinRowActionPredicates` interface (`{ visibleWhen?: unknown; disabledWhen?: unknown }`) and read it at six declaration sites: both `RowActionMenuProps` predicate props, the shared `isBuiltinRowActionVisible` gate, both `planRowActionMenu` parameters, and the `BuiltinRowActionItem` component. Nothing tied any of them to the type whose values they receive, so a rename at the source would have left every one compiling against a shape that no longer existed — the objectui#3009 hand-copy family, and the mirror of what PR #4423 collapsed in the data-table.
-
-**Measured true source.** These predicates do NOT flow from `DataTableSchema.rowEditPredicates` / `rowDeletePredicates` — this surface is never handed those keys. `ObjectGrid` resolves the object's `userActions.edit` / `delete` through `resolveRowCrudAffordances`, which returns `CrudAffordances['editPredicates']` / `['deletePredicates']`: the spec-owned `RowCrudPredicates` (ADR-0103, `@objectstack/spec/data`), parsed in exactly one place and re-exported by `@object-ui/core`. Each site now derives from that — per-key `Pick` for the planner (visibility is all it decides), one union alias for the two consumers that serve both built-ins. Measured: with `visibleWhen` renamed at the source, the previous hand-written declarations produce ZERO diagnostics in this package while the derived ones fail to compile at the declarations themselves.
-
-**Graded `minor` rather than `patch`** because a published type narrows (the objectui#4403 criterion). `RowActionMenuProps.editPredicates` / `deletePredicates` move from `unknown`-valued keys to the spec's `Expression | ExpressionInput` — the authored CEL shorthand or its `{ dialect, source }` envelope — so a consumer passing an `unknown`-typed value, or a bare boolean, stops type-checking. No runtime consumer breaks and no behavior changes; `@object-ui/core` retired the same `unknown` imprecision at its own seam, and this was the last copy of it. (PR #4423's data-table twin stayed `patch` because `DataTableSchema`'s keys were already declared `unknown` — deriving there narrowed nothing.)
-
-No runtime code was changed, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed. The planner-level case that claimed to pin this is renamed to the verdict it actually decides — the planner never reads `disabledWhen`, so its fixture behaved identically to `{}`.
diff --git a/.changeset/handrolled-interpolators-literal-4370.md b/.changeset/handrolled-interpolators-literal-4370.md
deleted file mode 100644
index ee5f9ad5e5..0000000000
--- a/.changeset/handrolled-interpolators-literal-4370.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@object-ui/plugin-gantt': patch
-'@object-ui/plugin-grid': patch
----
-
-A gantt task titled `A$&B` no longer prints `{{title}}` back into its own delete dialog — the two hand-rolled provider-less fallback interpolators are literal, like i18next
-
-objectui#3418 fixed the shared helper's fallback interpolator: `String.prototype.replace` became `split(needle).join(value)`, because `replace` and `replaceAll` both interpret `$&`, `` $` ``, `$'` and `$$` in the **replacement** string and i18next does not. Two hand-rolled copies of that interpolator never got the fix. Both are deliberate non-users of `createSafeTranslation` — each falls back per key so a host dictionary that covers the common keys but lags on newer ones still resolves what it has — so the shared fix had no path to reach them.
-
-The reachable one is gantt's. `gantt.delete.body` is `'"{{title}}" will be permanently removed. …'` and its call site interpolates the record's own title, which is user data:
-
-| task title | rendered before | rendered now |
-|---|---|---|
-| `A$&B` | `"A{{title}}B" will be permanently removed.` | `"A$&B" will be permanently removed.` |
-| ``x$`y`` | `"x"y" will be permanently removed.` | ``"x$`y" will be permanently removed.`` |
-| `p$$q` | `"p$q" will be permanently removed.` | `"p$$q" will be permanently removed.` |
-| `u$'v` | `"u" will be permanently removed. …v" will be permanently removed. …` | `"u$'v" will be permanently removed.` |
-
-The first row is the ugly one: `$&` expands to the matched text, so the placeholder itself is printed back to the user inside the record's own name. Gantt's copy also carried the other half of the same defect — a bare string needle substitutes only the **first** occurrence, where i18next substitutes every one — and `split`/`join` fixes both at once.
-
-The import wizard's copy used a `g`-flagged `RegExp`, which covered the repeated-placeholder half but could not touch the `$`-pattern half: that harm lives in the replacement string, not the needle. Its values are authored metadata — field labels and type names spliced into `grid.import.missingRequiredHint` and `grid.import.legacyReferenceBlocked` — so a label containing `$&` corrupted the hint the same way. Retiring the `RegExp` also retires an unescaped needle, since the placeholder name went into the pattern uninterpolated; that was inert while every placeholder name is a bare identifier, and is now structurally impossible.
-
-This is the provider-less path only (standalone embedding, unit tests). With an `I18nProvider` mounted, i18next serves these keys and was already literal on both sides — which is exactly why the divergence was invisible. No pack, key or call site changed; the three `{{count}}` gantt keys take numbers and were never affected, and `gantt.quickFilter.resultSummary`'s deliberate single-brace idiom is resolved by its call site rather than this interpolator and is untouched.
diff --git a/.changeset/home-action-centre-badges-full-unread-count-4329.md b/.changeset/home-action-centre-badges-full-unread-count-4329.md
deleted file mode 100644
index cbf2095022..0000000000
--- a/.changeset/home-action-centre-badges-full-unread-count-4329.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Home's action centre badges everything that is waiting, not the five rows it has room for
-
-`/home` showed two numbers for one question. The bell badges distinct unread topics plus pending approvals over the shared feed's full 20-row window; the action centre 200px below badged `pendingApprovalsCount + notifications.length` — and `notifications` is the list it renders, which `useHomeInbox` caps at 5. So nine unread messages read as **9** on the bell and **5** on the card, on one page, about one set of rows. The badge was reporting the size of a preview as if it were a total.
-
-Before objectui#4225 the card could not have said anything else: its own read was `$top: 5`, so nine was not a number it had. Both surfaces now cut from one already-joined feed, so the true count is in hand at Home's call site and the cap is a presentation slice over data the card already holds.
-
-`useHomeInbox` grows one additive field, `unreadTopicCount`, and `HomeActionCenter` takes it as a required prop: badge = `pendingApprovalsCount + unreadTopicCount`, list = the same unread set, newest first, still capped at 5. Badge means "how much needs you", list means "the newest few of it" — two semantics, each truthful, one number.
-
-The count is the bell's own fold (`groupNotifications`, by `(topic, title)`) applied to the bell's own rows, deliberately, rather than the pre-slice length of Home's list. That length is title-folded and drops blank titles, so it would agree with the bell on ordinary data and disagree whenever two topics share a title — and "two derivations of one number that agree usually" is exactly the defect objectui#4316 was. One fold, applied twice, cannot drift.
-
-Two adjacent behaviours are unchanged and now pinned as such: the approvals addend (distinct pending request ids from the shared REST feed, degrading to 0 on 404) and the list's own cap of five. "You're all caught up" is now gated on the total rather than on the rows on show, so it can no longer contradict the badge above it.
diff --git a/.changeset/home-action-centre-needs-an-answer-4235.md b/.changeset/home-action-centre-needs-an-answer-4235.md
deleted file mode 100644
index 350c860f18..0000000000
--- a/.changeset/home-action-centre-needs-an-answer-4235.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Home's action centre no longer says "You're all caught up" to a user whose inbox it failed to read (#4235)
-
-`useHomeInbox` caught every failed `sys_inbox_message` read to `[]`, so a denial
-arrived at `HomeActionCenter` wearing the exact shape of an empty inbox and the
-panel reported a quiet day, with no badge, to a user with nine unread messages.
-That is the reported symptom, and objectstack#7344 measured its mechanism in a
-browser: `403 PERMISSION_DENIED` on that object for every non-admin session,
-while `/api/v1/notifications` — a projection of the very same rows — answered
-with their messages. It also resolves the cross-run contradiction the card
-carried: two QA runs on one console pin disagreed because one was an admin and
-one was not.
-
-The hook now reports `notificationsStatus` (`idle` / `loading` / `ready` /
-`error` — `MetadataProvider`'s vocabulary, per #4300's one-dialect ruling), and
-the affirmative copy renders only on `ready`. An unanswered read gets a quiet,
-non-affirmative notice instead, rendered alongside the approvals row when only
-that half answered. A deployment with no inbox object at all is still an answer
-and still reads as caught up, unchanged.
-
-The source is unchanged and deliberately so: ADR-0030 names `sys_inbox_message`
-as the console's consumer channel, and `/api/v1/notifications` projects the same
-query one hop later.
diff --git a/.changeset/host-app-resolver-root-export-4280.md b/.changeset/host-app-resolver-root-export-4280.md
deleted file mode 100644
index 814c687807..0000000000
--- a/.changeset/host-app-resolver-root-export-4280.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/app-shell': minor
-'@object-ui/console': minor
----
-
-`resolveHostAppSegment` is published from `@object-ui/app-shell`'s package root, and the console's local copy of it is deleted
-
-Which app should host a framework-owned, app-INDEPENDENT page — the Approvals Inbox, the full inbox, an internal form's created record — is one hard-won definition, and `resolveHostAppSegment`'s docblock says so at length: prefer the app the user is in or last had open RE-CHECKED against the live active list, else their first active app, else the two last resorts. It lived in `app-shell/src/utils/appRoute.ts`, and `packages/app-shell` published only its package root, which re-exported `./utils` nowhere. So the definition was unreachable from every consumer outside the package.
-
-Something outside the package needed it anyway. objectui#4109 had to name a host app for the record an internal `/forms/:name` submit creates, could not import the resolver, and shipped a documented local subset instead: steps 1 and 2 only, returning `null` where upstream falls through further. That is the "two readers of one prose contract" shape this repo keeps paying for (#3367 / #3842) — the next edit to the resolution order lands on one copy — and the divergence was disclosed rather than smuggled precisely so it could be collected later. This is that collection.
-
-`resolveHostAppSegment` is now exported from the package root, alongside the two predicates it is defined in terms of (`appRouteSegment`, `filterActiveApps`), and the console's copy is gone: `createdRecordPath.ts` holds the URL shape and delegates the choice. The app record type it accepts is derived from the resolver's own signature rather than re-declared, so the call site cannot drift from it either.
-
-**Behaviour change on the created-record redirect.** Converging on the full resolver means the two cases the subset answered `null` for now name an app. An empty openable list with a preferred app keeps that preferred app unchecked — an empty list means "not loaded yet" at least as often as "this user has no apps", and demoting someone demonstrably rendering inside `/apps/{preferred}/…` would reintroduce the defect objectui#4074 removed. Anything else unresolvable — no apps and nothing preferred, or apps carrying neither `_packageId` nor `name` — lands on `setup`, the least-surprising last resort rather than a broken link. Concretely: an internal form submit that previously stopped on FormPage's in-place confirmation ("no record page to land on") now navigates to the record under the resolved app, which is the same answer every other record link in the console already gives. The write itself was never at stake, only where the user is put afterwards. The remaining `null` from `buildCreatedRecordPath` means what it always should have: there is no record to point at, because the caller has no object or no id.
diff --git a/.changeset/i18n-copy-trio-3878-3875-3877.md b/.changeset/i18n-copy-trio-3878-3875-3877.md
deleted file mode 100644
index 8e30ddc11a..0000000000
--- a/.changeset/i18n-copy-trio-3878-3875-3877.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@object-ui/i18n': patch
-'@object-ui/app-shell': patch
-'@object-ui/fields': patch
-'@object-ui/collaboration': patch
-'@object-ui/components': patch
-'@object-ui/plugin-detail': patch
-'@object-ui/plugin-grid': patch
-'@object-ui/plugin-kanban': patch
-'@object-ui/console': patch
----
-
-i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
-
-Three locale-copy defects that no gate could see, because all three are *value* defects on keys whose names, placeholders and key sets were already correct.
-
-**One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
-
-The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
-
-Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
-
-**es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders *underneath the usted draft-preview banner at the same moment*, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
-
-**pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
-
-No behavior, no keys added or removed, no placeholder changed.
diff --git a/.changeset/i18n-stragglers-4375-4376.md b/.changeset/i18n-stragglers-4375-4376.md
deleted file mode 100644
index 1ecfd887fc..0000000000
--- a/.changeset/i18n-stragglers-4375-4376.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@object-ui/fields': patch
-'@object-ui/i18n': patch
-'@object-ui/plugin-list': patch
----
-
-i18n: the two search placeholders become pack values, and four values the packs served in English get translated
-
-**objectui#4375** — `ListView` and `LookupField` built their search placeholder as
-`t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
-in all ten locales on screens where objectui#3878 had converged everything else on
-U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
-was appended to right-to-left text). Both now read `table.search`, which is already
-the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
-`PeoplePicker` render it too — and is translated with the right ellipsis in all ten
-packs. No new keys.
-
-**objectui#4376** — `list.loading` served the English `Loading records…` in eight of
-the nine translation packs (`zh` alone had translated it); `designer.undo` and
-`designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
-`ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
-pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
-inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
diff --git a/.changeset/i18nlabel-render-sites-4163.md b/.changeset/i18nlabel-render-sites-4163.md
deleted file mode 100644
index 8a52790190..0000000000
--- a/.changeset/i18nlabel-render-sites-4163.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-'@object-ui/layout': patch
-'@object-ui/plugin-list': patch
-'@object-ui/plugin-dashboard': patch
-'@object-ui/plugin-designer': patch
-'@object-ui/app-shell': patch
----
-
-An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
-
-rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
-
-| package | what an author can now write and see |
-| --- | --- |
-| `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
-| `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
-| `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
-| `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
-| `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
-
-**Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
-
-Two consequences worth knowing:
-
-- **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
-- **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
diff --git a/.changeset/image-field-maxsize-guard-4141.md b/.changeset/image-field-maxsize-guard-4141.md
deleted file mode 100644
index 33433f393c..0000000000
--- a/.changeset/image-field-maxsize-guard-4141.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/fields': patch
----
-
-An image field's declared `maxSize` is enforced before the upload starts, not after it finishes
-
-`ImageField` received a `maxSize` and ignored it. `paramToField` copies `maxSize` onto the field config for every action param regardless of type, so an image param declared with a 5 MB limit handed the widget its constraint and the widget uploaded anyway: a 6.3 MB PNG fired the full `presigned → PUT → complete` chain and rendered a thumbnail, with no rejection anywhere. The sibling file param, declared the same way, refused the identical pick without a single request. Reported from a QA run driving the two side by side (objectui#4141).
-
-Both of the widget's upload doors now check the limit first. The native picker rejects oversize picks before any request, keeping FileField's partial-acceptance rule — the in-limit members of a multi-select still upload, and only the oversize ones are reported. The crop dialog is the second door and needed the check in its own right: the cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a blob *over* the limit, and it is the crop's size that is uploaded. A rejected pick or crop now surfaces the same message the file widget has always shown, in a new error row — this widget had no surface for rejections before, because it never rejected anything.
-
-The guard itself moved into one shared `maxSizeError` helper that both widgets call, rather than a second copy living in the image widget. The check is the only thing between a declared limit and a real upload, and a per-widget copy is what let these two drift apart unnoticed in the first place. Both widgets also share the existing `fields.file.exceedsMaxSize` message: it names a file and a limit, says nothing file-specific, and is already translated in all built-in locales, so no new key was added and no translation is pending. FileField's own behavior is unchanged — same threshold, same message, same partial acceptance.
-
-An undeclared `maxSize` still means unrestricted; the falsy check is preserved deliberately, so a missing limit can never be read as a zero-byte one.
diff --git a/.changeset/inbox-activity-links-host-app-4074.md b/.changeset/inbox-activity-links-host-app-4074.md
deleted file mode 100644
index 1d65c38dad..0000000000
--- a/.changeset/inbox-activity-links-host-app-4074.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-The bell popover's and Home's "see all" drills open inside an app the user can actually open, not the setup app
-
-Four navigation producers hardcoded `/apps/setup/...` for pages that are not bound to the setup app at all: Home's notification fallback (`sys_inbox_message?view=mine`, taken whenever a notification carries no `action_url`), Home's "View all activity" (`sys_activity`), and the bell popover's two footer links to the same two pages. `sys_inbox_message` and `sys_activity` are framework-owned objects that resolve at `/apps/{any app}/{object}` — exactly like `system/approvals`, whose sibling entry in the very same popover was already resolving the current app. The in-code comment defending the hardcoded path argued the OBJECT is app-independent, which is true, and which is precisely why rendering it in `setup` does not follow.
-
-Both halves of the cost were measured in a browser before the fix. A business user whose app list does not contain `setup` clicked "View all notifications" and got the target rendered inside their own app's shell with the page showing "You don't have access" — a softer, more confusing failure than a hard app guard. Every other user, admins included, was switched out of the app they were in: the URL, the sidebar and the header app switcher all flipped to Setup, with no announcement and no way back except the app switcher.
-
-All four now resolve the host app through one shared helper (`resolveHostAppSegment` in `utils/appRoute.ts`), which generalizes the resolution objectstack#7231 introduced for the approvals entry and which Home and the popover both call, so the two surfaces cannot drift into different answers: the app the user is in (or last had open) re-checked against the live active-app list, then their first active app, then — only when the list names no app at all, i.e. it has not loaded yet — the caller's own hint, and `setup` last. A user whose current app is `setup` still lands in `setup`; a remembered app that has since been deactivated or hidden is not resurrected as a link.
-
-Two admin-scoped links, `/apps/setup/system/marketplace` and `/apps/setup/system/apps`, are deliberately left alone and pinned as such: those are setup-app surfaces by intent.
-
-Routing was not the only reason a business user saw an empty inbox — objectstack#7344 (no shipped permission set grants `sys_inbox_message`) is a second, independent cause, and the destination can still refuse the data read until that grant question is settled. This fix is necessary rather than sufficient, and the tests assert the resolved target, never the far end's data grant.
diff --git a/.changeset/inbox-popover-bell-polls-off-app-4110.md b/.changeset/inbox-popover-bell-polls-off-app-4110.md
deleted file mode 100644
index 63ba2e4a74..0000000000
--- a/.changeset/inbox-popover-bell-polls-off-app-4110.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-fix(app-shell): the top-bar bell polls the inbox on every console surface, not only inside an app (#4110)
-
-The bell's `sys_inbox_message` + `sys_notification_receipt` poll was gated on the
-header's `isApp` variant flag — the flag that exists to hide the app-only presence
-avatars and connection dot. The bell itself renders in every variant, so on Home,
-Organizations and the full-page AI screen its Notifications tab held `[]` forever:
-"Unread" read "You're all caught up" and "All" — which applies no predicate at all —
-read "No notifications", while Home's own To-do card listed the same row from the
-same object. The inbox is scoped to the signed-in user, not to the app in the URL,
-so the poll is now scoped by `user?.id` only.
diff --git a/.changeset/init-scaffold-vite-env-tsc-4111.md b/.changeset/init-scaffold-vite-env-tsc-4111.md
deleted file mode 100644
index 9235fa4604..0000000000
--- a/.changeset/init-scaffold-vite-env-tsc-4111.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/cli': patch
----
-
-Fix `objectui init`'s scaffold failing its own `npm run build`, and put the third generator under the real `tsc` gate
-
-The scaffold `objectui init` writes declares `"build": "tsc && vite build"`, so `tsc` runs on the way to a production build — and its `src/main.tsx` did `import './index.css'` with no ambient declaration behind it. Any user who followed the generated README (`objectui init`, then `npm run build`) got `TS2882: Cannot find module or type declarations for side-effect import of './index.css'` in a file the tool had just written for them, before Vite was ever reached.
-
-Fixed the same way objectui#3853 fixed the two temp-app generators: the scaffold now writes `src/vite-env.d.ts` (`/// `), where the `declare module '*.css'` declarations live. `vite` was already in the scaffold's `devDependencies`, so nothing new is declared.
-
-Measured rather than assumed: this scaffold had that one error and none of the other four classes objectui#3853 found in the temp apps — those live in a `src/Layout.tsx` this scaffold does not have. The `tsc` gate in `app-generator.test.ts` now covers the init scaffold too, so the strictness its own `tsconfig.json` declares is enforced instead of decorative.
diff --git a/.changeset/inline-address-input-4216.md b/.changeset/inline-address-input-4216.md
deleted file mode 100644
index 0d1a8f46ac..0000000000
--- a/.changeset/inline-address-input-4216.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-Inline-editing an `address` on the record detail page now edits it as real sub-fields, instead of collapsing it to one text box reading `[Object]` and saving a string over the structured value.
-
-`InlineFieldInput`'s type switch routed the scalar and relational families to their dedicated widgets; every structured-object type matched nothing and fell through to the terminal raw text input at the end of the component. That fallback stringifies an object value through `coerceToSafeValue`, whose general-object case extracts `name || label || externalId || id || _id` and otherwise returns the literal `[Object]`. A stored address carries none of those keys, so the edit box read `[Object]`.
-
-The display half was cosmetic; the write half was not. The fallback is a plain input wired to `onChange(v)`, so whatever the user typed was emitted as a **string** that replaced the whole `{ street, city, state, postalCode, country }` object on save — and `[Object]` was what the user saw as the current value they were correcting, which makes typing over it the natural gesture. An ordinary double-click inline edit therefore destroyed the sub-field structure. This is the input path only: objectui#4037 fixed the display registry, and read mode (including the inline-edit read state before editing starts) already rendered a formatted address.
-
-`location` and `geolocation` are fixed with it. Both store objects too (`{ latitude, longitude }`), both reached the same terminal input, and both produced the identical `[Object]`-then-overwrite pair — one defect in three spellings, not three defects.
-
-No new editor was written and no consumer-side tolerance was added. All three route to the widgets the create/edit dialog already uses (`AddressField` / `LocationField` / `GeolocationField`, the form's own structured-value editors), so the two entry points cannot diverge on the value shape they write back, and `coerceToSafeValue` is left untouched — the routing is what stops an address from ever reaching it. `autoFocus` follows the numeric branches' convention and lands on each widget's first sub-input (street / the coordinate box / latitude).
-
-String-valued types are unchanged: `text`, `textarea`, `email`, `phone`, `url`, `color`, `code`, `time`, `qrcode` and the rest keep the terminal text input, where stringification is the identity and nothing is lost.
diff --git a/.changeset/inline-credential-gate-4221.md b/.changeset/inline-credential-gate-4221.md
deleted file mode 100644
index dae6c96282..0000000000
--- a/.changeset/inline-credential-gate-4221.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-A `password` or `secret` field on the record detail page is no longer inline-editable: it renders no pencil / double-click affordance and produces no editor, on both the details body and the highlights strip.
-
-Both types are **masked on read** — `getCellRenderer` returns a fixed bullet run for either — so the value the row could hand an editor was never the credential. `InlineFieldInput` had no branch for either type, so both reached the terminal raw text input at the end of the component, and the row's payload value was seeded into a `type="text"` box: rendered in clear, selectable and copyable, in a control the user reads as holding their credential. Committing the row then wrote that placeholder back verbatim over the field. For `secret` the overwritten value is an opaque reference into an encrypted store (ADR-0100), so the write destroyed the pointer, not just the display. Nothing in the flow said so; the failure surfaced later, wherever that credential was used.
-
-The decision already existed one package over. `INLINE_EXCLUDED_FIELD_TYPES` in `@object-ui/fields` excludes both types with exactly this reasoning, and the grid honours it through `isInlineExcludedFieldType()`. The detail hosts gated on readonly / computed / system only and never consulted it, so the detail page reproduced the precise failure the set exists to prevent. Both hosts now consult that same alias-aware contract (`isInlineExcludedDetailFieldType`, a narrow-only union of the authored and the object type, matching the computed gate under objectui#3355) rather than growing a second hand-maintained list — so the rule cannot drift between the grid and the detail page again.
-
-Consulting the shared set closes the container family on the detail page with it: `object`, `composite`, `record`, `grid`, `repeater` and `vector` rode the same plain-text fallback with an object-shaped value, and are now excluded too. The spec spelling `autonumber` is likewise excluded, where the detail computed gate only knew the `auto_number` spelling. The heavy-editor family (`markdown`, `html`, `richtext`) loses its one-line text box on the detail page — those are authored in the record form, which has the real editors.
-
-The binary/attachment family is deliberately exempt and keeps its detail editor. It is in the shared set for a grid-cell reason — a cell cannot host an upload dropzone — while `InlineFieldInput` routes `image` / `avatar` / `signature` / `file` (and the `video` / `audio` spellings) to the same upload widgets the record form uses. That exemption is pinned against the routing it claims, so it cannot outlive it.
-
-Re-authoring a credential is unchanged and still belongs in the record form, which has the widget for it (`PasswordField`).
diff --git a/.changeset/inline-default-value-drift-gate-3810.md b/.changeset/inline-default-value-drift-gate-3810.md
deleted file mode 100644
index d6951b2f18..0000000000
--- a/.changeset/inline-default-value-drift-gate-3810.md
+++ /dev/null
@@ -1,39 +0,0 @@
----
-"@object-ui/app-shell": patch
-"@object-ui/plugin-detail": patch
-"@object-ui/plugin-list": patch
-"@object-ui/console": patch
----
-
-Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
-
-`t(key, { defaultValue: 'English text' })` only renders that text when i18next
-**misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
-pack value always wins, so the inline string is dead code — and 43 of those dead
-strings said something different from the sentence users actually read.
-
-`scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
-whenever a call site carries a literal `defaultValue` for a key `en` defines, and
-fails on any byte of difference. It is a hard rule with **no baseline**: the
-repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
-and all 43 are aligned here, so there is no debt for a ratchet to hold. A
-`defaultValue` on a key that is *not* yet in `en` stays legal — that transition
-runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
-which keeps reporting it alone.
-
-Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
-values are what users read today, and changing one would oblige the same change in
-the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
-43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
-is how they survived three i18n gates that are each blind to this class by
-construction.
-
-The visible effect is confined to hosts that render these components with **no**
-`I18nProvider` and no initialised i18next instance. There, react-i18next's
-not-ready `t` returns the `defaultValue`, so the inline string was the rendered
-one; it now matches what a provider-backed app has always shown. Inside the
-console — provider mounted — nothing users see changes. The clearest converging
-examples: the workspaces screen was written as "Organizations" at nine call sites
-while every user has been reading "Workspaces"; the forgot-password success line
-was written as "If an account exists, a reset link has been sent." while the pack
-asserts "We've sent a password reset link to {{email}}."
diff --git a/.changeset/inline-edit-delegate-4220.md b/.changeset/inline-edit-delegate-4220.md
deleted file mode 100644
index 1c077a7bad..0000000000
--- a/.changeset/inline-edit-delegate-4220.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
-'@object-ui/plugin-detail': patch
-'@object-ui/fields': patch
----
-
-fix(detail): inline edit no longer destroys array values or flattens types on the record page
-
-`InlineFieldInput`'s type switch ended in a raw text input, and every type it had
-no branch for landed there: the value was displayed through `coerceToSafeValue`
-and written back as whatever the user typed — a bare string.
-
-Two damage classes survived the earlier passes. Array-valued fields (`tags`,
-`checkboxes`, an options-less multi picklist) were offered for editing as
-`"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
-the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
-`rating`, `radio`) round-tripped through `String()`, so a boolean column
-received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
-value its option list never offered.
-
-Types the switch already routes keep their editors. Everything else that the
-fields package can edit inline now falls back to `FieldEditWidget` — the same
-control the form renders, `json` → the code editor included — and only genuinely
-string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
-input. A drift guard asserts every field type is exactly one of routed /
-excluded / delegated / benign, so a new type can no longer inherit the
-value-destroying default in silence.
-
-`@object-ui/fields`: the four fixed-option widgets no longer clear the stored
-value when the field declares no `options` at all. An empty offered set had two
-opposite causes — a list that cascaded to zero (clear) and a list that was never
-authored (nothing to decide) — and the second deleted the value on mount, which
-the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
-also forwards `autoFocus` to the widget it renders.
diff --git a/.changeset/internal-form-in-shell-4109.md b/.changeset/internal-form-in-shell-4109.md
deleted file mode 100644
index ac56276586..0000000000
--- a/.changeset/internal-form-in-shell-4109.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/console': minor
----
-
-Internal `/forms/:name` renders inside the console shell, and an internal submit lands on the record it just created
-
-A `type: 'form'` action navigates to `/forms/:name` (`ActionRunner.executeForm`). That route was declared at the TOP level of the console route tree, a sibling of the app-shell routes, so clicking a button inside an app dropped the user onto a bare form — no header, no navigation, no way back — while the URL still said they were in the console. It now renders inside the console's layout for app-independent authed pages, the same chrome `/home` and `/organizations` use. The route itself is unchanged: deep links to it keep working, because the missing chrome was the defect, not the navigation.
-
-The second half is what happens after Submit. The post-submit default was `{ kind: 'thank-you' }` for both form modes, so a signed-in operator who had just created a record was shown the ANONYMOUS confirmation — "Your submission has been received" — with no link to the thing they had created. The default is now mode-aware: an internal submit navigates to the created record's page, while the public `/f/:slug` path keeps `thank-you`, which is the right answer for a visitor who has no console to be sent into. A form view that declares its own `submitBehavior` still wins in both modes, unchanged and untouched — the point of a default is that the corpus never has to opt out of a wrong one.
-
-Landing on the record needs the created record's id, and that comes from the spec-declared `CreateDataResponse = { object, id, record }` returned by `POST /api/v1/data/:object`. Only that one declared key is read: `record.id` carries the same value, but reading both would be a second de-facto contract for one fact. A response that names no id — or a workspace where no app can host the record's page — falls back to confirming the submit rather than navigating somewhere broken, since the record really was created and silence would be the worse answer.
-
-No authorable surface changed. The "land on the created record" behaviour is deliberately NOT a new `submitBehavior.kind`: the spec's union (`thank-you | redirect | continue | next-record`) is strict and stays exactly as it is, and nothing parses the new internal default out of metadata — it is only what the renderer does when an author declared nothing.
diff --git a/.changeset/interpolation-parity-gate-3845.md b/.changeset/interpolation-parity-gate-3845.md
deleted file mode 100644
index 0ac28eb4cf..0000000000
--- a/.changeset/interpolation-parity-gate-3845.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Fail when a `t()` call site's arguments are not the holes its `en` value has, and delete the three that were inert
-
-`scripts/check-i18n-call-site-keys.mjs` gains a fourth failure class, `interpolation-parity`: for a call site whose key resolves to a readable `en` leaf, the set of interpolation option names it passes must EQUAL the set of `{{hole}}` names in that value. Both directions fail, because they fail differently — an argument with no hole is dropped by i18next in silence, and a hole with no argument leaves its own braces in what the user reads.
-
-Nothing else could see this. `all-locales-key-parity` does compare placeholder shape, but pack against pack, so ten packs agreeing on `Update` while the call site passes `version` is full parity. `check-i18n-en-drift` fires only when an `en` string changes. And objectui#3810's `default-value-drift` is satisfied the moment the call site's inline default matches the pack — which is exactly how `home.welcome` got here: its value was rewritten from `Welcome to {{product}}` to `Build your business system with AI`, the call site's default was later aligned to the new sentence, and the now-inert `product` argument stayed sitting beside it.
-
-The repo-wide run found **3 inert arguments and 0 unfilled holes**, so the rule lands hard, with no baseline. All three arguments are deleted rather than answered with a new hole in `en.ts`:
-
-- `marketplace.action.updateTo` — `version`, on a primary button reading a bare `Update` while its sister key in the same file renders `Update → v{{version}}`.
-- `home.welcome` — `product`, so no white-label deployment's name has ever reached the console hero.
-- `objectActions.resetPackageSetSuccess` — `label`, copied from the `deleteSuccess` branch next to it, whose sentence does name the record.
-
-**No rendered output moves, on any path.** With a provider mounted, i18next dropped these arguments already. With none, react-i18next's `notReadyT` returns `optsOrDefaultValue.defaultValue` verbatim — there is no interpolation step on that path at all — and all three inline defaults are hole-free too, so the miss path is unaffected as well. Adding the hole instead would have been a copy change: it moves a string users read today and obliges the other nine packs through objectui#3650. The gate accepts either resolution; choosing between them is not its business, and objectui#3546's slice-five assertion — written to force this decision rather than let it be settled silently — now pins the chosen state.
-
-One key is registered as filling its hole downstream: `auth.forgotPassword.successDescription` travels through `t()` with `{{email}}` intact because `ForgotPasswordForm` substitutes the address itself, once the form knows it. That entry silences the unfilled direction only — passing `email` to `t()` there would let i18next consume the hole and make the form append the address a second time, and the gate still reports it.
diff --git a/.changeset/kanban-rejected-move-rollback-4138.md b/.changeset/kanban-rejected-move-rollback-4138.md
deleted file mode 100644
index 08e39a2547..0000000000
--- a/.changeset/kanban-rejected-move-rollback-4138.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/plugin-kanban': patch
----
-
-A rejected Kanban drag rolls the card back on both data ownerships, not just when the board owns its own records
-
-Dragging a card into a column the server refuses (`PATCH` 400 `invalid_transition`) left the card sitting in the target column until a manual reload, whenever the board was hosted by a parent that supplies records through the `data` prop — the ListView/console path, which is the one real users meet. The toast fired and the server value was untouched, so the board was showing a move that had not happened.
-
-`handleCardMove` performed its failure revert only inside `if (!hasExternalData)`. The reasoning recorded next to it was that the parent handles the refresh, and for an accepted move it does — the parent's mutation subscription refetches and the new value propagates. A *rejected* move changes nothing server-side, so no refetch is ever triggered and nothing un-said the optimistic move.
-
-The revert is now unconditional, which is also what makes it a single code path rather than two. The card's on-screen position does not live in `ObjectKanban` at all: the board component moves the card inside its own column state before reporting the move upward, and re-syncs that state from its `columns` prop whenever the prop's identity changes — which any re-render of `ObjectKanban` produces, since the renderer re-buckets the records into fresh column arrays. On the internal path the revert corrects the record and re-renders; on the external path it re-renders against the parent's records, which the server never changed, and the re-bucket puts the card back where it started.
-
-The optimistic write on the way *in* stays gated on internal data deliberately, and the asymmetry is now pinned by tests: writing it on the external path would re-render against the unchanged parent records and snap an accepted move back before the server had answered. Accepted moves on both paths, and the existing rejection toast, are covered by controls alongside the regression test.
diff --git a/.changeset/list-filtered-empty-copy-4155.md b/.changeset/list-filtered-empty-copy-4155.md
deleted file mode 100644
index a50b6dd5bf..0000000000
--- a/.changeset/list-filtered-empty-copy-4155.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/plugin-list': patch
----
-
-A list emptied by the view's own filter says "no records match", instead of inviting you to create your first record
-
-`ListView`'s empty state distinguishes "filtered to empty" from "truly empty (first run)", but the view's own declared `filter` did not count toward that decision — only the search term, the user-filter conditions and the toolbar's live filter group did. A view that returns nothing *because it is filtered* therefore rendered the first-run copy over an object full of records.
-
-That is a small string, and it cost real triage time. In objectui#4155 a stored overlay filter had emptied a list, and the screen said "no data yet / create your first record" — so the report read as data loss or a permission problem, and the investigation went to the data and permission layers rather than to the view layer where the defect was. The same misread is available without any bug at all: a perfectly healthy view declaring `status not_in [archived, deleted]` over an object whose rows are all archived told the user the object was empty.
-
-The base `filter` now counts as an active query, in both at-rest shapes (an array of conditions, and the Mongo-style object form). No new copy — this only routes to the `list.noMatches` / `list.noMatchesMessage` strings that already exist in all ten locales, so there are no new keys to translate. An author-supplied `emptyState.title` / `emptyState.message` still wins over both branches, unchanged.
diff --git a/.changeset/list-sort-field-fallback-and-reset-4243.md b/.changeset/list-sort-field-fallback-and-reset-4243.md
deleted file mode 100644
index 34fbdc2af9..0000000000
--- a/.changeset/list-sort-field-fallback-and-reset-4243.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/plugin-list': patch
-'@object-ui/i18n': patch
----
-
-List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
-
-`filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for *filtering* silently became the *sort* whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
-
-Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
-
-One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
-
-The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
diff --git a/.changeset/listview-shared-datasource-gate-4038.md b/.changeset/listview-shared-datasource-gate-4038.md
deleted file mode 100644
index 368a2d19e4..0000000000
--- a/.changeset/listview-shared-datasource-gate-4038.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-"@object-ui/plugin-list": patch
----
-
-`list-view` now reads its `dataSource` binding through the shared `ElementDataSourceGate` instead of a private copy of the precedence table
-
-objectstack#5576 landed the per-element `dataSource` binding on `list-view` by
-writing the precedence table — binding keys override the component's, a `view` is
-only a baseline, `filter` AND-combines rather than replaces, an authored-but-empty
-`columns` counts as unauthored, the row cap lands on `pagination.pageSize`, and
-`viewType` is taken only when the component declared none — inline in
-`ListViewBlock`. objectstack#6953 then needed the same table for the other eight
-object-bound blocks and lifted it into `@object-ui/react`
-(`useElementDataSourceSchema` / `ElementDataSourceGate`), deliberately not
-touching `ListViewBlock`: refactoring already-merged code inside a wiring PR
-would have been an out-of-scope regression surface.
-
-That left one table with two implementations — `list-view` on the private copy,
-every other block on the shared one. Nothing was wrong for a user today; the risk
-is the next person to change the rules changing one side, which is how the spec's
-"*additional* filter criteria" becomes two dialects and a per-element filter
-quietly starts replacing a saved view's instead of narrowing it.
-
-`ListViewBlock` now contributes only what is genuinely its own — the names of the
-keys `ListView` reads:
-
-```ts
-const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = {
- columns: true, filter: true, sort: true,
- limit: 'pagination.pageSize', viewType: true,
-};
-```
-
-and the ~45-line `useMemo` mapping block is deleted, along with the block's
-hand-rolled error and loading panels (the shared
-`ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel` render with the
-`list-view` testId prefix, so `list-view-datasource-error` and
-`list-view-resolving-view` are unchanged down to the byte, and the error heading
-is passed through as `errorTitle`).
-
-**No behaviour changes in either direction**, and that is the acceptance
-criterion rather than a hoped-for outcome: objectstack#5576's entire suite passes
-untouched, with no assertion edited — had any single case needed adapting, the
-two implementations would have been proven to disagree, which is a defect to
-re-grade rather than a refactor detail to absorb. New pins cover the mapping
-table key by key at the block/gate seam, plus the shared loading panel, which
-neither implementation had ever asserted.
diff --git a/.changeset/local-select-dimension-i18n-4330.md b/.changeset/local-select-dimension-i18n-4330.md
deleted file mode 100644
index 6c6e6863a8..0000000000
--- a/.changeset/local-select-dimension-i18n-4330.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/plugin-dashboard': patch
-'@object-ui/plugin-report': patch
----
-
-Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
-
-A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
-
-Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
-
-Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
-
-This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
diff --git a/.changeset/locale-menu-from-app-4039.md b/.changeset/locale-menu-from-app-4039.md
deleted file mode 100644
index aa4d4bff01..0000000000
--- a/.changeset/locale-menu-from-app-4039.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/i18n': minor
-'@object-ui/app-shell': patch
-'@object-ui/console': patch
----
-
-The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
-
-`LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
-
-The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
-
-**The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
-
-Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
-
-While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
diff --git a/.changeset/lucky-buttons-shave.md b/.changeset/lucky-buttons-shave.md
deleted file mode 100644
index 4bfd40ed67..0000000000
--- a/.changeset/lucky-buttons-shave.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/cli': patch
----
-
-Make the generated temp app pass the strict `tsconfig.json` the generator writes beside it, and gate it with a real `tsc`
-
-Both app generators (`createTempApp`, `createTempAppWithRouting`) emit a `tsconfig.json` carrying `strict`, `noUnusedLocals` and `noUnusedParameters`, but nothing had ever run it — `dev`/`serve`/`build` go through Vite, which transpiles without type-checking — so the generated sources had drifted 17 errors past their own declared config. A user who copies the temp app out as a scaffold, or runs `tsc` in it, met all 17 at once.
-
-Fixed at the templates: dropped five imports that were declared and never used (`Link` in `src/App.tsx`; `cn`, `Button`, `SidebarGroupContent`, `SidebarGroupLabel` in `src/Layout.tsx`), typed `DynamicIcon`'s and `AppLayout`'s props (which also types the `menu`/`children` map callbacks by inference, and makes `className` optional so the two call sites that omit it are legal), and added the `src/vite-env.d.ts` every Vite TS scaffold carries — without it the entry's `import './index.css'` has no declaration behind it, in both generators.
-
-The Lucide lookup no longer needs `@ts-expect-error`: the namespace is narrowed to the component-by-name shape the layout actually uses. No `any` was added.
-
-A real `tsc -p` over a generated app now runs in the package's tests, so the declared strictness is enforced rather than decorative.
diff --git a/.changeset/map-pack-mirror-gate-4401.md b/.changeset/map-pack-mirror-gate-4401.md
deleted file mode 100644
index d5af98117e..0000000000
--- a/.changeset/map-pack-mirror-gate-4401.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/plugin-detail': patch
-'@object-ui/plugin-list': patch
-'@object-ui/plugin-designer': patch
----
-
-Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
-
-`DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
-`Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
-control — the map's on provider-less hosts (standalone embeds, the preview gallery),
-the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
-
-The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
-now compared key-by-key against the `en` pack by a new gate, generalizing the
-collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
-`DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
-and `COLLAB_DEFAULT_TRANSLATIONS` already were.
diff --git a/.changeset/mapdensity-dead-rowheight-spellings-4352.md b/.changeset/mapdensity-dead-rowheight-spellings-4352.md
deleted file mode 100644
index 8d6e986408..0000000000
--- a/.changeset/mapdensity-dead-rowheight-spellings-4352.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-'@object-ui/react': minor
----
-
-`bridgeListView` maps the five row heights the spec admits, and only those — the four dead spellings are gone
-
-`mapDensity` carried a nine-key table: `compact`, `short`, `comfortable`,
-`spacious`, `small`, `medium`, `large`, `tall`, `extra_tall`. `RowHeightSchema`
-in `@objectstack/spec` admits five — `short | compact | medium | tall |
-extra_tall` — so `comfortable`, `spacious`, `small` and `large` were unreachable
-from any spec-valid list view. The bridge's own parameter type said as much
-(`Partial< ListView >`), and `mapDensity` widened it back to `rowHeight?: string`
-to let them in. They survived because the fixture asserting three of them
-compiled against nothing: the package's build tsconfig excludes tests and no
-other `tsc` read them, so four branches of renderer-side dialect read as live
-capability (objectui#4352, surfaced by objectui#4040 / PR #4351).
-
-They are deleted. The parameter takes the spec type honestly, and the table is
-now `Record< RowHeight, … >`, so a row height added upstream fails the build here
-instead of arriving with no density. This is AGENTS.md #0.1: a lenient reading
-for off-spec metadata is a second de-facto contract, and one strict contract
-beats N dialects — a bad `rowHeight` gets fixed at the producer, where the schema
-already rejects it.
-
-**Breaking semantics, deliberately graded `minor`** (this repo never publishes
-`major` — its major tracks `@objectstack`). Nothing narrows in the published type
-surface: `mapDensity` is module-local and never appeared in the emitted `.d.ts`,
-and `bridgeListView`'s declaration is unchanged. What changes is runtime output,
-and only for input the spec already rejects: a host handing the bridge
-`rowHeight: 'comfortable'` (or `'spacious'` / `'small'` / `'large'`) used to get
-`density: 'comfortable'` / `'spacious'` / `'compact'` back, and now gets no
-`density` key at all, so the renderer's own default applies. A sweep of this repo,
-the `objectstack` example apps and the console's view metadata found zero authored
-uses of any of the four; the legacy `densityMode` alias cannot produce one either,
-since `DENSITY_MODE_TO_ROW_HEIGHT` is typed `Record< DensityMode, RowHeight >` and
-folds onto `compact` / `medium` / `tall`.
diff --git a/.changeset/metric-kpi-i18n-channel-4032.md b/.changeset/metric-kpi-i18n-channel-4032.md
deleted file mode 100644
index aa4de1b8d5..0000000000
--- a/.changeset/metric-kpi-i18n-channel-4032.md
+++ /dev/null
@@ -1,45 +0,0 @@
----
-'@object-ui/plugin-dashboard': patch
-'@object-ui/core': patch
-'@object-ui/types': patch
-'@object-ui/app-shell': patch
----
-
-fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
-
-A `type: 'metric'` dashboard widget rendered raw English while every other widget
-type on the same dashboard rendered the translation, and dashboard filter chips
-rendered `[object Object]` or the raw stored value. Both come from the same
-cause: authored labels reaching a render site that could not read the
-vocabulary `@objectstack/spec` actually admits.
-
-- **KPI cards rejoin the widget translation channel.** The self-contained
- `metric` branch built its own label from the raw `widget.title`, so the
- `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
- resolved was computed and thrown away. It now reads that channel like every
- other widget header.
-- **The three private `resolveLabel` copies** (`DashboardRenderer`,
- `MetricWidget`, `MetricCard`) are gone. Each read the retired
- `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
- handed the inline per-locale map the spec admits today they returned nothing —
- a KPI card with a map title rendered the literal string `metric`. All three
- now use `pickLocalized`, the resolver already used for this vocabulary
- elsewhere in the package.
-- **Dashboard filter labels and static option labels resolve per locale.**
- `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
- resolves before rendering (fixing `[object Object]: All` in the trigger, and
- in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
- tests the RESOLVED string — an object is always truthy, so it never reached
- the fallback before.
-- **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
- map label to the raw stored value in every locale, English included, so
- `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
- The pair shape is still normalized; the label vocabulary is preserved for the
- render side to resolve.
-- **`DashboardComponentSchema.globalFilters` is bound to the spec's
- `GlobalFilter`** instead of restated by hand. The restatement was both too
- narrow (`label?: string`, which is what made these read sites invisible to
- `tsc`) and too wide (it declared a bare-string option shorthand the spec
- rejects at publish).
-
-Plain-string labels are unaffected and render byte-identically.
diff --git a/.changeset/metric-props-dom-passthrough-4426.md b/.changeset/metric-props-dom-passthrough-4426.md
deleted file mode 100644
index 5f58bd6fa7..0000000000
--- a/.changeset/metric-props-dom-passthrough-4426.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@object-ui/plugin-dashboard': minor
----
-
-`MetricWidgetProps` / `MetricCardProps` declare the DOM pass-through their spread has always accepted
-
-Both KPI components end their prop list with a `...domProps` spread onto the Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately — it is their only accessibility pass-through, and removing it would delete the only way a host can put an `id`, a `role` or an `aria-label` on a KPI card. Neither props interface declared any of it. So the type refused what the runtime accepted: a JS consumer, and every SDUI author going through `SchemaRenderer` (untyped at that boundary), got the pass-through, while a TypeScript consumer importing the component directly got `error TS2322` on `id` / `role` / `aria-label` and needed a cast.
-
-`MetricWidgetProps` now extends `React.HTMLAttributes`, and `MetricCardProps` extends the same minus `title`. That is the repo's measured convention for an exported props interface that spreads onto a host element (`PageHeaderComponentProps`, `ChatbotProps`, `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`, `FieldProps`, and shadcn's `BadgeProps`), and the `Omit` carve-out is `ComboboxProps`'s spelling for a name the component's own contract owns.
-
-Graded `minor` rather than `patch` per the objectui#4403 precedent: two exported interfaces widen. The widening is purely additive for existing callers — every prop that compiled before still compiles, and nothing narrows — so no source change is required to upgrade.
-
-Semantics worth knowing, because both are contract statements rather than incidental:
-
-- **`MetricCard.title` stays the heading.** HTML's `title` is a tooltip; this card's `title` is its heading, in the `I18nLabel` vocabulary, destructured out and rendered into `CardTitle`. No `title` attribute has ever reached this element, so the inherited DOM `title` is omitted rather than declared and silently dropped — the "declared but not delivered" failure this repo treats as first-class (objectui#3290, objectui#3222). `MetricWidget` has no such collision (its heading is `label`) and extends the DOM attributes whole.
-- **`MetricWidget.onClick` stays zero-arg**, narrower than the inherited `MouseEventHandler`, because the same handler is wired to Enter/Space where there is no mouse event to hand over. A zero-arg function is assignable to the inherited signature, so callers already passing `(e) => …` keep compiling.
-
-Not declared, deliberately: the schema-shaped keys `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` / `ariaLabel` / `ariaDescribedBy` / `dataSource`). None is an HTML attribute name, all seven are destructured out before the spread, and declaring them would re-assert as public contract exactly what PR #4428 stripped from the DOM. They stay in `SchemaHostProps`, intersected in at each component's own signature — accepted so the renderer can inject them, never part of the documented authoring surface.
-
-Zero runtime change: no component body was touched, and PR #4428's pins pass untouched.
diff --git a/.changeset/metric-widget-dom-spread-4357.md b/.changeset/metric-widget-dom-spread-4357.md
deleted file mode 100644
index a56632a8fb..0000000000
--- a/.changeset/metric-widget-dom-spread-4357.md
+++ /dev/null
@@ -1,47 +0,0 @@
----
-'@object-ui/plugin-dashboard': patch
----
-
-KPI cards no longer write their own schema onto the DOM — `MetricWidget` and
-`MetricCard` keep `SchemaRenderer`'s schema-shaped props out of the `...props`
-spread (objectui#4357).
-
-Both components are two things at once: an SDUI block reached through
-`SchemaRenderer`, and a plain React component a host may render directly. The
-React half wants a `...props` spread on its root so callers can pass `aria-*`,
-`data-*`, `id`, `role`. The SDUI half means that spread also received the node's
-own metadata — and React writes unknown lowercase attributes straight to the DOM,
-stringifying object values. Every KPI card therefore carried
-`schema="[object Object]"`, and a widget authored with events, a binding or a
-props container carried `events="[object Object]"`, `bind="data.revenue"` and
-`props="[object Object]"` beside it.
-
-Seven props were measured arriving at the call site that are not HTML attribute
-names — `schema`, `events`, `props`, `bind`, `ariaLabel`, `ariaDescribedBy` (the
-last two are the camelCase authored forms of ARIA the renderer already emits in
-their dashed spelling) and `dataSource`. They are destructured out; the spread
-survives untouched for everything that IS a DOM attribute: `id`, `name`, `role`,
-`disabled`, `aria-*`, `data-*`, `className`. Nothing else about the render moves
-— no text, no class, no element.
-
-`dataSource` is the one that only a live dashboard shows. It is not a schema key
-(the renderer strips the schema's own `dataSource` binding by name); it is the
-injected adapter `DashboardRenderer` hands its `SchemaRenderer` call, which
-arrives through the renderer's trailing props. Every fixture in this package
-renders without an adapter, so it read `undefined` and wrote nothing — while
-every deployment that actually loads data put `datasource="[object Object]"` on
-the card. The pin renders a dashboard with an adapter so the case that only
-production had is now a test.
-
-The cost of this was never visible; it was that the defect poisoned the
-assertion this area attracts. objectui#4163 pins
-`not.toContain('[object Object]')` on the dashboard grid, and objectui#4032
-wanted the same pin on the metric path but could not write it: the card carried
-the attribute before and after any i18n fix, so the container assertion was red
-for a reason unrelated to labels and the tempting repair was to loosen it. That
-suite asserted on the card heading instead, with a comment. The workaround is
-now removed and the container assertion is back.
-
-The exported `MetricWidgetProps` / `MetricCardProps` interfaces are unchanged —
-the components' accepted props widen only by the optional, ignored
-`SchemaHostProps` keys, so no consumer type narrows.
diff --git a/.changeset/nervous-pans-repeat.md b/.changeset/nervous-pans-repeat.md
deleted file mode 100644
index f8977703a3..0000000000
--- a/.changeset/nervous-pans-repeat.md
+++ /dev/null
@@ -1,33 +0,0 @@
----
----
-
-Releases nothing on purpose: this deletes 22 dead `t(key) || 'English'` fallbacks in
-`@object-ui/app-shell` and adds the gate that stops them coming back, and not one of
-them can change what a user reads.
-
-The right operand of these was unreachable on **every** path before the deletion, which
-is the whole finding (objectui#4117). With an `I18nProvider` mounted, i18next serves the
-`en` value and the fallback is skipped. Without one, react-i18next's not-ready `t`
-returns the KEY — truthy — so `||` skips the fallback there too and the raw key renders
-either way. Removing an operand that never evaluated leaves both paths byte-identical.
-
-The five rows whose dead string said something *different* from the pack were the ones
-worth checking individually, because a divergent fallback that also carried
-`{{holes}}` could have been hiding an unfilled hole. Each was verified against what the
-call actually passes:
-
-- `marketplace.detail.purgeSuccess` (×2) — `en` `Removed {{count}} sample record(s).`,
- call passes `{ count: removed }`. Hole filled; renders the same before and after.
-- `marketplace.detail.reseedLocalSuccess` — `en` `Re-seeded sample data: {{inserted}}
- inserted, {{updated}} updated.`, call passes `{ inserted, updated }`. Both filled.
-- `marketplace.detail.reseedPartialErrors` — `en` `({{count}} record(s) failed to
- write)`, call passes `{ count: errored }`. Filled.
-- `console.objectView.deleteViewConfirm` — `en` `… delete the view "{{name}}"? …`,
- call passes `{ name: viewLabel }`. Filled.
-- `marketplace.detail.moreOptions` — `en` `More install options` against a dead
- `'More options'`. No holes; the `aria-label` already read the pack value.
-
-So no call needed params wired, and the `interpolation-parity` rule from objectui#3845
-independently agrees: it judges all 22 of these sites and reports nothing in either
-direction. The user-visible copy is unchanged, which is why this entry carries no
-version bump.
diff --git a/.changeset/number-format-locale-ordinal-grouping-4033.md b/.changeset/number-format-locale-ordinal-grouping-4033.md
deleted file mode 100644
index 523d321cf3..0000000000
--- a/.changeset/number-format-locale-ordinal-grouping-4033.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-'@object-ui/i18n': patch
-'@object-ui/fields': patch
-'@object-ui/components': patch
-'@object-ui/plugin-dashboard': patch
----
-
-Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
-
-Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
-
-The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
-
-`useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
-
-Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 *count* loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
-
-Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
-
-`formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
diff --git a/.changeset/objectchart-label-net-third-copy-4405.md b/.changeset/objectchart-label-net-third-copy-4405.md
deleted file mode 100644
index d142976c74..0000000000
--- a/.changeset/objectchart-label-net-third-copy-4405.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/plugin-charts': patch
----
-
-Analytics: `ObjectChart` consumes the shared label-net helpers instead of a third copy
-
-objectui#4389 (PR #4404) named two copies of the analytics label-net glue — the dashboard's `DatasetWidget` and plugin-report's dataset block — and retired both into `@object-ui/core` + `@object-ui/react`. There was a THIRD, which that card did not name and its PR deliberately left out of scope: `packages/plugin-charts/src/ObjectChart.tsx` carried its own `translatorFor` closure, its own `buildDimensionLabelMap` loop, and its own base-object-read-then-walk composition. The `translatorFor` copy was logically identical to the two that were deleted, down to the comment explaining the binding.
-
-`ObjectChart` now calls core's `dimensionOptionTranslator`, `deriveDimensionLabelMaps` and `loadDimensionFieldMeta` directly. Nothing about what a label IS changes — those helpers are the same code the two retired copies were rewritten onto, so the part that was genuinely duplicated three times is now written once.
-
-Behaviour is unchanged by construction: same two metadata reads in the same order on the dataset path, same one read on the aggregate path, same best-effort fallback (an unresolvable path yields no entry and the raw value survives), same locale-applying memo boundary. `plugin-charts`' 22 test files / 170 assertions pass unchanged and their files are byte-identical to before, which is the acceptance evidence for a pure swap.
-
-The card's second, optional step — moving the DATASET path's metadata read onto `@object-ui/react`'s `useDatasetDimensionMeta` — was attempted and declined on measurement; the shape blocker is recorded on objectui#4405 and in the PR. The two bug-fix properties the family exists to state (the read rides the host's authenticated `apiFetch`, objectui#4121; the fetched metadata stays locale-free, objectui#4030 / PR #4324) therefore remain stated locally in this file, exactly as before, and are undisturbed by this change.
diff --git a/.changeset/objectchart-option-color-probe-apifetch-4114.md b/.changeset/objectchart-option-color-probe-apifetch-4114.md
deleted file mode 100644
index 43e2169190..0000000000
--- a/.changeset/objectchart-option-color-probe-apifetch-4114.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-"@object-ui/plugin-charts": patch
----
-
-`ObjectChart`'s category option-color / dimension-label probe now rides the host's
-authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
-`fetch`.
-
-Both metadata reads the effect makes — `GET /api/v1/meta/dataset/{dataset}` and
-`GET /api/v1/meta/object/{object}` — went out on the global `fetch`, so in a hosted
-console they skipped whatever the host supplies on that channel (Authorization /
-tenant headers, base-URL rewrite, draft-preview params). A bearer-token session
-carries its credential in a header rather than a cookie, so `credentials: 'include'`
-alone left these two reads unauthenticated. The effect is best-effort and swallows
-every failure, which made the symptom silent: semantic option colors and dataset
-dimension labels simply never applied, and the chart fell back to the positional
-theme palette and raw stored values.
-
-Standalone embeds are unaffected — with no provider (or a provider that supplies no
-`apiFetch`) the probe still uses the global `fetch`, the same documented fallback
-`useRecordEditable` and `provider: 'api'` view sources use.
diff --git a/.changeset/objectgrid-external-pagination-props-4277.md b/.changeset/objectgrid-external-pagination-props-4277.md
deleted file mode 100644
index 6b8a29d3cb..0000000000
--- a/.changeset/objectgrid-external-pagination-props-4277.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/plugin-grid': minor
----
-
-ObjectGrid's host-driven pagination mode is a declared interface instead of twelve `(rest as any)` reads
-
-`ObjectGridProps` declared twelve members while the component read twelve more out of `...rest`, each through an `as any` cast: `data`, `manualPagination`, `rowCount`, `page`, `pageSize`, `onPageChange`, `onPageSizeChange`, `sort`, `onSortChange`, `search`, `onSearchChange` and `onColumnStateChange`. They are not accidental — together they are the host-driven external-pagination path from framework#2212, where a host has already fetched one window of a larger collection and drives the page/sort/search controls itself, and the component's own comment said so. They were simply declared nowhere, so no call site could be checked against them and no editor could offer them.
-
-Nothing had caught it because the only untyped caller is `ObjectGridRenderer`, whose `{ schema: any; [key: string]: any }` index signature accepts anything; every typed caller happens to pass only declared props; and the test that exercises the path was compiled by nothing.
-
-They now live on a named `ObjectGridExternalPaginationProps`, which `ObjectGridProps` extends — a separate interface rather than twelve more members flattened into the authoring surface, so the "advanced host-driven mode" boundary stays visible. The eleven members that already have a counterpart on `DataTableSchema` — the type ObjectGrid forwards them to — are **type-derived** from that declaration (`Partial< Pick< DataTableSchema, … > >`) rather than hand-copied, so the two cannot drift apart; only `onColumnStateChange` is declared explicitly, because the table vocabulary reports per-event `onColumnResize` / `onColumnReorder` rather than the merged `{ order, widths }` layout this reports. `ObjectGridColumnState` is exported for that payload.
-
-Purely additive for callers: every member is optional, so existing code compiles unchanged, and hosts that were already passing these props now get them checked instead of silently accepted. Runtime behavior is unchanged.
diff --git a/.changeset/objectgrid-rowheight-boundary-4443.md b/.changeset/objectgrid-rowheight-boundary-4443.md
deleted file mode 100644
index 3eedf47c0d..0000000000
--- a/.changeset/objectgrid-rowheight-boundary-4443.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/plugin-grid': patch
----
-
-standalone ObjectGrid resolves off-spec `rowHeight` to compact, matching ListView and the spec bridge, instead of silently styling it as medium
-
-One component answered one question two ways. `ObjectGrid` seeded its density state with `schema.rowHeight ?? 'compact'`, so an ABSENT `rowHeight` landed on `compact` while an OFF-SPEC one skipped every arm of the density ternaries and came out at their terminal `else` — the `medium` styling. That is the absent-vs-off-spec split objectui#4440 removed from `ListView`, and it made a standalone grid the third answer to a question the rest of the system had already settled: `@object-ui/core`'s `rowHeightToDensityMode` abstains for an off-spec value, the `@object-ui/react` spec bridge abstains, and `ListView` defaults the abstention to `compact`. Off-spec now renders exactly like absent, everywhere.
-
-Only a standalone grid was affected. When `ListView` owns the grid it overwrites the prop with a value derived from `density.mode`, so nothing off-spec survives that hop.
-
-The narrowing happens at the state boundary, not in the ternaries. `medium` is still a real row height with its own styling arm, and a leaf renderer's terminal `else` is still legitimate styling — what changes is that nothing unrecognized can reach it. Membership is tested against `ROW_HEIGHT_TO_DENSITY_MODE`, so the admitted values keep one definition in the repo and the build fails if the spec grows a sixth row height without teaching the resolver about it. Both entry points go through the resolver: the initial state and the effect that re-syncs when the `rowHeight` prop changes.
-
-Two off-spec spellings behaved differently before this, which the report of the defect did not distinguish, and the boundary fix covers both:
-
-- A plain off-spec value (`'garbage'`) was not a key of the toolbar's row-height icon map either. That map is looked up by the same unvalidated state, so `rowHeightIcons[mode]` was `undefined` and rendering ` ` threw `Element type is invalid` — a standalone grid with an off-spec `rowHeight` did not render at all, rather than rendering as `medium`. The toolbar is shown precisely when `schema.rowHeight` is defined, so the crash and the off-spec case coincide exactly.
-- A prototype member (`'toString'`) WAS reachable through that map's prototype chain, resolving to `Object.prototype.toString` — a function, which React accepts as a component — so it survived to the ternaries and rendered as `medium`, the defect as filed. The resolver uses `hasOwnProperty` rather than `in` for this reason, the same reason `@object-ui/core` does.
-
-Both are now inert: the state can only ever hold one of the five admitted row heights, so the icon lookup is total and the ternaries never fall through.
diff --git a/.changeset/objectmap-filter-config-4034.md b/.changeset/objectmap-filter-config-4034.md
deleted file mode 100644
index 2b6641238a..0000000000
--- a/.changeset/objectmap-filter-config-4034.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/plugin-map': patch
----
-
-`object-map` reads its configuration from the declared `map` input only — `filter` is the query filter, and a map authored with both stopped rendering markers
-
-`getMapConfig` probed every filter for a `map` key and, on a hit, used it as the MapConfig: `schema.filter.map`, plus a `schema.filter.map.style` half in the style chain. That shape predates the `{ name: 'map', type: 'object' }` input both registrations declare, and it gave `filter` two meanings inside one block — the query filter at `$filter: schema.filter`, and a configuration slot.
-
-The probe was written as `'map' in schema.filter`, and `in` walks the prototype chain. The ordinary filter is an **array**, and every array inherits `Array.prototype.map` — so the probe matched, handed the component a *function* as its map configuration, and the spread of a function is `{}`. The declared `schema.map` was never reached (it sat in the `else` branch), so a map authored with both `map` and `filter` — two documented inputs, no legacy shape required — lost `latitudeField` / `longitudeField` / `titleField`, failed `extractCoordinates` on every record, and rendered zero markers under a "N records with missing or invalid coordinates excluded from the map" banner. The only console output was `[ObjectMap] Invalid map configuration:` from the Zod parse of a function.
-
-This was reachable two ways and both are fixed by the same deletion: an author writing `filter` alongside `map`, and the `dataSource` binding of objectstack#7121, whose merged filter is an `and` node — `['and', [...], [...]]`, still an array, still carrying `Array.prototype.map`.
-
-Both legacy reads are gone; the map consumes only what it declares. The `map` config, the top-level `locationField` / `latitudeField` branch, and the `style` / `mapStyle` reads are untouched, and `filter` is passed to the query verbatim — a field genuinely named `map` still filters on it, and nothing is stripped from the author's filter.
-
-A schema still carrying the legacy `filter.map` stash now gets a dev-mode warning naming the shape and pointing at `schema.map`, rather than silently falling back to the default field names. It is deliberately narrow: own properties only (so an inherited `map` method never triggers it) and object-valued only (so `filter: { map: 'x' }` reads as a filter on a field named `map`), and it warns once per distinct stash because `getMapConfig` runs on every render. Production behavior is unchanged beyond the configuration no longer being read.
diff --git a/.changeset/olive-donkeys-shake.md b/.changeset/olive-donkeys-shake.md
deleted file mode 100644
index 5aae0be1cf..0000000000
--- a/.changeset/olive-donkeys-shake.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
----
-
-Compile-time only, no published behaviour change (objectui#4281).
-
-`action:button` and `action:icon` composed their `execute({ … })` payload as a
-single object literal that spread `localContext`. That binding is `any`, and a
-literal spreading an `any` is not excess-property checked — so those two
-renderers absorbed invented and misspelled action keys in silence while
-`action:group` and `action:menu`, whose payloads carry no spread, rejected them
-with `TS2353`. `ActionDef` being a closed surface (objectui#4046) bought those
-two sites nothing.
-
-Their explicit keys now live in an `ActionDef`-annotated binding, with the
-spreads composed around it, which restores the check. The object that reaches
-the runner is unchanged — the same keys, the same values, the same insertion
-order, and the same "host context overrides the authored key" precedence, each
-pinned in `action-forward-precedence.test.tsx`.
-
-`check:action-forward-parity` now enforces the property structurally, so a
-renderer added with the unchecked shape fails CI instead of silently reopening
-the hole.
diff --git a/.changeset/olive-eels-shave.md b/.changeset/olive-eels-shave.md
deleted file mode 100644
index 692e402b8b..0000000000
--- a/.changeset/olive-eels-shave.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
----
-
-Test-only change to `@object-ui/plugin-charts`: `ObjectChart`'s category option-color
-probe (`/api/v1/meta/dataset/*` and `/api/v1/meta/object/*`) is now answered by a
-recording test double in the four suites that were escaping to the real network, and
-its request shape and success path get their first coverage. No published behaviour
-changes.
diff --git a/.changeset/phantom-dependency-gate-4394.md b/.changeset/phantom-dependency-gate-4394.md
deleted file mode 100644
index 66116b7f6b..0000000000
--- a/.changeset/phantom-dependency-gate-4394.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-`@object-ui/plugin-detail` now declares `react-router-dom` as a peer dependency (`^6.0.0 || ^7.0.0`), the range its three siblings already use.
-
-It has been importing the router all along — `PermissionFacetLink.tsx` and `record-reference-rail.tsx` both take `Link` and `useParams` from it — while its manifest named it in no field at all. That resolved locally for a reason that does not travel: the workspace root declares `react-router-dom` in its own `devDependencies`, so a `node_modules/react-router-dom` symlink exists at the root of this repository and Node's upward directory walk reaches it from every package directory. A consumer's install has no such root, and this package's rollup config externalises every bare specifier, so the published `dist/index.js` carried an import of a package the manifest never asked for.
-
-Consumers already installing `@object-ui/app-shell`, `@object-ui/layout` or `@object-ui/plugin-designer` were unaffected — all three declare the same peer — so this closes the case of a consumer that pulls `plugin-detail` on its own.
-
-A new repository gate, `pnpm check:phantom-deps`, now asserts that every bare specifier a released package imports under `src/` is declared by that package rather than merely resolvable from it, so the next one of these fails on the pull request that introduces it (objectui#4394).
diff --git a/.changeset/picklocalized-own-props-3907.md b/.changeset/picklocalized-own-props-3907.md
deleted file mode 100644
index 3a53858476..0000000000
--- a/.changeset/picklocalized-own-props-3907.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/i18n': patch
----
-
-`pickLocalized` reads own properties only, and takes only string values, on every limb
-
-The resolver read four of its six limbs — the exact tag, the base language, `default` and `en` — with a bare bracket access. Bare access walks the prototype chain, so a locale that happened to name an `Object.prototype` member resolved to that member and the function stringified it into the label: `pickLocalized({ en: 'Pricing' }, 'constructor')` returned `function Object() { [native code] }`, and the same held for `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`. Those same four limbs also skipped the `typeof === 'string'` filter the regional and last-resort limbs already applied, so a non-string value short-circuited the chain and rendered as `[object Object]`.
-
-Both guards now apply uniformly. A guarded limb **misses** rather than aborting the resolution, so an unusable entry falls through to the next limb exactly as an absent one does — `pickLocalized({ en: 'Pricing' }, 'constructor')` is now `'Pricing'` (the `en` limb), and only a map with no usable entry at all resolves to `''`. An empty-string value is still a hit, because `''` is a label the author wrote.
-
-No real language tag can observe this: no BCP-47 tag is an `Object.prototype` member, and the inline locale map is declared `z.record(, z.string())`, so every in-contract input resolves byte-identically to before. What it changes is agreement with the backend twin `resolveI18nLabel` (objectstack#6765), which shipped with exactly these two narrowings recorded as deliberate departures from this function because on a server the locale can arrive in an `Accept-Language` header. That recorded rule divergence is now zero; the only remaining difference is how each side spells a miss (`''` here for a text node, `undefined` there for a producer's fallback chain), which is pinned as an identity in the cross-resolver parity table.
diff --git a/.changeset/pivot-null-bucket-4056.md b/.changeset/pivot-null-bucket-4056.md
deleted file mode 100644
index 32412ce23e..0000000000
--- a/.changeset/pivot-null-bucket-4056.md
+++ /dev/null
@@ -1,50 +0,0 @@
----
-"@object-ui/core": patch
-"@object-ui/plugin-dashboard": patch
-"@object-ui/plugin-report": patch
----
-
-Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
-
-objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
-with `JSON.stringify`, because every delimiter that had been tried — an empty
-string, a plain space, a control character — assumed the data would not contain
-it, and each assumption failed on ordinary data. This closes the last place the
-same assumption survived: the ids were JSON, but the VALUES fed into them were
-spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
-ordinary string `"∅"` and shared a bucket with a row whose value literally is
-that character (U+2205). One bucket, later row overwriting the earlier one — the
-cell showed a different row's measure, the overwritten row was unreachable, and
-drill-through followed the same wrong index into the wrong records, all without
-an error. The trigger requires that character to appear as a dimension value, so
-this is the assumption being removed rather than a defect users hit today.
-
-An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
-bare `null` that no string can spell. The normalization lives in
-`@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
-its string form) rather than at each call site, because a placeholder spelled by
-a caller is a placeholder that can collide again — which is exactly how this one
-survived the previous fix. `pivotBucketId` accepts `Array`
-accordingly; that is a widening, so existing callers passing `string[]` are
-unaffected.
-
-Both renderers' bucket keys move together, which the fix requires: a bucket id
-and the subtotal map keyed by it are built from the same expression, so changing
-one alone would split the headers while the subtotal map still merged, landing
-every column subtotal under the wrong header. In `plugin-dashboard`'s
-`DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
-and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
-`DatasetReportRenderer` the single `bucketId` helper already feeds all five.
-
-The dashboard's column bucket id also stops being a bare string and becomes a
-one-element tuple through the same shared encoder. It was the one id in the
-family still built by hand, on the reasoning that a single value needs no
-boundary — true of the boundary, false of everything else the encoder does, and
-it is why the across axis kept carrying this collision after the row ids were
-fixed.
-
-No display change: these placeholders only ever entered ids, never labels. An
-unset dimension still renders through `formatDimensionValue` exactly as before,
-and data containing neither an absent value nor that character buckets
-identically — the ids are opaque lookup keys, never parsed back into a value,
-never shown, never persisted.
diff --git a/.changeset/react-type-checks-its-tests.md b/.changeset/react-type-checks-its-tests.md
deleted file mode 100644
index 9739ba7a01..0000000000
--- a/.changeset/react-type-checks-its-tests.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
----
-
-chore(react): `@object-ui/react` type-checks its whole test tree.
-
-No published code moves. The package gains a `tsconfig.test.json` chained from
-`type-check`, its 43 code-tier test errors are fixed in the tests themselves,
-and the narrow `tsconfig.typetests.json` — the rescue hatch for a package still
-in `TEST_DEBT` — is retired now that the full project compiles the same file
-(objectui#4040 tranche 4, under objectui#4291's ratchet).
diff --git a/.changeset/record-details-retire-layout-input-3818.md b/.changeset/record-details-retire-layout-input-3818.md
deleted file mode 100644
index 049335d15f..0000000000
--- a/.changeset/record-details-retire-layout-input-3818.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-`record:details` stops publishing a `layout` key the spec removed and the renderer never honoured
-
-`record:details` declared `layout: enum ['auto','custom']` with `defaultValue: 'auto'` and the description "auto uses the object highlightFields; custom uses explicit sections". None of that was ever implemented. The renderer's only `schema.layout` read tested `'inline'` | `'compact'` — two values the schema never permitted — so both legal values fell through the same ternary and the key selected nothing. `auto` and `custom` have behaved identically for as long as both have existed.
-
-Two directions were wrong with zero diagnostics: `layout: 'auto'` plus explicit `sections` still rendered the sections, and `layout: 'custom'` with no sections silently fell back to the flat body rather than reporting the missing groups. Because the input carried a `defaultValue`, this was not stale documentation — it was the manifest, the generated `sdui-intrinsics.d.ts` and the designer panel actively offering the key. An AI author writing `layout: 'custom'` believed it took effect.
-
-`@objectstack/spec` 17.0.0 removed the property (objectstack#6946, ADR-0087 D2); `17.0.0-rc.6` is pinned here, so the key is already rejected on parse with a named migration message pointing at `os migrate meta --from 16`. This release completes the objectui half of that retirement: the input declaration is gone, and so is the dead `inline`/`compact` branch — the synthesized layout is now the constant it always resolved to.
-
-Nothing that worked stops working. The body-source contract is unchanged and is now the only one declared: **`sections` renders the explicit groups; omitting it falls back to the flat body derived from the object's fields.** That is pinned in both directions, plus the empty-array boundary between them, in `recordDetailsBodySource.test.tsx`.
-
-One gate got sharper on the way through. The parity test's "declares no top-level input the spec does not accept" check read raw `.shape` keys — but an ADR-0087 D2 tombstone stays *in* the shape as a `z.never()`, so a retired key still answers "is this declared?" with yes. That is precisely why this input survived the rc.6 pin bump with every derived gate green. The check now filters tombstoned members out, so it catches the next D2 retirement instead of waving it through.
diff --git a/.changeset/record-picker-filter-input-3830.md b/.changeset/record-picker-filter-input-3830.md
deleted file mode 100644
index fc2bce209f..0000000000
--- a/.changeset/record-picker-filter-input-3830.md
+++ /dev/null
@@ -1,41 +0,0 @@
----
-"@object-ui/components": patch
----
-
-`element:record_picker.filter` is now discoverable from the published `inputs`
-
-The fourth A-class gap of objectui#3808's own list, and the one its three-way
-triage dropped: `filter` appears in that issue's raw key dump for this block and
-then in none of its A / B / C lists, so the change that added the repo-wide
-parity gate exempted it by name instead of declaring it. It is the same shape as
-the four #3808 fixed — `@objectstack/spec` declares
-`ElementRecordPickerProps.filter`, the renderer has read it all along
-(`composed?.filter ?? props.filter`, straight into the picker query's `$filter`),
-and the registry `inputs` never mentioned it.
-
-`element:record_picker` is not in the public tier ("record picking is a field
-widget, not a page block"), so the gap was not in `sdui.manifest.json` — it was
-in the JSX-page compiler's prop whitelist, which `renderers/layout/page.tsx`
-builds from `getKnownTypes()` plus these same `inputs`. A JSX page writing
-`filter` therefore got an `unknown-prop` warning from `sdui-parser`'s prop walk
-on the very key that decided which records the picker offered, and the designer
-panel gave an author no way to discover the key existed at all.
-
-The description is derived from what the renderer does, not from restating the
-spec's one-liner, because the one thing an author cannot read off the spec is
-which of the two places they may write a filter wins: a node-level `dataSource`
-filter (itself AND-combined with any saved `view` it names) is taken and this
-top-level `filter` is DROPPED, not merged — so this key applies only when the
-node carries no `dataSource` filter.
-
-`type` is `'object'`, taken from the spec's actual shape on the resolved pin
-rather than the `'array'` the issue's landing sketch guessed:
-`FilterConditionSchema` is `z.record(z.string(), z.unknown())` intersected with
-the `$and` / `$or` / `$not` group, so a rule array is rejected. This is the one
-key in the family where `ComponentInput`'s coarse typing costs nothing —
-`sdui-parser`'s `checkType` accepts exactly the values the spec accepts here, so
-unlike `element:text_input.defaultValue` there is no narrowing to disclose.
-
-The parity gate's explicit exemption for this key is deleted in the same change
-(its own `carries no stale unpublished-key exemption` assertion demands it), and
-the key joins #3808's four in the by-name "declared, not merely not-failing" pin.
diff --git a/.changeset/record-picker-sort-limit-empty-text-4167.md b/.changeset/record-picker-sort-limit-empty-text-4167.md
deleted file mode 100644
index 1916bbff0d..0000000000
--- a/.changeset/record-picker-sort-limit-empty-text-4167.md
+++ /dev/null
@@ -1,46 +0,0 @@
----
-"@object-ui/components": minor
----
-
-`element:record_picker` publishes `sort`, `limit` and `emptyText` as authoring
-inputs (objectui#4167).
-
-All three were already READ by the renderer and declared by the contract — the
-renderer has passed `sort` into `$orderby` and `limit` into `$top` since the
-block existed, and `emptyText` decides the no-rows message — but none of them
-appeared in `inputs`, so every layer that reads a manifest said they did not
-exist. `packages/components/src/renderers/layout/page.tsx` builds the JSX-page
-compiler's prop whitelist from `getKnownTypes()` plus these `inputs`, so writing
-any of the three on a JSX page drew an `unknown-prop` warning from
-`sdui-parser/src/validate.ts` on a key the renderer then went on to honour.
-
-That is objectui#3407's shape — honoured, undiscoverable — and this is the same
-repair objectui#3808 made for `record:details.hideFields` and objectui#3830 made
-for `element:record_picker.filter`. `@objectstack/spec` 17.0.0-rc.6 is what made
-it actionable: objectstack#5775 declared the three upstream, and the reverse
-direction of the console's registry parity gate went red demanding them the
-moment the pin moved — a red the previous exemption had predicted in writing and
-called "correct and wanted".
-
-Each description documents the renderer's real behaviour rather than restating
-the schema, because that is the half an author cannot read off the contract:
-
-- **`sort`** and **`limit`** are both overridden OUTRIGHT by a node-level
- `dataSource` binding (`dataSource.sort ?? sort`), not merged with it — so a
- node that carries a `dataSource` silently ignores them.
-- **`limit`** defaults to 50 in the renderer, not in the schema, and a record
- outside the limit cannot be picked at all with nothing in the control to say
- more exist.
-- **`emptyText`** is published as `string` against a contract of
- `string | Record< string, string >`: rc.6 widened it to `I18nLabel`, and this
- renderer passes the value straight into a text node with no locale resolution,
- so only the plain-string form renders today. The description says so rather
- than advertising a shape the renderer drops — the narrowed-type treatment
- objectui#3832 describes, with the render-site gap tracked in objectui#4163.
-
-The console's `registry-inputs-spec-parity` suite also drops all twelve of its
-off-spec exemptions, which rc.6 obsoleted at once (objectstack#6776 declared
-`page:header.recordChrome` / `showStar` / `showCopyId`, `page:accordion.variant`
-and `page:tabs.tabStyle`; objectstack#5775 declared the `element:record_picker`
-trio and `children` on the four page containers). The forward direction of that
-gate now runs with no cover of any kind.
diff --git a/.changeset/render-save-advisory-findings-4133.md b/.changeset/render-save-advisory-findings-4133.md
deleted file mode 100644
index 348ef9eaae..0000000000
--- a/.changeset/render-save-advisory-findings-4133.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@object-ui/data-objectstack': patch
-'@object-ui/app-shell': patch
-'@object-ui/i18n': patch
----
-
-Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
-
-The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
-
-The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
-
-`MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
-
-The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
-
-**What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active *does* run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
diff --git a/.changeset/required-when-submit-verdict-4161.md b/.changeset/required-when-submit-verdict-4161.md
deleted file mode 100644
index 45256dc6d3..0000000000
--- a/.changeset/required-when-submit-verdict-4161.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/components': patch
----
-
-Conditional required (`requiredWhen`) now decides at SUBMIT time too — the star and the validator can no longer disagree
-
-A `requiredWhen` predicate that flipped to FALSE after the dialog mounted updated only half the form. The display layer re-evaluated correctly — the asterisk and `aria-required` both disappeared — while submit stayed refused with " is required" and no write was ever issued. The user saw an optional field and a form that would not save, with nothing on screen naming the field it was still waiting on (objectui#4161).
-
-The cause is not a mount-time snapshot, which is what the symptom looks like. The renderer hands react-hook-form its per-field rules as a `` prop, and RHF *merges* that object into the field descriptor it already holds — `_f: { ...previous._f, ...options }`. A rule key that stops being spelled is therefore never removed. Rules could be ADDED live (a predicate flipping TRUE after mount did start enforcing, correctly) but never withdrawn: the `validate.required` entry installed the first time the predicate evaluated TRUE outlived every later FALSE verdict. The validation layer was append-only, latched on the first TRUE the field ever produced.
-
-The `validate.required` entry is now registered unconditionally and decides required-ness when it *runs*, reading the live verdict the renderer publishes on every render — the same single `resolveFieldRuleState` result that draws the asterisk, not a second evaluation of the predicate with its own copy of the record assembly. Both directions are pinned: a predicate flipping FALSE re-opens submit, a predicate flipping TRUE starts enforcing, and statically required fields are unaffected.
diff --git a/.changeset/reserved-auth-feature-flags-2514.md b/.changeset/reserved-auth-feature-flags-2514.md
deleted file mode 100644
index ebbafdae37..0000000000
--- a/.changeset/reserved-auth-feature-flags-2514.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/auth': patch
----
-
-`features.passkeys` and `features.magicLink` are documented as reserved, so enabling them no longer implies a login-page entry point that does not exist
-
-`GET /api/v1/auth/config` advertises eight login-surface capability flags. Six of them gate something real — `sso` gates the "Sign in with SSO" button, `phoneNumberOtp` the verification-code mode, `deviceAuthorization` the device-approval page — and each carries a doc comment saying what it gates. Two did not gate anything: `passkeys` and `magicLink` existed only as two undocumented lines in `AuthPublicConfig.features`, consumed by no component, no route and no metadata. A deployer who turned one on got a flag that changed nothing, with nothing anywhere to say so. That is the mirror of the defect the audit was looking for (framework#2874 P2②): not UI advertising a capability the backend lacks, but a backend advertising a capability the UI lacks.
-
-Both flags are now marked reserved at the two places a deployer meets them. The declarations in `packages/auth/src/types.ts` carry doc comments — which ship in the published `.d.ts`, so the warning appears on hover — stating that enabling the flag adds no entry point and naming the follow-up card. `packages/auth/README.md` gains a "Server Feature Flags" section documenting what the `features` map is for and a "Reserved flags" table saying the same thing in prose. Per the maintainer's ruling on objectui#2514, the UI is deliberately not built here; it is filed as objectui#4179 and left unscheduled.
-
-No runtime behaviour changes: nothing read these flags before and nothing reads them now. What changes is that the published types and docs stop being silent about it.
-
-The three artifacts are pinned together by `packages/auth/src/__tests__/reserved-auth-features.test.ts`, which asserts the declarations still exist, that each carries a reserved marking naming the card, that the README section says the same, and — the inverse direction — that no source file under `packages/*/src`, `apps/*/src` or `examples/*/src` references either identifier. The sweep covers `.json` as well as `.ts`/`.tsx` because `AppContent` hands the whole `features` object to `ExpressionProvider`, so authored metadata can reach these flags without any source file naming them. Two calibration cases keep the pin from rotting into a vacuous pass: one asserts the sweep reads a non-trivial number of files, the other that it can still find a flag that *is* consumed. When the UI is eventually built, the pin fails and its docblock spells out the retirement checklist — the doc comments, the README section and the pin itself come out in the same PR.
-
-`features.twoFactor` is untouched and explicitly excluded: two-factor is implemented in this package (`enableTwoFactor` / `verifyTwoFactor`) and its challenge is driven by server-side remediation rather than by the flag, so `LoginForm` not reading it is the design rather than a gap. The README says so, and the pin asserts `twoFactor` never appears in the reserved table.
diff --git a/.changeset/retire-action-engine-event-mapping-3368.md b/.changeset/retire-action-engine-event-mapping-3368.md
deleted file mode 100644
index 5d859ccbbc..0000000000
--- a/.changeset/retire-action-engine-event-mapping-3368.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-'@object-ui/core': minor
----
-
-Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
-`ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
-`ActionMapping` interface are removed under enforce-or-remove: all four were public surface
-of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
-mapping, so `dispatch()` had no reachable caller either, and every call site was in the
-engine's own test file.
-
-Breaking for anyone who typed against or called the removed declarations, marked `minor`
-per this repository's version-alignment convention (the major tracks `@objectstack`, never
-an API-break count). Actions are still entered by name (`executeAction`), by location
-(`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
-only the event-keyed entry point is gone, and no runtime behaviour changes because no
-runtime path reached it.
-
-The three ways the retired condition gate had drifted from the `visible` contract that
-`getActionsForLocation` implements die with the path rather than being fixed on it: it
-entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
-`string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
-`@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
-failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
-API nobody calls would only have widened behaviour nobody uses.
diff --git a/.changeset/retire-cloud-operations-surface-4152.md b/.changeset/retire-cloud-operations-surface-4152.md
deleted file mode 100644
index a1579af97b..0000000000
--- a/.changeset/retire-cloud-operations-surface-4152.md
+++ /dev/null
@@ -1,71 +0,0 @@
----
-'@object-ui/data-objectstack': minor
----
-
-data-objectstack: retire the phantom `CloudOperations` surface — the class, its three `Cloud*` types, and the module that claimed to integrate a cloud namespace no client has ever shipped
-
-`src/cloud.ts` exported a `CloudOperations` class with four methods, all
-re-exported from the package entry, so this was published surface of
-`@object-ui/data-objectstack`. Every method optional-chained into
-`client.cloud?.…`, and no released `@objectstack/client` has ever exported a
-`cloud` namespace. Re-measured at `17.0.0-rc.6` before deleting: the module's
-export list is `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`,
-`QueryBuilder`, `FilterBuilder`, `createQuery`, `createFilter`, and a
-constructed client's `.cloud` is `undefined`. The nearest real namespaces on the
-instance — `projects` (which owns `/api/v1/cloud/environments`) and `packages`
-(which owns marketplace installs) — are not what these methods reached for.
-
-So every call resolved `undefined` and fell through to a literal:
-
-| method | what it returned, always |
-|:--|:--|
-| `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
-| `getDeploymentStatus` | `{ status: 'unknown' }` |
-| `searchMarketplace` | `[]` |
-| `installPlugin` | `{ success: false }` |
-
-The maintainer's 2026-08-11 ruling removed it rather than repairing it, and named
-the reason: `deploy()` did not degrade to an error, it **manufactured a
-plausible success**. A caller got a well-formed `deploymentId` for an operation
-that never left the process and then polled it forever against
-`{ status: 'unknown' }`. That is the most dangerous shape for an AI consumer,
-which builds downstream logic on the fake id instead of getting suspicious.
-Under the startup-focus principle a declared capability with no producer, no
-consumer and no business pull is retired, not stubbed.
-
-**Breaking, in FROM → TO form.** `CloudOperations`, `CloudDeploymentConfig`,
-`CloudHostingConfig` and `CloudMarketplaceEntry` are no longer exported from
-`@object-ui/data-objectstack`. It is a `minor` under this repo's version policy
-(objectui's own breaking changes never declare `major`). Nothing broke that was
-working: the only in-repo construction site was a test, and every method's
-observable behaviour was a fabricated constant.
-
-**No compile-compat stub was left.** The ruling allows one — throwing loud
-`NotImplemented` — only where a compile need is demonstrated. Measured across the
-whole repository, the sole importers were the package's own `index.ts`,
-`v3-compat.test.ts` (three cases asserting the fallback had the right *keys*,
-which is how the emptiness stayed green) and objectui#3720's vocabulary pin. No
-app, no other package, no doc. With no consumer to keep compiling, a stub would
-be a second phantom surface guarding the first.
-
-The false module header went with it — it read `Cloud namespace integration for
-@objectstack/spec v3.0.0 / Replaces the legacy Hub namespace`, against a resolved
-spec of `17.0.0-rc.6` and schemas this package never consumed.
-
-**objectui#3720's pin retires with its subject.** `cloud-environment-vocabulary.pin.test.ts`
-pinned the doc comment on `CloudDeploymentConfig.environment` — the deliberate
-three-member deploy-target vocabulary and the `staging`-is-not-a-discovery-member
-trap. Every fact it held was a claim *about* that comment, and its spec-side
-assertions existed only to keep those claims honest; with the type deleted they
-would pin `@objectstack/spec`'s enums on behalf of no local reader — the same
-phantom shape this change closes. #3720's conclusion is unaffected and now moot:
-it found no producer-side deploy-target type to converge onto because the
-producer did not exist, and this change removes the consumer that was waiting for
-it. Its pending empty changeset (`cloud-deploy-environment-vocabulary-3720.md`,
-never released) is removed too, since it announced a deliberate vocabulary on a
-type this same release deletes.
-
-A negative pin (`src/cloud-surface-retired-4152.pin.test.ts`) replaces the
-retired cases and fails if any of the four names returns — reading both the
-runtime export list (which catches the class) and `index.ts`'s source text
-(which is the only instrument that can catch a returning `export type`).
diff --git a/.changeset/retire-common-search-key-4392.md b/.changeset/retire-common-search-key-4392.md
deleted file mode 100644
index 17f50d3491..0000000000
--- a/.changeset/retire-common-search-key-4392.md
+++ /dev/null
@@ -1,43 +0,0 @@
----
-'@object-ui/i18n': minor
-'@object-ui/fields': patch
----
-
-i18n: retire the reader-less `common.search` key from all ten locale packs
-
-`common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
-built its dialog placeholder by concatenating the key with three ASCII full
-stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
-the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
-brought it under objectui#3878's glyph pin. That left `common.search` with zero
-readers repo-wide while it still existed in all ten packs.
-
-Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
-no MDX or JSON reference, and the one dynamic template-literal reader of the
-`common` namespace takes a two-member union parameter (`'openChat' |
-'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
-
-The dormant copy in `@object-ui/fields`' no-provider fallback table
-(`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
-module-local `Record` read only when no `LocalizationProvider`
-is mounted; it is not exported, so removing an entry no reader asks for changes
-no rendered output and narrows no public type. Hence patch for that package,
-while the pack change is a minor: deleting a key from `en` narrows the exported
-`TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
-`common.search` stops type-checking. Same grading, for the same reason, as
-objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
-break.
-
-Retiring a key from `common` was the ruled decision on objectui#4392 rather than
-keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
-reader inherits an unreviewed contract, and a dormant key beside a live
-`table.search` is where a second dialect gets started. The objectui#4328
-dead-surface family has consistently chosen removal for zero-consumer surfaces.
-
-The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
-PR #4397) is a different key and is untouched.
-
-A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
-fails if the key returns to any pack, if any package reads or re-declares it, or
-if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
-runs call site to key, and none of them can see a key with no call site.
diff --git a/.changeset/retire-narrow-typetests-projects-4291.md b/.changeset/retire-narrow-typetests-projects-4291.md
deleted file mode 100644
index 32fb98c519..0000000000
--- a/.changeset/retire-narrow-typetests-projects-4291.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
----
-
-Retire the narrow `tsconfig.typetests.json` projects in the six packages that have graduated out of `TEST_DEBT`
-
-`tsconfig.typetests.json` is a rescue hatch (objectui#3181): a package whose test tree was still in `scripts/check-type-check-coverage.mjs`'s `TEST_DEBT` could compile the one file whose whole value is compile-time assertions — `Assert< Equal< Local, Spec > >` is a `tsc` error or it is nothing — instead of waiting for its whole backlog to compile. As objectui#4040's tranches landed full `tsconfig.test.json` projects, that hatch became redundant in the packages that graduated: the full project already compiles the same file, so the repo carried two spellings of what gets checked plus one extra `tsc` per `type-check` run.
-
-Retired in `@object-ui/auth`, `@object-ui/plugin-chatbot`, `@object-ui/plugin-detail`, `@object-ui/plugin-form`, `@object-ui/plugin-grid` and `@object-ui/plugin-list`, each with its `type-check` chain entry. For every one, `tsc -p tsconfig.test.json --listFiles` was checked to contain the exact file the narrow project named, and a provably-false `Assert` appended to that file turned the FULL project red (TS2344, exit 2) — so the coverage moved rather than vanished. The four packages still in `TEST_DEBT` that have a narrow project (`app-shell`, `components`, `core`, `react`) keep theirs untouched.
-
-`scripts/check-type-check-coverage.mjs` now makes this a ratchet rather than a one-time sweep: a `tsconfig.typetests.json` on a package whose full test project already compiles everything is reported as redundant, so the seventh cannot reappear. Section 5½ had no test coverage at all before this change — the gate keeping the rescue hatch honest was itself unchecked — so it gains a fixture suite plus a real-repository pin on the six retirements.
-
-No published behaviour changes: only `type-check` scripts, checking-only tsconfig projects, test-file comments and a CI gate.
diff --git a/.changeset/retire-object-detail-factory-3731-3736.md b/.changeset/retire-object-detail-factory-3731-3736.md
deleted file mode 100644
index 565747eea0..0000000000
--- a/.changeset/retire-object-detail-factory-3731-3736.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/console': patch
----
-
-Retire the dormant bespoke object-detail page factory and its seven widgets
-
-`buildObjectDetailPageSchema()` had zero callers. Its only consumer was the registry-driven `MetadataDetailPage`, deleted when the console moved onto the metadata-admin engine; the factory outlived it by months as code no route could reach. The seven widgets it fed — `object-detail-tabs`, `object-properties`, `object-field-designer`, `object-relationships`, `object-keys`, `object-data-experience`, `object-data-preview` — were still registered in `ComponentRegistry` at startup, so they were reachable in principle by any schema naming those types, and in practice by none: nothing in the repository produces one.
-
-That unreachability is also why 60 lines of hardcoded Chinese UI copy sat in `objectDetailWidgets.tsx` and `ObjectDetailTabsWidget.tsx` against the English-only rule without any gate seeing them — the strings were bare literals, never `t()` keys, and all three i18n gates judge keys. Translating copy that no user can reach, on a surface with no future, was the more expensive of the two exits; the maintainer ruled REMOVE (objectui#3731 / #3736) and both cards close together.
-
-Deleted: `schemas/objectDetailPageSchema.ts`, `components/schema/objectDetailWidgets.tsx`, `ObjectDetailTabsWidget.tsx`, `ObjectFieldDesignerWidget.tsx`, `registerObjectDetailWidgets.ts`, and the `main.tsx` registration import. No user-visible behaviour changes, because no route rendered any of it. The `skills/objectui/guides/console-development.md` chapter that positioned the factory as the bespoke-editor recipe now points at the live specimen (`PermissionMatrixEditPage`) instead, and the retired names are recorded in that guide's "Retired names" table.
diff --git a/.changeset/retire-report-editor-namespace-4145.md b/.changeset/retire-report-editor-namespace-4145.md
deleted file mode 100644
index 498d354a40..0000000000
--- a/.changeset/retire-report-editor-namespace-4145.md
+++ /dev/null
@@ -1,31 +0,0 @@
----
-'@object-ui/i18n': minor
----
-
-i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
-
-The namespace labelled the hand-rolled report editor form. That form no longer
-exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
-inspector whose labels come from the report spec's own metadata rather than from
-a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
-and it was the objectui#4118 defect itself — the panel borrowing
-`report.editor.title` (the label of the report's Title *field*) to name itself.
-Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
-105 keys with no reader anywhere.
-
-Re-verified before deleting, repo-wide and per key: no `t()` call site, no
-dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
-No user-visible string changes — these keys never rendered.
-
-`report.editor.panelTitle` survives in all ten packs and is untouched; the
-deletion sweeps around it. `report.editor` therefore remains a live namespace
-holding exactly that one key.
-
-This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
-is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
-stops type-checking. No runtime consumer existed to break.
-
-A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
-names all 105 retired keys and fails if any returns to any pack, since every
-existing i18n gate runs call site to key and none of them can see a key with no
-call site.
diff --git a/.changeset/retire-standalone-validation-resource-4132.md b/.changeset/retire-standalone-validation-resource-4132.md
deleted file mode 100644
index c9741bd1a7..0000000000
--- a/.changeset/retire-standalone-validation-resource-4132.md
+++ /dev/null
@@ -1,60 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-metadata-admin: retire the standalone `validation` resource, and move
-`ValidationPreview` onto the embedded path the framework actually evaluates
-(objectui#4132)
-
-`anchors.ts` registered a standalone `validation` resource anchored by
-`anchorByField('object')`, complete with `createFields` / `createSchema` /
-`createDefaults`. That gave every object's Related tab a "Validations" group
-whose `+` routed to `validation/_new`, and the file justified it in a comment:
-"usually embedded in the object, but standalone variants do exist."
-
-They do not. ADR-0088 / objectstack#4509 retired `validation` as a metadata
-kind, and the framework ledger (`packages/spec/liveness/validation.json`)
-records that the door never led anywhere in the first place:
-
-> a STANDALONE `validation` item (file `*.validation.ts` or Studio) never
-> reached any object's write path, because the schema has no object-binding key
-> and — every variant being `.strict()` — an author could not add one; no merge
-> code existed […] A state machine authored through that door saved cleanly and
-> gated nothing.
-
-Re-measured against the installed `@objectstack/spec` 17.0.0-rc.6 by parsing the
-shipped registries rather than grepping source: `validation` is in neither
-`DEFAULT_METADATA_TYPE_REGISTRY` (27 kinds) nor
-`listUnregisteredKindSchemaTypes()` (`connector`, `sharing_rule`, `webhook`). So
-the console was offering an authoring affordance for a kind the framework does
-not have — the "shipped false signpost" shape, where the most confident surface
-in the product is the one for the thing that does not work.
-
-**What is gone**: the standalone registration, its create affordance, and the
-stale comment. **What stays**: the embedded anchor `__object_validation`
-(`editAs: 'validation'`, `embeddedPath: 'validations'`) — rules live inside their
-object, and that is the path the framework evaluates.
-
-**The renderer moved rather than retiring with the door.** `ValidationPreview`
-renders a rule's label, description, severity/message callout, per-variant body
-and tags, and until now the only route that mounted it was `ResourceEditPage`'s
-Preview tab on the retired standalone resource; the governed route (Related tab
-→ `MetadataDetailDrawer` → `EmbeddedItemEditor`) drew a bare `SchemaForm`.
-`EmbeddedItemEditor` now looks a preview up by the anchor's `editAs` and mounts
-it above the form on the live draft. The lookup is generic, so this is not a
-`validation` special case: an embedded sub-type with no registered preview
-(`index`) is unchanged and grows no empty preview chrome. This also re-opens
-objectstack#7427's `validation.label` / `.description` / `.tags` ledger rows,
-which were graded `dead` precisely because the render was unreachable on the
-evaluated path.
-
-**One read went with the door.** `ValidationPreview` painted an
-`object: ` pill, exempted from the objectui#3275 cleanup on the stated
-ground that "anchors.ts registers a standalone `validation` resource … so a
-standalone rule really does carry it". With that registration gone the read has
-no producer, and it never had one on the governed path either — measured,
-`ValidationRuleSchema.safeParse({ type: 'script', …, object: 'account' })`
-returns `unrecognized_keys: ['object']`. The pill could only ever confirm a key
-that makes the rule unsaveable. Its test case is replaced rather than re-spelled:
-the schema's verdict is now the instrument, and the preview is asserted not to
-paint the key.
diff --git a/.changeset/retire-url-action-params-newtab-4097.md b/.changeset/retire-url-action-params-newtab-4097.md
deleted file mode 100644
index 0466689a13..0000000000
--- a/.changeset/retire-url-action-params-newtab-4097.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/core': patch
----
-
-Retire `params.newTab` on a url action — `openIn: 'new-tab'` is the sanctioned spelling
-
-`ActionRunner`'s navigator read a legacy `params.newTab` escape hatch below `openIn` and above the external-URL heuristic. That read is removed, executing the objectstack#6828 maintainer ruling of 2026-08-10, whose contract half shipped in objectstack PR #7375: the url-side readings of an object-form `params` are retired, not renamed.
-
-Nothing that ever validated can regress. `params` is declared as `z.array(ActionParamSchema)`, so an object-form `params` has always failed the props parse — the fallback could only fire on a stack the spec refuses. The removal also closes a collision hazard: a params dialog declaring a field named `newTab` had the user's own collected input silently steering navigation.
-
-`openIn: 'self' | 'new-tab'`, the legacy `navigate.newTab` modifier on the `navigation` shape, and the external/relative default are all unchanged.
diff --git a/.changeset/richtext-spec-spelling-4250.md b/.changeset/richtext-spec-spelling-4250.md
deleted file mode 100644
index ade8feb456..0000000000
--- a/.changeset/richtext-spec-spelling-4250.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/plugin-detail': patch
-'@object-ui/plugin-form': patch
-'@object-ui/app-shell': patch
----
-
-`richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
-
-`@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
-
-For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
-
-The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
-
-`markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
diff --git a/.changeset/rotten-plums-smash.md b/.changeset/rotten-plums-smash.md
deleted file mode 100644
index d8c958253d..0000000000
--- a/.changeset/rotten-plums-smash.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
----
-
-`@object-ui/plugin-list` now type-checks its 31 test files: `tsconfig.test.json` is chained from its `type-check` script and its `TEST_DEBT` entry is gone (#4040).
-
-Test-side and build-config only — no package source changed, so nothing is released by this.
diff --git a/.changeset/rowheight-density-one-answer-4440.md b/.changeset/rowheight-density-one-answer-4440.md
deleted file mode 100644
index c04d7e2fca..0000000000
--- a/.changeset/rowheight-density-one-answer-4440.md
+++ /dev/null
@@ -1,42 +0,0 @@
----
-'@object-ui/core': minor
-'@object-ui/plugin-list': minor
----
-
-`rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
-
-Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
-density vocabulary, and since objectui#4352 they answered differently for the
-same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
-`@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
-`comfortable`. One metadata-driven system, two answers for one input
-(objectui#4440).
-
-The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
-off-spec metadata is a second de-facto contract, and one strict contract beats N
-dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
-rejects it. The five mappings themselves are untouched (`compact`/`short` →
-`compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
-table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
-still fails the build here.
-
-**Breaking semantics, deliberately graded `minor`** (this repo never publishes
-`major` — its major tracks `@objectstack`). Two things change:
-
-- **Published type.** `rowHeightToDensityMode` is exported from
- `@object-ui/core`, and its return widens from `DensityMode` to
- `DensityMode | undefined`. A host assigning the result straight into a
- `DensityMode` now has to say what an off-spec row height should mean to it.
-- **Rendered output, for input the spec already rejects.** `ListView` — the one
- in-repo caller — used to render an off-spec `rowHeight` one step looser than an
- ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
- exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
- A sweep of this repo, the `objectstack` example apps and one downstream app
- found zero authored off-spec values, and the legacy `densityMode` alias cannot
- produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
- `Record< DensityMode, RowHeight >`).
-
-Also closed while retiring the branch: the lookup guarded membership with `in`,
-which walks the prototype chain, so `rowHeight: 'toString'` returned
-`Object.prototype.toString` — a function — from something typed `DensityMode`. It
-is an own-property check now.
diff --git a/.changeset/safe-translation-inline-default-3865.md b/.changeset/safe-translation-inline-default-3865.md
deleted file mode 100644
index c0a2df613c..0000000000
--- a/.changeset/safe-translation-inline-default-3865.md
+++ /dev/null
@@ -1,22 +0,0 @@
----
-'@object-ui/i18n': patch
----
-
-i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
-
-`fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
-**raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
-loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
-showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
-embedding and tests are the whole reason this factory exists).
-
-The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
-which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
-pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
-name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
-The provider path is untouched.
-
-Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
-map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
-`plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
-hosts; the other 6 pass no inline default and still need a map or pack entry.
diff --git a/.changeset/second-client-save-advisories-4237.md b/.changeset/second-client-save-advisories-4237.md
deleted file mode 100644
index e15778aac5..0000000000
--- a/.changeset/second-client-save-advisories-4237.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/data-objectstack': patch
-'@object-ui/app-shell': patch
----
-
-The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
-
-objectui#4133 (PR #4236) put the gate's advisory findings — the ones that ride a **200**, where the save succeeded and the row persisted — in front of Studio authors, but it covered only one of the two client classes that write through `PUT /api/v1/meta/:type/:name`. The wiring lifts at `useMetadataClient`, which is where every app-shell path takes its `MetadataClient` from. `ObjectStackClient.meta.saveItem` — the SDK client hanging off `ObjectStackAdapter` — is a different class reaching the same door, and every one of its callers awaited the call and discarded the response, so an `advisories[]` the server attached was parsed off the wire and dropped one layer further out.
-
-Those callers all write in **active** mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing. The list is `MetadataService` (five saves behind the Object Manager and Field Designer), `useNavigationSync`, plugin-designer's Create/EditAppPage, and the adapter's own `updateViewConfig` / view / `updateDashboard` paths.
-
-`ObjectStackAdapter` now carries an `onSaveAdvisory(listener)` subscription and emits on it after a metadata save whose 200 carried a non-empty `advisories[]`; `AdapterProvider` subscribes once and renders through the same `emitSaveAdvisories` the other client class already uses, so both doors produce one wording on the warning tier that says "Saved" first. The emitter is installed **once at the adapter/client seam** rather than at the call sites: every caller above reaches the save door through the adapter's own long-lived `ObjectStackClient`, so one interception covers all of them, plus any future one, without a toast copied into a dozen places — the same reasoning that put #4133's sink at one factory instead of twenty call sites.
-
-It is a sibling of the `onWriteWarning` channel (#3431/#3455) rather than a second payload pushed down it, which is what `MetadataSaveAdvisoryEvent` already said it was modelled on. `WriteWarningEvent` is a closed shape whose required `droppedFields` means "fields the write legally stripped", so carrying advisories on it would either force every existing subscriber to grow a branch or make the event lie about what happened. The seam's shape is reused; its event type is not. `readSaveAdvisories` is shared unchanged between the two clients — one reader, two call sites — which the response envelopes make possible: the spec puts `advisories` at the save body's top level, and the SDK returns that body verbatim (it strips its `{ success, data }` envelope only when a `data` key is present, and this body has none). That measurement is pinned by tests that drive a real SDK client through a fake `fetch` rather than stubbing the method under test.
diff --git a/.changeset/set-default-view-tab-identity-4211.md b/.changeset/set-default-view-tab-identity-4211.md
deleted file mode 100644
index c46349b165..0000000000
--- a/.changeset/set-default-view-tab-identity-4211.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Set-default on a saved view fires its write again — a stored `id` can no longer rename the tab out from under the overlay read
-
-"Set as default" could do nothing at all: no toast, no request, no change. The filer measured that the adapter cannot produce that — `ObjectStackAdapter.updateView` has no early return between its read and its `saveItem`, so every patch shape it is handed becomes a write — which put the cause above it, in the view switcher.
-
-It was an identity seam. Views reach `ObjectView` through two independent reads of the same `type='view'` metadata namespace: the object definition's `listViews`, keyed by the composer's `.` identity, and the adapter's `listViews()` overlay rows. Everything that decides whether a view is mutable — the tab's `readonly` flag, the `isSavedView` guard, and the early return in all five mutating handlers — asks whether a tab's id is among the overlay keys. So the two reads have to spell the same view's identity the same way, and they had two different spellings to do it with. The overlay side stamped `id` last and was safe; the metadata side built each tab as `{ id: , ...body, ...override }`, with `id` FIRST, so any `id` key inside the merged body or the stored override replaced it.
-
-Both of those are stored documents that really do carry one. `persistViewPatch` writes the whole tab object — its `id` included — back through `updateViewConfig`, so a personalized view leaves an override row carrying an `id`; and a duplicated view copies its source artifact's `id` verbatim into the view body, which `MetadataProvider` spreads into the `listViews` entry. Either way the tab ended up under an id the overlay read had never heard of, the view was classified as system, and — because the set-default, rename and delete entries render only under `!readonly` — the menu entry was **absent** rather than present-and-inert. That is why the symptom reads as "nothing happened" instead of "refused": there was no control left to click, and the guard's toast was never reached.
-
-The fix is one spelling instead of three. `viewRowId` answers "what is this row's identity" (`name` → `id` → `_id`, empty strings skipped) for the producer and every reader, and is idempotent across the overlay normalization so the key written and the key read back cannot drift. `viewEntry` stamps identity last at all three tab-building sites, so a tab id is a property of the key it was looked up by and never of the data. `isSavedViewId` is now the single predicate behind both the tab's `readonly` flag and the handler guard, so the menu and the handler agree by construction rather than by coincidence.
-
-No behavior was loosened to get there: the guard is unchanged, and a genuine system view stays read-only with its mutating entries correctly absent.
diff --git a/.changeset/setup-deep-link-2794.md b/.changeset/setup-deep-link-2794.md
deleted file mode 100644
index cddb0a694a..0000000000
--- a/.changeset/setup-deep-link-2794.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/console': patch
-'@object-ui/app-shell': patch
----
-
-`/setup` is a real address again — the console gets a stable deep link into platform administration instead of bouncing you back to home
-
-Opening `/_console/setup` landed on `/_console/home`. System settings had no direct URL at all: the only way in was clicking the 「系统设置」 card on the home launcher, which meant the entry point could not be bookmarked, could not be pasted into a support runbook, and was asymmetric with Studio, whose front door has been stable for a while.
-
-The route was never missing — it was occupied. `/setup` mounts the first-run owner-bootstrap wizard (ported here when the Account SPA was retired), and that page evicts everyone it is not meant for: a signed-in visitor via `window.location.assign('/')`, which the landing resolver then turns into `/home` on any multi-app deployment. So the bounce was the wizard doing its job at a URL that had quietly acquired a second, more common meaning.
-
-`/setup` now decides between the two, on the condition the wizard itself already probes — whether the deployment has an owner (`GET /api/v1/auth/bootstrap-status`). No owner yet: the wizard, unchanged. Otherwise: the platform-administration deep link. A live session short-circuits the probe entirely, because `hasOwner: false` cannot be true while somebody is signed in — which also keeps a failed probe from re-creating the bounce it is meant to remove. The verdict is latched for the lifetime of the mount, because `signUp()` flips the session to authenticated while the wizard is still renaming the bootstrap organization, and re-deciding on that flip would unmount the wizard mid-submission.
-
-The destination is read from metadata rather than spelled out. `SetupRedirect` (new, exported from `@object-ui/app-shell` alongside `SystemRedirect`, with its policy available as the pure `resolveSetupAppPath`) resolves the Setup app through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app ROOT — so the page you land on is whatever `AppContent` already resolves as that app's landing item, not a second copy of that policy that would drift the next time Setup's navigation is re-ordered. Search and hash carry across the hop, as they do for `SystemRedirect`.
-
-Two edges are handled rather than papered over. An unauthenticated deep link now goes to `/login?redirect=%2Fsetup` through the console's existing auth-redirect contract — router-derived, so it stays correct under a ` ` mount — and lands back on `/setup` after signing in; previously it reached a bare `/login` and the deep link was dropped. And a viewer whose metadata contains no Setup app (the common cause is not a broken build but a missing `setup.access` permission, which filters the app out server-side) gets the shell's ordinary "App not available" screen, with its retry and its one-shot metadata re-check — never a silent landing on home, and never the bare `/apps/setup` pseudo-route, which would have resolved to whichever app happens to be the default.
-
-`/_console/studio` was checked for the same asymmetry and needed no change: bare `/studio` is a declared front door rendering the builder landing, and `/studio/:packageId` already redirects to its Data pillar.
diff --git a/.changeset/setup-exit-console-basename-4181.md b/.changeset/setup-exit-console-basename-4181.md
deleted file mode 100644
index 1ead01b50f..0000000000
--- a/.changeset/setup-exit-console-basename-4181.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/console': patch
----
-
-The first-run setup wizard no longer drops a brand-new owner outside the console
-
-On a console served under a mount — `/_console/`, which the framework CLI configures for every embedded deployment by injecting a ` ` — finishing the first-run owner bootstrap landed the new owner on the ORIGIN root instead of the console. Both of `SetupPage`'s exits navigated to a bare `/`: the success path after the account is created and the bootstrap organization renamed, and the bounce that sends an already-signed-in visitor away. `window.location.assign` does not go through React Router, so its `basename` never applied and a root-relative `/` left the SPA. It is the worst possible moment for a dead end — the first screen after creating the account, on a deployment that by definition has no other account to recover with.
-
-Under the default `/` mount the prefixed and unprefixed spellings are identical, which is why no standalone `os dev` run ever surfaced this.
-
-Both exits now go through the console-mount helper `LoginPage` already used for exactly this, so they land inside the SPA under every mount. They stay full-page navigations deliberately: the console shell mounts its metadata tree as soon as auth *resolves* rather than when it authenticates, and re-keys it only on language, so the app list read while nobody was signed in would survive a router navigation and leave the new owner in an appless console. Tearing the document down is what guarantees the console rebuilds with the session.
-
-The helper itself was module-private to `LoginPage` and had already been copied verbatim into `RegisterPage`. It now lives in one place with all three auth surfaces importing it, so the next mount fix lands once rather than three times. `LoginPage` and `RegisterPage` behaviour is unchanged, and pinned as unchanged across all three mount configurations.
diff --git a/.changeset/shared-inbox-feed-4225.md b/.changeset/shared-inbox-feed-4225.md
deleted file mode 100644
index 882ce93b07..0000000000
--- a/.changeset/shared-inbox-feed-4225.md
+++ /dev/null
@@ -1,35 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Home's action centre stops counting messages the user has already read, and the inbox is read once per page instead of twice (#4316, #4225)
-
-`useHomeInbox` read `sys_inbox_message` and nothing else — it never joined
-`sys_notification_receipt`, where ADR-0030 (resolved decision 2) puts read-state.
-So Home's "Needs your attention" card could not tell a read message from an
-unread one: it listed the five most recent unconditionally and badged them. A
-user who opened the bell, read all nine messages and returned to Home still found
-up to five of them filed as work waiting on them — while the bell two hundred
-pixels above correctly showed zero, because its own poll did join the receipts.
-One page load, two panels, opposite claims about the same rows (#4316).
-
-The fix is the one #4225 sketched: `hooks/sharedUserFeeds.ts` gains an inbox feed
-holding the bell's already-joined 20-row window, polled once at the bell's 10s
-cadence, and BOTH consumers derive from it — the bell lists the window and badges
-its unread topics, Home takes the unread ones newest-first and caps them at its
-own smaller limit. Home's second query is gone (one `sys_inbox_message` read and
-one `sys_notification_receipt` read per page, not two and one), and the two
-surfaces can no longer disagree about a row's read-state, because there is no
-second read left to drift from the first.
-
-Two supporting changes travel with it, both visible only when something goes
-wrong. The shared store now reports per-feed status in the same four words the
-rest of the console uses (`idle` / `loading` / `ready` / `error`, per #4300's
-one-dialect ruling): it used to swallow every failure into "keep the last value"
-and say nothing, which is indistinguishable from a successful re-read, and would
-have turned #4235's hard-won `error` state back into stale-but-confident data on
-its way through the store. A missing object (404 / `OBJECT_NOT_FOUND`) is still
-an answer — the deployment has no inbox, so nothing is waiting — and a denial
-still is not. The bell's hidden-tab throttle, its return-to-tab refetch and its
-failure backoff moved into the store with the poll rather than being dropped, and
-now apply to every shared feed.
diff --git a/.changeset/show-empty-related-plural-base-key-3863.md b/.changeset/show-empty-related-plural-base-key-3863.md
deleted file mode 100644
index 53b630864e..0000000000
--- a/.changeset/show-empty-related-plural-base-key-3863.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/i18n': patch
-'@object-ui/plugin-detail': patch
----
-
-`detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
-
-This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
-
-The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
-
-The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
-
-Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
diff --git a/.changeset/sidebar-collapse-survives-reload-4234.md b/.changeset/sidebar-collapse-survives-reload-4234.md
deleted file mode 100644
index 902f8c1233..0000000000
--- a/.changeset/sidebar-collapse-survives-reload-4234.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/components': patch
----
-
-A collapsed sidebar now survives a reload — `SidebarProvider` reads the `sidebar_state` cookie it has always written
-
-The cookie half of this feature only ever ran in one direction. `setOpen` wrote `sidebar_state` on every toggle with a 7-day max-age, and nothing ever read it back: `SidebarProvider` seeded its state from `defaultOpen` (default `true`), so a sidebar you collapsed came back expanded on the next load with the correct cookie sitting right there, unread. QA measured it at 255px and `data-state=expanded` at +2s, +4s and +8s after load, reproduced three times.
-
-Upstream Shadcn closes this loop in a **server component** — it reads the cookie there and passes the value down as `defaultOpen`. A pure SPA like the console has no such step, which is why nothing downstream could paper over it: passing a cookie-derived `defaultOpen` from one shell would have fixed that shell and left every other consumer of the primitive broken. The read therefore happens client-side, in the provider, as a lazy `useState` initialiser rather than a mount effect — the state has to be right on the first render, since a post-mount correction would still flash an expanded sidebar at the user.
-
-Precedence is now pinned, in this order: a controlled `open` prop, then the cookie, then `defaultOpen`, then `true`. The cookie overrides the *default*, never a controlled usage. With no cookie present the behaviour is exactly what it was before, which is what keeps explicit `defaultOpen={false}` call sites — the marketing demos in `apps/site` — rendering unchanged; those cases are controls in the new test file and are green on both sides of the change.
-
-Only the two values the writer produces are honoured (`"true"` / `"false"`), matched on an exact cookie name; anything else, including an absent or malformed value, falls through to `defaultOpen` rather than inventing a preference the user never expressed. The reader is SSR-safe, which `apps/site` needs: those primitives are `"use client"`, and Next still renders them on the server for the initial HTML, where there is no `document`.
-
-Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the primitive itself only gains two anchored one-liners. All of the parsing lives in `packages/components/src/lib/sidebar-cookie.ts`, which the sync never touches, and the two edits are declared in `scripts/shadcn-local-patches.mjs` so `pnpm shadcn:update` re-applies them instead of silently reverting the fix — the same mechanism already used for the translated `Sheet`/`Dialog` close labels.
diff --git a/.changeset/six-bare-keys-defaults-4396.md b/.changeset/six-bare-keys-defaults-4396.md
deleted file mode 100644
index f5f4b855bd..0000000000
--- a/.changeset/six-bare-keys-defaults-4396.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-'@object-ui/plugin-detail': patch
-'@object-ui/plugin-list': patch
-'@object-ui/plugin-designer': patch
----
-
-Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
-
-`detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
-
-Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
diff --git a/.changeset/sort-relational-hint-stored-field-4294.md b/.changeset/sort-relational-hint-stored-field-4294.md
deleted file mode 100644
index 5dfa2600c0..0000000000
--- a/.changeset/sort-relational-hint-stored-field-4294.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-'@object-ui/plugin-list': patch
-'@object-ui/i18n': patch
----
-
-List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
-
-The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: *add a formula field holding it*. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
-
-The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
-
-All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
diff --git a/.changeset/spec-symbol-collisions-rc6-4167.md b/.changeset/spec-symbol-collisions-rc6-4167.md
deleted file mode 100644
index 1a6755052f..0000000000
--- a/.changeset/spec-symbol-collisions-rc6-4167.md
+++ /dev/null
@@ -1,92 +0,0 @@
----
-"@object-ui/types": minor
-"@object-ui/core": minor
-"@object-ui/react": minor
-"@object-ui/app-shell": minor
-"@object-ui/layout": minor
-"@object-ui/fields": minor
-"@object-ui/components": minor
-"@object-ui/plugin-designer": patch
----
-
-Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
-(objectui#4167, objectstack#4115).
-
-The rc.6 bump published nine names this repo already declared locally, on top of
-four that predate it — `check:spec-symbols` reported all thirteen at once, and a
-fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
-triaged on its own rather than blanket-renamed, because the right answer differs
-per symbol: five bind to the spec, three are renamed because the spec's
-same-named export means something else, five arrive by derivation, and one is a
-declared dialect with a written reason.
-
-**Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
-`@object-ui/types`** — three exported names changed, because the spec exports the
-same name for a *different* thing:
-
-| package | was | now | what the spec's same-named export actually is |
-|:--|:--|:--|:--|
-| `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
-| `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
-| `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
-
-`resolveI18nLabel` is the one where the collision had already started costing
-something. rc.6 widened `I18nLabel` from `string` to
-`string | Record< string, string >`, so the same authored value now reaches
-either resolver — and each answers wrongly, silently, for the other's input: the
-keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
-`defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
-tags. The rc.6 bump PR met this and aliased the spec's import as
-`resolveInlineI18nLabel` in five files, with hand-written comments at two of
-them. That is a review convention, which is what objectstack#4115 exists to
-replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
-name says which vocabulary it resolves at every call site.
-
-**Eleven keep their names and are now imported or derived from the spec** instead
-of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
-`BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
-`GanttConfig`, plus the three renamed above at their new names.
-
-**Four of the copies were losing information, not just duplicating it.**
-
-- **`GanttConfig` declared six keys and called itself canonical; rc.6's
- `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
- `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
- `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
- `quickFilters`, `autoZoomToFilter` — are all read by
- `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
- intersection that existed only because this type did not carry them. It now
- derives from the spec, with `timeSegments` (shift segmentation) as the one
- genuinely local extension; the schema is `$loose` upstream, so that key is
- legal metadata rather than a second dialect.
-- **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
- GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
-- **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
- `formatted` were missing, under a comment already claiming to be "the part
- names of `AddressSchema`". The widget still renders five inputs; binding the
- type stops it from asserting the platform cannot store the other two, and makes
- the `{ ...address }` write-through say so.
-- **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
- vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
- own doc comment names this module as one of the three. It is now the spec's
- array by reference, and the local date-macro bounds table is pinned complete
- against it with `satisfies`, so a preset the schema gains without bounds here
- is a compile error rather than a filter that validates clean and then selects
- nothing.
-
-`NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
-it is bound directly, with a both-directions type pin that it stays the same type
-as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
-`BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
-schemas and are now re-exports — "still exact" is the argument for binding them,
-since a copy with nothing to protect can only drift.
-
-`GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
-dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
-new refinement forced `.extend()` to be respelled as a `.shape` spread — which
-moved a derivation the guard could see into an object literal it deliberately
-does not descend into. The dialect is unchanged and its three divergences are
-pinned; which side moves on the refinement itself is objectui#4165.
-
-`@objectstack/spec` moves from `devDependencies` to `dependencies` in
-`@object-ui/layout`: its public type surface now references the spec.
diff --git a/.changeset/sso-landing-setup-only-home-4048.md b/.changeset/sso-landing-setup-only-home-4048.md
deleted file mode 100644
index bff660badb..0000000000
--- a/.changeset/sso-landing-setup-only-home-4048.md
+++ /dev/null
@@ -1,36 +0,0 @@
----
-'@object-ui/console': patch
----
-
-fix(console): a Setup-only environment lands on `/home`, not Setup's all-zero System Overview
-
-A new builder arriving on a just-created environment (platform SSO, no explicit
-target) landed on Setup's **System Overview** — a platform-health/audit
-dashboard reading all zeros, because a fresh environment has no audit history
-yet. The intended first screen is the environment's own home: build with AI,
-start from a template, Your apps.
-
-The path was `resolveLandingPath`'s rule 2. Measured end to end:
-
- / → RootLandingRedirect → resolveLandingPath([setup])
- → rule 2 "single visible app" → /apps/setup
- → AppContent.resolveLandingRoute() → the app's first nav item
- → dashboard/system_overview
-
-Rule 2 itself is right — a one-app PRODUCT deployment should not have to click
-through a one-tile launcher. Setup is not that app: it is the platform
-administration console that `@objectstack/platform-objects` ships into every
-deployment, so "the only app this viewer can see is Setup" means *this
-environment has no product apps yet*, not *Setup is the product*. Under ADR-0075
-the environment layer's home is the environment's own responsibility, so that
-case now resolves `/home`.
-
-Deliberately narrow — everything else is byte-identical:
-
-- a declared landing still wins (rule 1, `isDefault`, untouched): an admin
- console that genuinely wants Setup first says so, and gets it;
-- a one-app product deployment still lands in its app;
-- `[product, setup]` still resolves `/home` exactly as before — Setup is
- excluded from the single-app *outcome*, never from the visible *count*;
-- the `/setup` deep link is unchanged: `/` is "an arrival with no target",
- `/setup` is an explicit one, and it still resolves into Setup.
diff --git a/.changeset/studio-dock-polish-pins.md b/.changeset/studio-dock-polish-pins.md
deleted file mode 100644
index 8431cb832b..0000000000
--- a/.changeset/studio-dock-polish-pins.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
----
-
-Internal only — no user-visible change, so no release.
-
-ADR-0057 / issue #2477 items 2 and 3 (Studio dock collapse persistence, folded
-layout side-by-side at `xl`) were already implemented by PR #2478; this adds the
-regression pins that PR shipped without, and corrects three doc comments that
-still described the pre-#2478 behaviour. Tests, comments, and the extraction of
-the breakpoint constant into its own module — no runtime behaviour changes.
diff --git a/.changeset/studio-readonly-affordances-4036.md b/.changeset/studio-readonly-affordances-4036.md
deleted file mode 100644
index 877e155d95..0000000000
--- a/.changeset/studio-readonly-affordances-4036.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-Studio's read-only packages stop advertising writability they do not have — the `access` header badge and the `Data → Form` layout caption now report the gate that actually governs the screen
-
-Two captions on the Studio package surfaces contradicted the controls beside them. Both are display-side defects: the interaction gating was already correct in each case, and this change does not touch it.
-
-**The `access` header badge.** `/studio//access` rendered a permission set's title row as `... · Security · writable · ...` while all 207 permission checkboxes and the Save button below it were disabled with the read-only-package tooltip. The two renderings answer the same question — "can you write this?" — from different inputs. The controls read `!!resolved.allowOrgOverride && !readOnly`, i.e. the type registry AND the host package gate, which is the right answer. The header badge read `entry.allowOrgOverride` alone, and `permission` is one of the overlay-allowed types, so that flag is true and the badge said `writable` in every package. `PageShell` now takes a `readOnly` host gate that dominates the type-level flag, and the Studio Access pillar passes the same value it already gives the matrix's own controls; the tooltip names the package as the reason, reusing the wording (`engine.studio.pkg.readonly*`) the Studio top bar and the identity strip already use for this gate rather than inventing a second spelling. The badge slot was extracted into one `WritabilityBadge` component because it was duplicated verbatim in the compact and hero headers — the shape that lets a gate be honoured in one header and forgotten in the other.
-
-**The `Data → Form` layout caption.** In a read-only package the form tab read `Draft layout — your unsaved changes` with `Save draft` disabled and no draggable element on the surface at all. The mechanism was not a stale diff: the caption was selected by `formMode === 'layout'` — a TAB selector — so it asserted pending edits on every clean layout tab, read-only or not, while the component's real `dirty` flag sat a few lines below driving the preview warning. The claim is now made only when there are real local edits on a surface that can save them, and a neutral `Draft layout` caption (`engine.studio.data.form.layoutBadgeClean`, added in both locales) covers every other case, so the caption still names what you are looking at.
-
-Nothing about the read-only policy changed. This is the B-half of the objectstack#5768 split ruling — Studio keeps its blanket package-level read-only ("Studio 维持包级只读") while the A-half, whether the overlay-allowed types become individually editable, is still under review. The suites added here pin that deliberately: alongside the caption assertions they carry control tests asserting that a read-only package still disables every checkbox, still offers no Save, and still exposes no add-field affordance — so a change that made anything editable would fail even though the caption assertions would pass.
-
-Not covered, and correct as they stand: the metadata directory and resource-list badges read the type registry alone, which is the whole truth in those non-package-scoped screens; the OWD overview panel's unsaved badge and the form tab's preview warning were already derived from real edit state.
diff --git a/.changeset/synth-page-canonical-properties-4232.md b/.changeset/synth-page-canonical-properties-4232.md
deleted file mode 100644
index 2596b5a799..0000000000
--- a/.changeset/synth-page-canonical-properties-4232.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-'@object-ui/plugin-detail': patch
----
-
-fix(plugin-detail): synthesize page components in the spec's `properties` carrier so Studio page-create can persist
-
-Creating a page in Studio never completed. The create path seeds a record
-page's `regions` from `buildDefaultPageSchema(objectDef)` and PUTs the result,
-and every node that synthesizer emitted carried its widget props at the TOP
-level of the component — `{ type: 'page:header', recordChrome: true }`,
-`{ type: 'page:tabs', items: [...] }`, and the same for `record:highlights`,
-`record:path`, `record:details`, `record:related_list`, `record:history` and
-`record:reference_rail`. ADR-0089 D3a closed `PageComponentSchema` with
-`.strict()`, so those keys are not stripped, they are a parse error
-(`Unrecognized key(s) on this view/page schema: 'recordChrome', 'actions'`).
-The server refused the body and no page row was ever stored.
-
-The props now go where the spec declares them — the node's `properties` bag,
-which is where `ComponentPropsMap` defines `page:header.recordChrome` and
-`page:tabs.items` in the first place. Nothing is dropped and nothing changes on
-screen: a header still defaults to record chrome ON, an author's
-`recordChrome: false` is still carried (and now actually persists), the tabs
-keep their items, and `SchemaRenderer` hoists `properties` back onto the node
-before dispatch, so every renderer receives exactly the props it did before.
-
-One code path does the wrapping for every node the synthesizer builds, so there
-is a single answer to "what may go in a page write". Slot overrides are
-untouched — a node handed in by a caller is still placed verbatim.
diff --git a/.changeset/tall-eagles-repeat.md b/.changeset/tall-eagles-repeat.md
deleted file mode 100644
index d012ce7d27..0000000000
--- a/.changeset/tall-eagles-repeat.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
-'@object-ui/core': patch
-'@object-ui/console': patch
----
-
-Form actions no longer carry a record id across an object boundary (#4292).
-
-`ActionRunner.executeForm` forwarded `/forms/:name?recordId=` unconditionally,
-and that URL says nothing about which object the id belongs to — so the form route
-resolved it against the FormView's own target object. When an action fired from a
-record of a DIFFERENT object and ids collide across objects (per-table integer
-keys), the form silently prefilled and, since the route learned to honour the param,
-`PATCH`ed a same-id record of the wrong object.
-
-- **Producer**: the id is forwarded only when the firing context record's object
- (`context.objectName`) matches the target view's object; on a mismatch no id is
- forwarded, preserving create semantics. When it IS forwarded, the object travels
- with it as `?recordObject=`.
-- **Consumer**: `/forms/:name` refuses — no record read, no write — when
- `recordObject` disagrees with the FormView's object. A URL without the param
- behaves exactly as before, so existing deep links are unaffected.
diff --git a/.changeset/tenant-locale-seeds-ui-language-4035.md b/.changeset/tenant-locale-seeds-ui-language-4035.md
deleted file mode 100644
index 5ef1ac26f6..0000000000
--- a/.changeset/tenant-locale-seeds-ui-language-4035.md
+++ /dev/null
@@ -1,34 +0,0 @@
----
-'@object-ui/i18n': minor
-'@object-ui/console': minor
----
-
-console: seed the UI language from the tenant's server-side locale
-
-`GET /auth/me/localization` has always been fetched on every boot, but its
-`locale` only ever fed currency/date formatting — the UI language was decided
-entirely client-side, so a tenant configured `zh-CN` still handed every new
-device an English console until each user switched by hand.
-
-The tenant locale now sits in the language precedence chain, between the user's
-own choice and the browser's:
-
-1. the user's explicit choice (`objectui-locale`)
-2. the tenant's server locale, cached at `objectui-locale-seed`
-3. the browser language
-4. `en`
-
-The server value is cached in a slot of its own and is never written into the
-explicit-choice slot, so it can never masquerade as a preference the user
-expressed: only a manual switch promotes a language to an explicit choice. A
-cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
-that cache from every successful answer, so a tenant that changes its locale
-reaches choice-less devices on their next boot without an old seed pinning
-them. On a device's true first visit the fetch is raced against a ~500ms
-timeout alongside the console's existing pre-mount round-trips and fails open
-to the browser language; a seed that arrives after the bound is cached for the
-next boot rather than re-languaging a live session. A tenant locale this build
-ships no pack for falls through to the next tier instead of half-rendering.
-
-No platform additions: no new endpoint, no client read/write API, and
-`sys_user_preference` is untouched.
diff --git a/.changeset/tidy-eels-invite.md b/.changeset/tidy-eels-invite.md
deleted file mode 100644
index 38d6c2dfd4..0000000000
--- a/.changeset/tidy-eels-invite.md
+++ /dev/null
@@ -1,27 +0,0 @@
----
-'@object-ui/app-shell': minor
----
-
-fix(app-shell): a transient 404 no longer retires the shared inbox feed for the page's lifetime
-
-`sharedUserFeeds`' `markUnavailable()` was a one-way door: `refresh`, `schedule`
-and `onVisibilityChange` all returned early on `unavailable`, so a feed that took
-one missing-resource answer stayed retired until the user reloaded the page or
-switched identity. Since #4225 pointed both the header bell and Home's action
-centre at one inbox feed, that took both panels together — reproducing the
-#4110 / #4230 dead-bell signature from a lost race rather than from a real
-absence.
-
-The split is reachable because `isMissingResource` is status-shaped
-(`httpStatus === 404` alone qualifies) and the ObjectStack client stamps
-`httpStatus` from the response status before it reads the body — so a 404 from
-anywhere in the transport arrives indistinguishable from the registry's
-considered `OBJECT_NOT_FOUND`.
-
-A retired feed now re-probes at most `UNAVAILABLE_PROBE_LIMIT` (3) times, no
-more often than `UNAVAILABLE_PROBE_MS` (60s), on the poll timer and on
-`visibilitychange` alike; a probe that answers with rows revives the feed and
-restores its normal cadence. The retired state itself is unchanged — status
-stays `ready`, the value stays empty, and no error is ever rendered — so a
-deployment that genuinely has no messaging pipeline still reads as an answer and
-now costs three extra reads across the whole page rather than one.
diff --git a/.changeset/type-check-tests-auth-4040.md b/.changeset/type-check-tests-auth-4040.md
deleted file mode 100644
index f2c4686162..0000000000
--- a/.changeset/type-check-tests-auth-4040.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/auth` now type-checks its eleven test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
-test sources changed; no published behaviour, and no public type, moved.
-
-Both declared code-tier errors were real:
-
-- `AuthProvider.test.tsx`'s `createMockClient` claimed to return an `AuthClient` while
- implementing 8 of its ~38 methods. The double is the right call — the provider only
- reaches those 8 on the paths under test — but the claim was not, and it is the exact
- thing an unchecked test hides. Asserted at the one seam with `as unknown as AuthClient`,
- which is what this package's three other mock-client factories already do.
-- `createAuthClient.test.ts` had an unread `input` parameter on a `fetch` double
- (`TS6133`, from the repo's `noUnusedParameters`), renamed `_input`.
diff --git a/.changeset/type-check-tests-i18n-4040.md b/.changeset/type-check-tests-i18n-4040.md
deleted file mode 100644
index a916728a2f..0000000000
--- a/.changeset/type-check-tests-i18n-4040.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/i18n` now type-checks its 38 test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
-test sources changed; no published behaviour, and no public type, moved.
-
-The entry declared 13 errors; the real count at this branch point is **103**, because
-`main` has moved a long way since that sweep and the package's test tree roughly doubled.
-Two shapes account for all of them, and both are the "test tells the compiler less than
-the code" class:
-
-- **90 x TS7053** — `Object.keys(builtInLocales)` erases which keys it enumerated, so
- `builtInLocales[lang]` was an implicit-`any` index into a `const` map. Every locale
- parity, namespace and residue suite was therefore comparing packs the compiler never
- confirmed exist, and a mistyped locale code would have read `undefined` and been
- asserted against rather than failing. All of it is now derived from the map itself
- (`type LocaleCode = keyof typeof builtInLocales`) — the convention three sibling files
- in this same directory already used — so a locale added to or removed from
- `builtInLocales` reaches these suites for free.
-- **12 x TS2769** — every `React.createElement(I18nProvider, { … }, children)` wrapper.
- `I18nProviderProps.children` is required and React's `createElement` overloads check
- the props object alone, so the variadic children argument never satisfied them.
- `children` moves into the props object; identical at runtime.
-
-One straggler, `TS2537`: a fixture cast through `SpecTranslationData['objects'][string]`,
-where `objects` is optional and so cannot be indexed. `NonNullable< … >` names the record
-the fixture is one entry of.
-
-Case count is unchanged either side of the change — 38 files, 662 tests, before and
-after — which is the assertion that matters when twenty files' parametrised lists were
-re-typed at once.
diff --git a/.changeset/type-check-tests-permissions-4040.md b/.changeset/type-check-tests-permissions-4040.md
deleted file mode 100644
index be5bc0b236..0000000000
--- a/.changeset/type-check-tests-permissions-4040.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/permissions` now type-checks its four test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
-test sources changed; no published behaviour, and no public type, moved.
-
-All five declared code-tier errors were the same `TS2741`: fixtures typed `RoleDefinition`
-while omitting its required `permissions`. The empty array they gained is the accurate
-value rather than padding — those roles grant nothing directly, and every grant the cases
-exercise arrives through the `ObjectPermissionConfig[]` beside them, keyed by object.
-
-A further 21 errors appeared first and were config-tier, not code: `TS2304` on the Node
-`global` the two `MePermissionsProvider` suites stub `fetch` through, resolved by naming
-`types: ["node"]` in the test project.
diff --git a/.changeset/type-check-tests-plugin-chatbot-4040.md b/.changeset/type-check-tests-plugin-chatbot-4040.md
deleted file mode 100644
index a96d1c8bcb..0000000000
--- a/.changeset/type-check-tests-plugin-chatbot-4040.md
+++ /dev/null
@@ -1,21 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/plugin-chatbot` now type-checks its seventeen
-test files (`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is
-gone. Only test sources changed; no published behaviour, and no public type, moved.
-
-Both declared code-tier errors were the `TS2353` dialect shape, and one of them was hiding
-a case that passed for the wrong reason:
-
-- `ChatbotEnhanced.test.tsx`'s extend-badge case passed `planExtendLabel` inside `labels`,
- but that is a top-level prop of `ChatbotEnhancedProps`, not a member of `ChatbotLabels`.
- The component therefore rendered its default, `"Adding to existing app"` — and the
- expectation was `toContain('Adding to')`, which the default satisfies. Green while the
- override it names was ignored. Fixed by passing the prop where the component reads it,
- with an override string that is deliberately not a substring of the default.
-- `mapMessages.test.ts` spelled a tool state `'result'`, the AI SDK v4 name;
- `ChatToolInvocation.state` is documented as the v6 lifecycle and admits
- `'output-available'` for it. Nothing in `mapMessages.ts` branches on the value, so the
- fixture caught up with the dialect rather than the type being widened to admit the old
- spelling.
diff --git a/.changeset/type-check-tests-plugin-form-4040.md b/.changeset/type-check-tests-plugin-form-4040.md
deleted file mode 100644
index 1bfc30ee5e..0000000000
--- a/.changeset/type-check-tests-plugin-form-4040.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/plugin-form` now type-checks its 40 test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
-test sources changed; no published behaviour, and no public type, moved.
-
-Thirteen code-tier errors, ten of them one collision. `describe.each` over the four
-sectioned containers hands JSX a UNION of `ModalForm | DrawerForm | TabbedForm |
-SplitForm`, and JSX resolves a union of components by INTERSECTING their props — so
-`ModalFormSchema & DrawerFormSchema & …` reduced `formType` to `never` and every schema
-was rejected, including the right one. The parametrised suites now name the surface they
-actually exercise (`schema` plus `dataSource`) once, which is the only shape all four are
-assignable to; the `formType` values they pass are pinned to a literal union instead of
-`string`, so the discriminants stay checked.
-
-The other three were each a stub or fixture that told the compiler less than the code:
-
-- `occSave.test.tsx` stubbed `dataSource.update` with three parameters while the contract
- is `(resource, id, data, opts?: { ifMatch? })` — and the assertion under test reads the
- FOURTH argument. vitest records real arguments whatever the stub declares, so
- `mock.calls[0][3]` passed at runtime against a declared 3-tuple. Now declared, so the
- case checks the option bag it is about.
-- `deriveMasterDetail.test.ts` built a field map by `.map().concat()`, where inference
- narrowed the nine plain fields to `{ type: string }` and the tenth (a relation carrying
- `reference`) was then rejected. The entry type is written out.
-- `LineItemsPanel.test.tsx` had an unread `o` parameter on an `update` double (`TS6133`,
- from the repo's `noUnusedParameters`), renamed `_o`.
diff --git a/.changeset/type-check-tests-plugin-gantt-4040.md b/.changeset/type-check-tests-plugin-gantt-4040.md
deleted file mode 100644
index 936d0f9aa7..0000000000
--- a/.changeset/type-check-tests-plugin-gantt-4040.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/plugin-gantt` now type-checks its 41 test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. Only
-test sources changed; no published behaviour, and no public type, moved.
-
-All three declared code-tier errors were real:
-
-- `GanttView.scales.test.tsx` carried hand-written copies of `MS_PER_DAY` and
- `NOMINAL_DAYS`, both of which `GanttView.tsx` exports, under a header claiming it
- recomputes expectations with "the same linear ms→px mapping the component uses". The copy
- had already drifted — `NOMINAL_DAYS` grew a `year` entry the fork never did, which is what
- the compiler reported (`TS2741`). Both are imported now, so "the same" is true by
- construction.
-- `GanttView.summaryedit.test.tsx` stubbed `onTaskUpdate` with a bare `vi.fn()`, typing
- `mock.calls` as `any[][]` — arrays of unknown length — while the assertions destructure
- `[task]` and read `call[1]`. The spies are typed to the handler signature the helper
- already declares.
diff --git a/.changeset/type-check-tests-plugin-map-4040.md b/.changeset/type-check-tests-plugin-map-4040.md
deleted file mode 100644
index 9d4c0e8e77..0000000000
--- a/.changeset/type-check-tests-plugin-map-4040.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
----
-
-Releases nothing on purpose: `@object-ui/plugin-map` now type-checks its four test files
-(`tsconfig.test.json` chained from `type-check`), and its `TEST_DEBT` entry is gone. No
-published source changed.
-
-Its one declared code-tier error turned out to be config-tier under a fuller template,
-the same reclassification `sdui-parser`'s 50 `TS2304`s got in objectui#3032. The error was
-`TS2882` on `ObjectMap.tsx`'s side-effect import of `maplibre-gl/dist/maplibre-gl.css` —
-raised not because the source is wrong (the package build compiles it cleanly) but because
-`src/global.d.ts`, which declares `*.css`, was not a program input. An ambient declaration
-file is only an input when a pattern NAMES it; being reachable by import is not enough,
-since nothing imports it. `"src/**/*.d.ts"` in the project's `include` is the fix, and it
-is the general shape for any package whose build gets ambient declarations for free from
-`"include": ["src"]`.
diff --git a/.changeset/unpublished-banner-reads-unpublished-key-6955.md b/.changeset/unpublished-banner-reads-unpublished-key-6955.md
deleted file mode 100644
index b69eb53f54..0000000000
--- a/.changeset/unpublished-banner-reads-unpublished-key-6955.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-The Unpublished-app banner now reads the ADR-0045 publish gate `_unpublished`, not the navigation flag `hidden` — so a published app that is merely kept out of the launcher no longer wears the "only builders can see it" watermark
-
-Upstream (framework PR #6942, objectstack#4829 ruling A1) split one flag into two with disjoint meanings: `_unpublished` is the machine-managed publish gate that materialization sets and publish clears, while `hidden` became author-declared navigation presentation and nothing else — "Hidden apps stay fully routable and permission-checked". `UnpublishedAppBar` read `hidden`, which after that split was wrong in **both** directions at once, and both are fixed here:
-
-- **False positive.** A published, nav-hidden app — the built-in `account` app is the specimen — rendered the amber "Unpublished app — fully functional, but only builders can see it" bar. The app was live to every user; the console told its owner the opposite.
-- **False negative.** A genuinely unpublished app whose author had not also set `hidden` rendered no bar at all. The server withholds unpublished apps from non-builders, so this watermark is the *builder's own* only signal that what they are looking at is not live yet — losing it means publishing feels already-done.
-
-This was not latent: console pin bump objectstack#7308 put an objectui build keying on `hidden` in front of a framework that had already redefined it, so both directions were live in the bundled console.
-
-The Publish button on that bar writes `PUT /meta/app/{name}` with `{"_unpublished": false}` instead of `{"hidden": false}`. `false` rather than a key delete, matching the server's own `POST /packages/:id/publish-drafts` flip: ADR-0045 §3 makes publish/unpublish symmetric, so the gate stays two-state rather than a key whose absence has to be re-derived. Whatever `hidden` the app carries now rides through the write **untouched** — publishing an app must not silently rewrite the author's navigation choice, which is the regression objectstack#4829 was filed for in the first place. The package-level Publish (`POST /packages/:id/publish-drafts`) is unchanged; the server-side flip it triggers already clears `_unpublished`.
-
-**Launcher surfaces deliberately do NOT move to the new key** and are pinned against it by test, because the symmetry is a trap rather than an oversight. `_unpublished` is enforced server-side — the REST metadata gate withholds those apps from everyone who should not see them — so an unpublished app that reaches the client at all belongs to a builder who is entitled to navigate to it; filtering it in the launcher would hide a builder's in-progress app from the builder while buying no protection the server was not already providing. `hidden`, by contrast, has no other enforcement point anywhere: that client-side filter *is* its entire implementation. Two keys, two enforcement layers, so the App Switcher, the `AppContent` launcher list, the home grid and the root landing resolver all keep filtering on `hidden` alone.
-
-No authoring surface, wire format or exported type changes. The `publish-drafts` response fields `unhiddenApps` / `unhideError` keep their spelling: the framework still emits exactly those names, and renaming only the consumer would be the same silent break this pair of cards exists to close.
diff --git a/.changeset/updateview-draft-addressing-4139.md b/.changeset/updateview-draft-addressing-4139.md
deleted file mode 100644
index 7cd3775642..0000000000
--- a/.changeset/updateview-draft-addressing-4139.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/data-objectstack': patch
----
-
-Renaming a freshly-created view now persists — `updateView` reads and writes the same row, instead of reading the published overlay and losing the edit into a rejected partial write
-
-ADR-0034 stages every runtime-created view as a per-item **draft**: a view made from the `+` tab lives only in the draft row until an explicit Publish, and the UI reads it back through `?preview=draft`. `updateView` addressed neither half of that. Its read went to the published overlay (`client.meta.getItem`, no draft qualifier), which 404s for a draft-only view; a `catch {}` labelled "treat missing as create-equivalent" then substituted `current = {}`, so the read-merge-write cycle merged onto nothing. What went out was the fragment that merge produces — literally `{label, name, object}`, no `viewKind`, no `config` — which the server rejects as an invalid ViewItem (422). Nothing surfaced to the user, and the draft row still held the old label, so the rename simply did not happen. Create, pin and delete were unaffected: they never take this path.
-
-The read now probes the draft row first and, on a hit, merges onto that body and writes it straight back with `mode: 'draft'`. Whichever row the read resolved is the row the write updates, so the two halves agree by construction rather than by coincidence. Probing the draft **before** the published overlay is what makes it correct for a view that has both: writing the published row while a draft is pending would put the edit somewhere the draft shadows, and Publish would later overwrite it with the pre-edit body — losing the change a second time, further from the cause. A draft edit stays a draft, preserving ADR-0037's guarantee that nothing the preview shows goes live until Publish. Renaming a published view with no draft pending is unchanged, published read to published write.
-
-The silent catch is gone. A view that resolves in neither home now throws naming the view and the object (creating one is `createView`'s job — no caller of `updateView` relied on the create-equivalent behaviour), and a network, permission or server fault on either read propagates instead of degrading into the partial write that corrupted the row. This turns a class of failure that was previously invisible into an error the existing call sites already catch and surface.
-
-Set-default and reorder drive the same read-merge-write cycle with `{isDefault}` / `{sortOrder}` patches, so they were emitting the same partial write and are fixed by the same change.
diff --git a/.changeset/useobjectchat-honest-message-type-4424.md b/.changeset/useobjectchat-honest-message-type-4424.md
deleted file mode 100644
index 81eccffa47..0000000000
--- a/.changeset/useobjectchat-honest-message-type-4424.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@object-ui/plugin-chatbot': minor
-'@object-ui/app-shell': patch
----
-
-`useObjectChat` declares the message shape it actually hands back
-
-The hook typed `messages` — and the `onSend(content, messages)` callback fed from it — as `@object-ui/types`' authoring `ChatMessage`. That was true in local mode only. In API mode the values came out of the runtime mapper and were asserted into place with `as OuiChatMessage[]`, and the authoring contract declares none of what they carry: `buildProgress`, `blueprintProgress`, `charts`, and `pendingActionId` / `draftReview` / `proposedPlan` / `proposedChanges` / `builderHandoff` on every tool invocation. Those keys are the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and the inline charts. They survived only because nothing on the path ever rebuilt a message; anyone writing the obvious thing — reconstruct a message field-by-field from its declared type — deleted all of them, with the compiler agreeing, because the declared type genuinely did not have them.
-
-The declaration is now the truth, published as `ObjectChatMessage`. The survey behind it found the honest type to be neither of the two `ChatMessage` types on either side, because neither is true of both modes: it stays **wide** where local mode is wide (an authored `'tool'` role and the legacy `'partial-call'` / `'call'` / `'result'` tool states reach this surface unchanged and are folded only at the render seam), **narrow** where both modes are narrow (`timestamp` is `string`, never `Date` — API mode never produces one and local mode absorbs it before emitting), and adds the render-only keys API mode really carries. The `as OuiChatMessage[]` assertion is deleted rather than moved: the mapper's output satisfies the declared type, so the compiler checks that assignment instead of being told to stop looking.
-
-Nothing about the values changed, and nothing correct breaks. `ObjectChatMessage` is a **subtype** of the authoring `ChatMessage` it replaces, so every consumer that accepted the old declaration still accepts these values — including a host `onSend` callback that types its parameter as `ChatMessage[]`, which keeps type-checking by contravariance. Naming `ObjectChatMessage` is what lets a host *read* the keys above. The one observable narrowing is deliberate: code that branched on `timestamp instanceof Date` was handling a value this hook cannot emit, and now says so at compile time.
-
-The seam below it (`chatMessageAdapter.ts`, from objectui#4399) is still necessary and unchanged in behaviour — `'tool'` and the legacy tool states still have to be narrowed for the renderers. What changed is that its pass-through is no longer an act of faith: its input type (`SeamChatMessage`, also exported, alongside `SeamToolInvocation`) names the render-only keys, so the spread preserves them as declared properties the compiler can see, and the pass-through tests type their API-mode fixture directly instead of casting it past the compiler. A cast returning to the hook is now caught by a test rather than by a future outage.
-
-App-shell carries a comment-only correction on the same family: `AiChatPage` still described `@object-ui/plugin-chatbot` as exporting a second, minimal legacy `ChatMessage` alongside the enhanced one. That collision was retired in objectui#4383 — the barrel publishes one contract and `ChatbotEnhancedMessage` is a deprecated alias of it — so the paragraph was sending readers to look for a hazard that no longer exists.
diff --git a/.changeset/view-invalidation-seam-4373.md b/.changeset/view-invalidation-seam-4373.md
deleted file mode 100644
index afbbbb5c19..0000000000
--- a/.changeset/view-invalidation-seam-4373.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@object-ui/data-objectstack': patch
-'@object-ui/app-shell': patch
----
-
-Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
-
-`ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.
-
-Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.
-
-The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.
-
-No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
diff --git a/.changeset/view-overrides-invalidation-4363.md b/.changeset/view-overrides-invalidation-4363.md
deleted file mode 100644
index ec98bab7f6..0000000000
--- a/.changeset/view-overrides-invalidation-4363.md
+++ /dev/null
@@ -1,13 +0,0 @@
----
-'@object-ui/data-objectstack': patch
----
-
-Every view write path now invalidates the override map — a created, renamed or deleted view is no longer shadowed by a five-minute-stale batch read
-
-`ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{viewId}`, and `listViewOverrides` under `view-overrides:{object}`. Four write paths touch view rows, and until now exactly one of them — `updateViewConfig` — invalidated the second key. `createView`, `updateView` and `deleteView` invalidated only the per-view key, so the batch override map kept answering from a snapshot taken up to `MetadataCache`'s default 5-minute TTL earlier.
-
-That gap does not heal itself. `loadViewOverrides` in app-shell's `ObjectView` treats a resolved map as authoritative and deliberately does not re-probe per view — that is objectui#3774's fix, and it is correct, since re-probing reinstates the 404 flurry the batch read exists to remove. So the per-view `getView` fallback that would have masked a stale map is by design unreachable, and the stale map is served in full. Meanwhile `listViews` is uncached and answers fresh, so the view switcher could list a view whose override body came from a map written minutes earlier: the sharpest shape is the rename/pin path (`updateView`), where a user edits a view, returns to the object, and is served the pre-edit override.
-
-All four paths now emit the same ordered pair — the per-view key, then the object's override map. The rule is uniform per method rather than per branch: `updateView`'s draft half invalidates both keys as its published half does, which is deliberate over-invalidation (both readers enumerate published rows, so a draft write stales neither) chosen because an unnecessary invalidation costs one refetch while a missed one costs the full TTL. `createView` names the per-view key too, because `saveItem` is an upsert and an explicit `spec.name` that already exists overwrites a published row a prior `getView` may hold.
-
-No signature, no cache key and no read path changed; the only difference is which keys each write drops. The pin suite added by objectui#4328 now asserts the full invalidation key set for all five call sites, with the sweep's two pins kept as untouched controls: `listViews` stays uncached, and no write path names the retired `views:{object}` key.
diff --git a/.changeset/widget-dom-leak-sweep-gate-4425.md b/.changeset/widget-dom-leak-sweep-gate-4425.md
deleted file mode 100644
index 90984951a5..0000000000
--- a/.changeset/widget-dom-leak-sweep-gate-4425.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
----
-
-Internal only — a new test-only measurement gate, no source change, so no release.
-
-objectui#4425 phase 1: the #3291 DOM-leak canary sweep, generalized beyond
-`packages/fields` to the registry-reachable SDUI widgets of `plugin-charts`,
-`plugin-calendar`, `plugin-chatbot` and `plugin-dashboard`. New suite:
-`packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`, 39 cases over
-23 measured targets. Home chosen by the #4409 dependency-direction method —
-`app-shell` is the only package declaring all four targets, and it already hosts
-this repo's cross-package gates.
-
-Per the phase-1 ruling this changes no widget contract and no widget source. The
-5 leaking targets it found are RECORDED in the gate's ledger with the exact
-attribute names, the mechanism, and an owning issue — never silently baselined.
-The ledger asserts exact set equality, so a new leak fails the gate and a FIXED
-leak also fails it until its row is deleted in the same change; a row cannot
-outlive the defect it records.
-
-Filed from the measurement: objectui#4431 (plugin-chatbot, 14 attributes on two
-registrations), objectui#4432 (`DashboardRenderer`'s widget grid, 13), and
-objectui#4433 — not a leak but a crash, where authoring the ordinary SDUI
-`events` key takes `calendar-view` down entirely. objectui#4434 records the
-judge duplication this PR deliberately accepted.
-
-Phase 2 — whether the `toDomProps` whitelist becomes the SDUI widget contract
-generally — stays with the maintainer, now decidable on this gate's reading
-rather than on an assumption.
diff --git a/.changeset/wild-pugs-smoke.md b/.changeset/wild-pugs-smoke.md
deleted file mode 100644
index 6c62857f12..0000000000
--- a/.changeset/wild-pugs-smoke.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@object-ui/components': patch
----
-
-`ActionParamDialog`'s `select` branch no longer renders a hardcoded English `Select...` placeholder. The fallback used when an action param declares no `placeholder` of its own now reads the existing `common.select` pack key, so it is translated in all ten locales and carries the typographic ellipsis (U+2026) that #3878 converged the packs on. Authored `placeholder` metadata keeps priority, and no locale pack changed — the key was reused from `LookupField`'s identical select-trigger use.
diff --git a/.changeset/wise-pumas-attack.md b/.changeset/wise-pumas-attack.md
deleted file mode 100644
index cfe8b8d79c..0000000000
--- a/.changeset/wise-pumas-attack.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-'@object-ui/app-shell': patch
----
-
-metadata-admin: diagnose a path on the right-hand side of `==` / `!=` in a visibility predicate
-
-The predicate evaluator resolves paths only on the LEFT of `==` / `!=`. The right-hand side goes
-through `parseLiteral`, which hands back anything it does not recognise as a literal verbatim — so
-`data.a == data.b` compares the value of `data.a` against the seven-character string `"data.b"` and
-is false however equal the two sides are, with nothing in the console. objectstack#6936's
-unresolved-path warning cannot see this: it hangs on `resolveValue`, which the right side never
-enters.
-
-A dev-mode `console.warn` now fires when that tail returns something path-shaped (a dot-separated
-identifier chain — the same grammar the left side accepts), naming the text, the predicate carrying
-it, and the boundary. **No semantics change**: `data.a == data.b` still evaluates false, and the
-before/after verdicts are pinned identical. The semantic fix belongs to publish-time predicate
-validation (objectstack#7010) and to the real CEL runtime this file stands in for (ROADMAP M9), with
-which this diagnostic retires.
diff --git a/.changeset/wrong-slot-i18n-keys-4118.md b/.changeset/wrong-slot-i18n-keys-4118.md
deleted file mode 100644
index ad293bbc75..0000000000
--- a/.changeset/wrong-slot-i18n-keys-4118.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@object-ui/app-shell': patch
-'@object-ui/plugin-list': patch
-'@object-ui/i18n': patch
----
-
-Stop the report config panel being titled "Title", and the view-settings colour section "Color"
-
-Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
-
-`ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title *field* — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
-
-`ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
-
-No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
diff --git a/apps/console/CHANGELOG.md b/apps/console/CHANGELOG.md
index 2ecae05e69..9534a5af21 100644
--- a/apps/console/CHANGELOG.md
+++ b/apps/console/CHANGELOG.md
@@ -1,5 +1,340 @@
# @object-ui/console
+## 17.5.0
+
+### Minor Changes
+
+- 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
+
+ Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
+
+ Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
+
+ One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a ` ` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
+
+ Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
+
+- 3b7d1cc: An unloadable app list no longer lands you on `/home` as though you had no default app — and the Applications page's Set-as-default / Disable / Delete now actually write
+
+ Two defects on the same journey, reported together because the second is why the first had no workaround.
+
+ **The landing.** `resolveLandingPath` reads a list of apps, and an empty list is a legitimate input with a legitimate answer: rule 3 sends you to `/home`, the multi-app launcher. What it could not see is _why_ the list was empty. A failed `GET /meta/app` produces exactly the same `[]` — `MetadataProvider.ensureType` catches and resolves an empty array, so nothing rejects and `loading` goes false — and the resolver then reported "this deployment has no default app" about a deployment it never managed to ask. The wrong answer did not stay soft, either: `Navigate … replace` rewrites `/` to `/home` in history, so a reload re-enters at `/home` and the `isDefault` branch never gets a second chance; if the session also turns out to be dead, the auth guard captures `/home` into `?redirect=%2Fhome` and honors it after sign-in. An error-state fallthrough should never be fossilized as user intent.
+
+ `/` now resolves a landing only from an app list that is an _answer_. The distinguishing fact already existed on the metadata context and is used as-is — `getTypeStatus('app')`, the provider's own per-type load status — so no second dialect of loading or auth state is introduced, and the landing policy itself is untouched: every existing rule, including the empty-list fallthrough and the Setup-only case, still resolves exactly as before when the list genuinely loaded. While the list is unknown the console holds at `/` and re-asks the metadata layer once, so a transient failure heals on its own and a real outage settles on a screen that is at least not a claim about which apps exist.
+
+ The originally reported chain had an earlier link that is already closed: before objectui#4042 the `/` route mounted the resolver with no guard above it, so an unauthenticated visitor ran the whole resolution against a list emptied by a 401. That entry is now guarded and the bare `/` is deliberately not captured as a redirect target, and both facts — plus the legitimate deep-link capture that must keep working — are pinned here for the first time.
+
+ **The Applications page.** Set as default, Disable (and the bulk toggle) and Delete each showed a success toast having issued no request at all, then called `refresh()` — which re-rendered the unchanged server state underneath the confirmation, and is what made the stubs read as a working feature. Their `// TODO: Replace with real API call when backend supports app management` was measured against `@objectstack/client` 17.0.0-rc.6 and its premise is false: the write surface exists, is gated on `manage_metadata`, and is already how app schemas are persisted elsewhere in this console. All four handlers now await a real mutation and report success only afterwards; a refusal surfaces the server's own message. Set-as-default demotes the outgoing default before promoting the new one, so the landing cannot depend on the order the server lists apps in, and the bulk toggle counts the writes that actually landed rather than the size of your selection.
+
+- e4d1c08: `resolveHostAppSegment` is published from `@object-ui/app-shell`'s package root, and the console's local copy of it is deleted
+
+ Which app should host a framework-owned, app-INDEPENDENT page — the Approvals Inbox, the full inbox, an internal form's created record — is one hard-won definition, and `resolveHostAppSegment`'s docblock says so at length: prefer the app the user is in or last had open RE-CHECKED against the live active list, else their first active app, else the two last resorts. It lived in `app-shell/src/utils/appRoute.ts`, and `packages/app-shell` published only its package root, which re-exported `./utils` nowhere. So the definition was unreachable from every consumer outside the package.
+
+ Something outside the package needed it anyway. objectui#4109 had to name a host app for the record an internal `/forms/:name` submit creates, could not import the resolver, and shipped a documented local subset instead: steps 1 and 2 only, returning `null` where upstream falls through further. That is the "two readers of one prose contract" shape this repo keeps paying for (#3367 / #3842) — the next edit to the resolution order lands on one copy — and the divergence was disclosed rather than smuggled precisely so it could be collected later. This is that collection.
+
+ `resolveHostAppSegment` is now exported from the package root, alongside the two predicates it is defined in terms of (`appRouteSegment`, `filterActiveApps`), and the console's copy is gone: `createdRecordPath.ts` holds the URL shape and delegates the choice. The app record type it accepts is derived from the resolver's own signature rather than re-declared, so the call site cannot drift from it either.
+
+ **Behaviour change on the created-record redirect.** Converging on the full resolver means the two cases the subset answered `null` for now name an app. An empty openable list with a preferred app keeps that preferred app unchecked — an empty list means "not loaded yet" at least as often as "this user has no apps", and demoting someone demonstrably rendering inside `/apps/{preferred}/…` would reintroduce the defect objectui#4074 removed. Anything else unresolvable — no apps and nothing preferred, or apps carrying neither `_packageId` nor `name` — lands on `setup`, the least-surprising last resort rather than a broken link. Concretely: an internal form submit that previously stopped on FormPage's in-place confirmation ("no record page to land on") now navigates to the record under the resolved app, which is the same answer every other record link in the console already gives. The write itself was never at stake, only where the user is put afterwards. The remaining `null` from `buildCreatedRecordPath` means what it always should have: there is no record to point at, because the caller has no object or no id.
+
+- 90e792e: Internal `/forms/:name` renders inside the console shell, and an internal submit lands on the record it just created
+
+ A `type: 'form'` action navigates to `/forms/:name` (`ActionRunner.executeForm`). That route was declared at the TOP level of the console route tree, a sibling of the app-shell routes, so clicking a button inside an app dropped the user onto a bare form — no header, no navigation, no way back — while the URL still said they were in the console. It now renders inside the console's layout for app-independent authed pages, the same chrome `/home` and `/organizations` use. The route itself is unchanged: deep links to it keep working, because the missing chrome was the defect, not the navigation.
+
+ The second half is what happens after Submit. The post-submit default was `{ kind: 'thank-you' }` for both form modes, so a signed-in operator who had just created a record was shown the ANONYMOUS confirmation — "Your submission has been received" — with no link to the thing they had created. The default is now mode-aware: an internal submit navigates to the created record's page, while the public `/f/:slug` path keeps `thank-you`, which is the right answer for a visitor who has no console to be sent into. A form view that declares its own `submitBehavior` still wins in both modes, unchanged and untouched — the point of a default is that the corpus never has to opt out of a wrong one.
+
+ Landing on the record needs the created record's id, and that comes from the spec-declared `CreateDataResponse = { object, id, record }` returned by `POST /api/v1/data/:object`. Only that one declared key is read: `record.id` carries the same value, but reading both would be a second de-facto contract for one fact. A response that names no id — or a workspace where no app can host the record's page — falls back to confirming the submit rather than navigating somewhere broken, since the record really was created and silence would be the worse answer.
+
+ No authorable surface changed. The "land on the created record" behaviour is deliberately NOT a new `submitBehavior.kind`: the spec's union (`thank-you | redirect | continue | next-record`) is strict and stays exactly as it is, and nothing parses the new internal default out of metadata — it is only what the renderer does when an author declared nothing.
+
+- 78fa331: console: seed the UI language from the tenant's server-side locale
+
+ `GET /auth/me/localization` has always been fetched on every boot, but its
+ `locale` only ever fed currency/date formatting — the UI language was decided
+ entirely client-side, so a tenant configured `zh-CN` still handed every new
+ device an English console until each user switched by hand.
+
+ The tenant locale now sits in the language precedence chain, between the user's
+ own choice and the browser's:
+
+ 1. the user's explicit choice (`objectui-locale`)
+ 2. the tenant's server locale, cached at `objectui-locale-seed`
+ 3. the browser language
+ 4. `en`
+
+ The server value is cached in a slot of its own and is never written into the
+ explicit-choice slot, so it can never masquerade as a preference the user
+ expressed: only a manual switch promotes a language to an explicit choice. A
+ cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
+ that cache from every successful answer, so a tenant that changes its locale
+ reaches choice-less devices on their next boot without an old seed pinning
+ them. On a device's true first visit the fetch is raced against a ~500ms
+ timeout alongside the console's existing pre-mount round-trips and fails open
+ to the browser language; a seed that arrives after the bound is cached for the
+ next boot rather than re-languaging a live session. A tenant locale this build
+ ships no pack for falls through to the next tier instead of half-rendering.
+
+ No platform additions: no new endpoint, no client read/write API, and
+ `sys_user_preference` is untouched.
+
+### Patch Changes
+
+- a00d23c: API Console renders the Storage group again — its catalog key now names the canonical `file-storage` slot instead of the route
+
+ The API Console's service-gated groups are keyed by canonical service-slot name, because the key is looked up directly in `/discovery`'s `services` map — and the framework keys that map by `CoreServiceName`. The storage group was keyed `storage`, which is the _route_ (`/api/v1/storage`), not the slot (`file-storage`). So the lookup missed on every host, and because a miss is indistinguishable from "no such service" the deliberate fail-closed branch (ADR-0076 D12) hid all three storage endpoints — upload, download, delete — on every deployment, whether or not a storage service was registered and healthy.
+
+ The fail-closed posture was never the defect and is unchanged; only the key moves. The group's user-facing name stays `Storage` — that is the route's name, and it was never derived from the catalog key, so no display string and no i18n resource changes.
+
+ The mis-key survived because nothing tied the catalog's keys to the vocabulary they are spelled in: a wrong key produces silence, not an error, and an empty group is exactly what a legitimately absent service looks like. A tripwire now derives that vocabulary from `@objectstack/spec` itself and asserts every service-gated catalog key against it, so a rename on either side fails a test instead of quietly emptying a group. Deriving it also surfaced two further keys that name no slot and therefore can never render — `workflow` (slot retired upstream) and `feed` (never a slot) — recorded as a documented exception set pointing at objectui#4303 rather than fixed here, since neither has a correctly-spelled name to move to.
+
+- 734d186: The console's Applications page is localized — its own chrome only, never the
+ server's words (objectui#4307).
+
+ `AppManagementPage` was raw English end to end: headings, the search field, the
+ selection and bulk controls, the six per-row actions with their tooltip/ARIA
+ pairs, the status badges, and every toast. It was the last un-i18n'd system page,
+ and #4233 / PR #4300 had just given it four live mutations — so the gap became
+ user-visible on every non-English console at the moment operators started using
+ it. 45 keys land under `appManagement.*` in all ten packs, reached through
+ `useObjectTranslation` with the call site's `defaultValue` inline, which is the
+ convention the neighbouring system pages already follow.
+
+ The split that shapes this change is between the strings the PAGE authors and
+ the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
+ `manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
+required` is the server's diagnosis of one specific request; there is no fixed
+ catalogue of those sentences to key against. Each failure toast is therefore a
+ keyed template with a `{{reason}}` hole, and what fills the hole is passed
+ through byte for byte, untranslated. The one part that IS the page's own — what
+ it says when the server sent no message at all — is keyed as
+ `appManagement.toast.unknownError`.
+
+ Two smaller things follow from doing the conversion properly rather than
+ mechanically. The per-failure entry of a bulk toast and the separator between
+ entries are keys, not literals, because bracket style and list punctuation are
+ locale properties (the same rule, and the same past defect, as
+ `validation.formInvalidJoiner`). And the row's controls now name an app through
+ the resolver the visible heading two lines away already used, with `t` passed:
+ an app carrying objectui's keyed label form previously rendered `Select [object
+Object]` into its checkbox's ARIA label.
+
+- 3b4d78e: The Applications page's search box no longer takes the page out on the first keystroke when an app carries a non-string label
+
+ `apps/console/src/pages/system/AppManagementPage.tsx` filtered on `(app.label || '').toLowerCase()`. `label` and `description` are `I18nLabel` in `AppSchema` — `string | Record< string, string >` in `@objectstack/spec` 17.0.0-rc.6 — so an authored non-string label is spec-legal metadata, and an object is **truthy**: the `|| ''` guard never fired for one, and `.toLowerCase()` received the object.
+
+ ```
+ TypeError: (l || "").toLowerCase is not a function
+ ```
+
+ That throw happened inside `filter` **during render**, so it took the whole page down rather than degrading search. It stayed invisible until someone typed, because `if (!searchQuery) return true` returns before either read — the page mounted perfectly with the very metadata that killed it one character later.
+
+ Both reads now go through the resolver the rows already render with: `appTitle` (the single display-name helper objectui#4307 introduced in this file) for the label, and the identical `resolveKeyedI18nLabel(…, t)` call the description paragraph makes. This is a repair to one page's filter, not a new capability — but it does make search match what the operator can actually see: for objectui's keyed label form it now matches the pack's answer rather than the authoring `defaultValue`, and it matches `app.name` wherever the row heading itself falls back to it.
+
+ Routing search through the render path also means it cannot drift out of step again. The inline locale map form (`{ en: 'Storefront', 'zh-CN': '店面' }`) is still resolved by neither path — the row heading falls back to the app name and search now matches on exactly that, instead of crashing on it — so when objectui#4163 widens the resolver, display and search gain the map form in the same commit.
+
+ No first-party app ships a non-string app label today, so this was reachable through authored metadata rather than live in the shipped examples; the crash is real for anyone who authored one, and `AppSchema` accepts it with a green parse.
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 2a40f69: Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
+ branch point rather than taken from their cards, and one card's premise only half held.
+
+ Breaking for anyone who typed against the removed declaration, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/types` and `@object-ui/permissions` no longer export
+ `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
+ (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
+ read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
+ referents were its own definition and the two barrel lines. The wired home is
+ `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
+ what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
+ now records the retirement so the surface is not re-declared. (#4364)
+
+ `PermissionCondition` was proposed for retirement on the same card and is **kept**: its
+ premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
+ branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
+ type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
+ is likewise untouched — `FieldLevelPermission.effect` still reads it.
+
+ No behaviour change, no public surface change:
+
+ - `@object-ui/console` drops `src/utils/metadataConverters.ts` and
+ `src/services/MetadataService.ts`. Both were console-local duplicates of live
+ `@object-ui/app-shell` modules and lost their last importer when the bespoke
+ object-detail widgets were retired (#4365). Both had already drifted behind the live
+ copies they duplicate — the console converter's `referenceTo` chain never read the
+ server's `reference` key, and the console service predates the view cache-invalidation
+ seam (#4373) — which is precisely the imitation trap the card recorded: an author
+ grepping for "the converter" could land on the unexercised copy. The app-shell copies
+ and their tests are untouched. (#4368)
+
+- 6d8231c: A `type: 'form'` action fired from a record now EDITS that record instead of creating a duplicate
+
+ `ActionRunner.executeForm` forwards the record an action was fired from as `/forms/:name?recordId=`, but the console's internal form route never read that param. The only query params it consumed were the `prefill_` ones, so the route rendered EMPTY inputs, and its submit was an unconditional `POST /api/v1/data/:object` — an insert. An "edit this record" action therefore opened a blank form and, on Submit, created a second record while leaving the original untouched. In the showcase app: open any Task, click **Log Time**, fill it in, Submit — a NEW Task appeared. Until objectui#4109 the damage was hidden behind the anonymous "Your submission has been received" panel; once an internal submit started landing on the record it wrote, the duplicate became visible immediately.
+
+ `?recordId=` now selects the whole read/write pair. The route loads the record with `GET /api/v1/data/:object/:id`, prefills the inputs with its stored values, and saves with `PATCH /api/v1/data/:object/:id` — the verb the data plugin declares (`plugin-rest-api.zod.ts`), the one `packages/rest` registers, and the one every other update client in this workspace already spells. After a successful save the user lands back on the record they edited.
+
+ A `recordId` the route cannot honour now fails closed. A record that 404s or 403s, a payload whose object contradicts the form's target, and a present-but-blank `?recordId=` each render the form's error state; none of them falls back to create mode, because a blank form whose submit inserts a duplicate is this bug's exact harm and silently degrading into it would just re-arm it. A `recordId` naming a record of a different object is not found under the form's own object, so it takes the same refusal path.
+
+ When a URL carries both a `recordId` and `prefill_` params, the explicit params win for the fields they name and the record's stored values fill the rest — a producer that forwards both is expressing intent, and the per-field instruction is the more specific one. Stored nulls and empty strings count as real values and beat a field's create-time `defaultValue`, so opening an edit form never silently proposes a change the user did not make.
+
+ Two surfaces are deliberately untouched. Create mode — no `recordId` — behaves exactly as before, and the public `/f/:slug` path ignores `recordId` entirely: an anonymous visitor controls the URL, so honouring it there would turn a public form into an arbitrary-record reader and writer. In `@object-ui/app-shell` only the URL-param registry's documentation changed, recording that `recordId` now has a second reader on a route that can never match the same URL as the record drawer's.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
+
+ `LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
+
+ The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
+
+ **The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
+
+ Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
+
+ While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
+
+- 275d7df: Retire the dormant bespoke object-detail page factory and its seven widgets
+
+ `buildObjectDetailPageSchema()` had zero callers. Its only consumer was the registry-driven `MetadataDetailPage`, deleted when the console moved onto the metadata-admin engine; the factory outlived it by months as code no route could reach. The seven widgets it fed — `object-detail-tabs`, `object-properties`, `object-field-designer`, `object-relationships`, `object-keys`, `object-data-experience`, `object-data-preview` — were still registered in `ComponentRegistry` at startup, so they were reachable in principle by any schema naming those types, and in practice by none: nothing in the repository produces one.
+
+ That unreachability is also why 60 lines of hardcoded Chinese UI copy sat in `objectDetailWidgets.tsx` and `ObjectDetailTabsWidget.tsx` against the English-only rule without any gate seeing them — the strings were bare literals, never `t()` keys, and all three i18n gates judge keys. Translating copy that no user can reach, on a surface with no future, was the more expensive of the two exits; the maintainer ruled REMOVE (objectui#3731 / #3736) and both cards close together.
+
+ Deleted: `schemas/objectDetailPageSchema.ts`, `components/schema/objectDetailWidgets.tsx`, `ObjectDetailTabsWidget.tsx`, `ObjectFieldDesignerWidget.tsx`, `registerObjectDetailWidgets.ts`, and the `main.tsx` registration import. No user-visible behaviour changes, because no route rendered any of it. The `skills/objectui/guides/console-development.md` chapter that positioned the factory as the bespoke-editor recipe now points at the live specimen (`PermissionMatrixEditPage`) instead, and the retired names are recorded in that guide's "Retired names" table.
+
+- b3f665b: `/setup` is a real address again — the console gets a stable deep link into platform administration instead of bouncing you back to home
+
+ Opening `/_console/setup` landed on `/_console/home`. System settings had no direct URL at all: the only way in was clicking the 「系统设置」 card on the home launcher, which meant the entry point could not be bookmarked, could not be pasted into a support runbook, and was asymmetric with Studio, whose front door has been stable for a while.
+
+ The route was never missing — it was occupied. `/setup` mounts the first-run owner-bootstrap wizard (ported here when the Account SPA was retired), and that page evicts everyone it is not meant for: a signed-in visitor via `window.location.assign('/')`, which the landing resolver then turns into `/home` on any multi-app deployment. So the bounce was the wizard doing its job at a URL that had quietly acquired a second, more common meaning.
+
+ `/setup` now decides between the two, on the condition the wizard itself already probes — whether the deployment has an owner (`GET /api/v1/auth/bootstrap-status`). No owner yet: the wizard, unchanged. Otherwise: the platform-administration deep link. A live session short-circuits the probe entirely, because `hasOwner: false` cannot be true while somebody is signed in — which also keeps a failed probe from re-creating the bounce it is meant to remove. The verdict is latched for the lifetime of the mount, because `signUp()` flips the session to authenticated while the wizard is still renaming the bootstrap organization, and re-deciding on that flip would unmount the wizard mid-submission.
+
+ The destination is read from metadata rather than spelled out. `SetupRedirect` (new, exported from `@object-ui/app-shell` alongside `SystemRedirect`, with its policy available as the pure `resolveSetupAppPath`) resolves the Setup app through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app ROOT — so the page you land on is whatever `AppContent` already resolves as that app's landing item, not a second copy of that policy that would drift the next time Setup's navigation is re-ordered. Search and hash carry across the hop, as they do for `SystemRedirect`.
+
+ Two edges are handled rather than papered over. An unauthenticated deep link now goes to `/login?redirect=%2Fsetup` through the console's existing auth-redirect contract — router-derived, so it stays correct under a ` ` mount — and lands back on `/setup` after signing in; previously it reached a bare `/login` and the deep link was dropped. And a viewer whose metadata contains no Setup app (the common cause is not a broken build but a missing `setup.access` permission, which filters the app out server-side) gets the shell's ordinary "App not available" screen, with its retry and its one-shot metadata re-check — never a silent landing on home, and never the bare `/apps/setup` pseudo-route, which would have resolved to whichever app happens to be the default.
+
+ `/_console/studio` was checked for the same asymmetry and needed no change: bare `/studio` is a declared front door rendering the builder landing, and `/studio/:packageId` already redirects to its Data pillar.
+
+- 1f34b38: The first-run setup wizard no longer drops a brand-new owner outside the console
+
+ On a console served under a mount — `/_console/`, which the framework CLI configures for every embedded deployment by injecting a ` ` — finishing the first-run owner bootstrap landed the new owner on the ORIGIN root instead of the console. Both of `SetupPage`'s exits navigated to a bare `/`: the success path after the account is created and the bootstrap organization renamed, and the bounce that sends an already-signed-in visitor away. `window.location.assign` does not go through React Router, so its `basename` never applied and a root-relative `/` left the SPA. It is the worst possible moment for a dead end — the first screen after creating the account, on a deployment that by definition has no other account to recover with.
+
+ Under the default `/` mount the prefixed and unprefixed spellings are identical, which is why no standalone `os dev` run ever surfaced this.
+
+ Both exits now go through the console-mount helper `LoginPage` already used for exactly this, so they land inside the SPA under every mount. They stay full-page navigations deliberately: the console shell mounts its metadata tree as soon as auth _resolves_ rather than when it authenticates, and re-keys it only on language, so the app list read while nobody was signed in would survive a router navigation and leave the new owner in an appless console. Tearing the document down is what guarantees the console rebuilds with the session.
+
+ The helper itself was module-private to `LoginPage` and had already been copied verbatim into `RegisterPage`. It now lives in one place with all three auth surfaces importing it, so the next mount fix lands once rather than three times. `LoginPage` and `RegisterPage` behaviour is unchanged, and pinned as unchanged across all three mount configurations.
+
+- 234238e: fix(console): a Setup-only environment lands on `/home`, not Setup's all-zero System Overview
+
+ A new builder arriving on a just-created environment (platform SSO, no explicit
+ target) landed on Setup's **System Overview** — a platform-health/audit
+ dashboard reading all zeros, because a fresh environment has no audit history
+ yet. The intended first screen is the environment's own home: build with AI,
+ start from a template, Your apps.
+
+ The path was `resolveLandingPath`'s rule 2. Measured end to end:
+
+ / → RootLandingRedirect → resolveLandingPath([setup])
+ → rule 2 "single visible app" → /apps/setup
+ → AppContent.resolveLandingRoute() → the app's first nav item
+ → dashboard/system_overview
+
+ Rule 2 itself is right — a one-app PRODUCT deployment should not have to click
+ through a one-tile launcher. Setup is not that app: it is the platform
+ administration console that `@objectstack/platform-objects` ships into every
+ deployment, so "the only app this viewer can see is Setup" means _this
+ environment has no product apps yet_, not _Setup is the product_. Under ADR-0075
+ the environment layer's home is the environment's own responsibility, so that
+ case now resolves `/home`.
+
+ Deliberately narrow — everything else is byte-identical:
+
+ - a declared landing still wins (rule 1, `isDefault`, untouched): an admin
+ console that genuinely wants Setup first says so, and gets it;
+ - a one-app product deployment still lands in its app;
+ - `[product, setup]` still resolves `/home` exactly as before — Setup is
+ excluded from the single-app _outcome_, never from the visible _count_;
+ - the `/setup` deep link is unchanged: `/` is "an arrival with no target",
+ `/setup` is an explicit one, and it still resolves into Setup.
+
+- 9461dd3: Form actions no longer carry a record id across an object boundary (#4292).
+
+ `ActionRunner.executeForm` forwarded `/forms/:name?recordId=` unconditionally,
+ and that URL says nothing about which object the id belongs to — so the form route
+ resolved it against the FormView's own target object. When an action fired from a
+ record of a DIFFERENT object and ids collide across objects (per-table integer
+ keys), the form silently prefilled and, since the route learned to honour the param,
+ `PATCH`ed a same-id record of the wrong object.
+
+ - **Producer**: the id is forwarded only when the firing context record's object
+ (`context.objectName`) matches the target view's object; on a mismatch no id is
+ forwarded, preserving create semantics. When it IS forwarded, the object travels
+ with it as `?recordObject=`.
+ - **Consumer**: `/forms/:name` refuses — no record read, no write — when
+ `recordObject` disagrees with the FormView's object. A URL without the param
+ behaves exactly as before, so existing deep links are unaffected.
+ - @object-ui/sdui-parser@17.5.0
+ - @object-ui/react-runtime@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/apps/console/package.json b/apps/console/package.json
index 9560915984..92f5fa8386 100644
--- a/apps/console/package.json
+++ b/apps/console/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/console",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "ObjectStack Console — opinionated, fork-ready runtime console built on @object-ui/app-shell with the full plugin set wired up. Ships as a Hono UI plugin serving a pre-built SPA.",
"license": "MIT",
"type": "module",
diff --git a/packages/app-shell/CHANGELOG.md b/packages/app-shell/CHANGELOG.md
index 4b91dfdced..5a6da57327 100644
--- a/packages/app-shell/CHANGELOG.md
+++ b/packages/app-shell/CHANGELOG.md
@@ -1,5 +1,840 @@
# @object-ui/app-shell — Changelog
+## 17.5.0
+
+### Minor Changes
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
+
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
+
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
+
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
+
+ ## The dependency move
+
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
+
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
+
+- e4d1c08: `resolveHostAppSegment` is published from `@object-ui/app-shell`'s package root, and the console's local copy of it is deleted
+
+ Which app should host a framework-owned, app-INDEPENDENT page — the Approvals Inbox, the full inbox, an internal form's created record — is one hard-won definition, and `resolveHostAppSegment`'s docblock says so at length: prefer the app the user is in or last had open RE-CHECKED against the live active list, else their first active app, else the two last resorts. It lived in `app-shell/src/utils/appRoute.ts`, and `packages/app-shell` published only its package root, which re-exported `./utils` nowhere. So the definition was unreachable from every consumer outside the package.
+
+ Something outside the package needed it anyway. objectui#4109 had to name a host app for the record an internal `/forms/:name` submit creates, could not import the resolver, and shipped a documented local subset instead: steps 1 and 2 only, returning `null` where upstream falls through further. That is the "two readers of one prose contract" shape this repo keeps paying for (#3367 / #3842) — the next edit to the resolution order lands on one copy — and the divergence was disclosed rather than smuggled precisely so it could be collected later. This is that collection.
+
+ `resolveHostAppSegment` is now exported from the package root, alongside the two predicates it is defined in terms of (`appRouteSegment`, `filterActiveApps`), and the console's copy is gone: `createdRecordPath.ts` holds the URL shape and delegates the choice. The app record type it accepts is derived from the resolver's own signature rather than re-declared, so the call site cannot drift from it either.
+
+ **Behaviour change on the created-record redirect.** Converging on the full resolver means the two cases the subset answered `null` for now name an app. An empty openable list with a preferred app keeps that preferred app unchecked — an empty list means "not loaded yet" at least as often as "this user has no apps", and demoting someone demonstrably rendering inside `/apps/{preferred}/…` would reintroduce the defect objectui#4074 removed. Anything else unresolvable — no apps and nothing preferred, or apps carrying neither `_packageId` nor `name` — lands on `setup`, the least-surprising last resort rather than a broken link. Concretely: an internal form submit that previously stopped on FormPage's in-place confirmation ("no record page to land on") now navigates to the record under the resolved app, which is the same answer every other record link in the console already gives. The write itself was never at stake, only where the user is put afterwards. The remaining `null` from `buildCreatedRecordPath` means what it always should have: there is no record to point at, because the caller has no object or no id.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+- ca269fe: fix(app-shell): a transient 404 no longer retires the shared inbox feed for the page's lifetime
+
+ `sharedUserFeeds`' `markUnavailable()` was a one-way door: `refresh`, `schedule`
+ and `onVisibilityChange` all returned early on `unavailable`, so a feed that took
+ one missing-resource answer stayed retired until the user reloaded the page or
+ switched identity. Since #4225 pointed both the header bell and Home's action
+ centre at one inbox feed, that took both panels together — reproducing the
+ #4110 / #4230 dead-bell signature from a lost race rather than from a real
+ absence.
+
+ The split is reachable because `isMissingResource` is status-shaped
+ (`httpStatus === 404` alone qualifies) and the ObjectStack client stamps
+ `httpStatus` from the response status before it reads the body — so a 404 from
+ anywhere in the transport arrives indistinguishable from the registry's
+ considered `OBJECT_NOT_FOUND`.
+
+ A retired feed now re-probes at most `UNAVAILABLE_PROBE_LIMIT` (3) times, no
+ more often than `UNAVAILABLE_PROBE_MS` (60s), on the poll timer and on
+ `visibilitychange` alike; a probe that answers with rows revives the feed and
+ restores its normal cadence. The retired state itself is unchanged — status
+ stays `ready`, the value stays empty, and no error is ever rendered — so a
+ deployment that genuinely has no messaging pipeline still reads as an answer and
+ now costs three extra reads across the whole page rather than one.
+
+### Patch Changes
+
+- 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
+
+ Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
+
+ Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
+
+ One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a ` ` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
+
+ Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- e16fd95: The AI build conversation no longer blanks itself the moment the preview opens
+
+ `useChatConversation` treated every failed resolve the same way: clear the id, clear the messages. For a FIRST resolve that is right — there is nothing to lose. For a re-resolve of the conversation the hook is already holding it is destructive, and the AI build flow fires exactly such a re-resolve at the worst possible moment.
+
+ The sequence is the magic-moment one. A build turn streams; `apply_blueprint`'s draft lands and the Live Canvas opens, switching the page from full-screen chat to the chat|preview split; the turn ends; ADR-0057 A1.b bind-on-create — which deliberately waits for that edge — re-keys the conversation to `app::build` and navigates to `?package=`. The scope flip re-resolves the same conversation, one GET issued at the instant the server is still finishing the heaviest turn of the session. A 502 or a dropped connection on that single request landed in the blanket catch.
+
+ Clearing the id there is not a conservative fallback, because of what the host does with it: `AiChatPage` keys its chat pane on `` `${chatApi}:${conversationId ?? 'pending'}` ``, and the thread itself lives inside the chat hook's instance (`useObjectChat` seeds from `initialMessages` once per mount). So `undefined` does not re-render the pane, it REPLACES it, and the blueprint card, the build summary and the Publish button all leave with the discarded instance — the reported "the whole conversation went blank right after the build finished, and only came back after switching threads and back".
+
+ A failed resolve now keeps whatever it was re-reading, when that is the conversation already held: the id is still valid and the messages are still the truth, so the surface stays as it was and the next resolve recovers. This is the other half of a guard that was already there for the empty case — the same re-resolve returning NO messages mid-turn was already refused the right to wipe hydrated history; only the failing case was still open. A resolve aimed at a DIFFERENT conversation (a sidebar switch) and a first resolve with nothing held still clear, and both are pinned negatively.
+
+ Pinned at two levels: the hook, and the page driving the real build→preview→re-key sequence and asserting the pane is never remounted across it.
+
+- d8d0d66: `AiChatPage` narrows the chat hook's messages through the exported `toRuntimeMessages` adapter instead of five casts
+
+ `useObjectChat` returns `ObjectChatMessage[]` — the shape both of its modes really produce (objectui#4424) — which is wide where local mode is wide: it keeps the authored `'tool'` role and the legacy `'partial-call'`/`'call'`/`'result'` tool states. This page wants the runtime shape, and said so five times with `messages as ChatMessage[]` (plus one `as unknown as` double cast). The narrowing was real and the casts were legal; what was missing is that a cast erases the whole difference rather than the intentional part of it, so nothing recorded which narrowing was meant and nothing could go red when it changed.
+
+ There is now one conversion where the hook's values enter the page's runtime-typed world, memoized on `messages` exactly as the plugin's own three renderers do (objectui#4399). All five sites read it; no cast replaces them, and the `as unknown as` double cast at the bound-package derivation turned out to be unnecessary once the value is honestly typed.
+
+ One behaviour changes, and it is the one the fold was measured for. `sanitizeChatMessagesForCache` declares its parameter's role as `'user' | 'assistant' | 'system'` — it has always asked for folded roles, and the cast is what let an unfolded `'tool'` past that declaration. Measured on a thread carrying one: the entry was cached as `role: 'tool'`, which the cache's own reader then rejects, so the message vanished on a cache-fallback reload; and because sanitize gates tool serialization on `role === 'assistant'`, that turn's tool invocations — including the re-serialized draft envelope behind "Review N changes / Publish" — never reached the cache at all. Folded, both survive the reload they exist to survive. The other two compute sites were measured to be fold-insensitive and are unchanged: `isConversationZh` reads `role === 'user'` and the text, which the fold can neither create nor destroy, and `deriveBoundPackageId` walks every message irrespective of role and reads keys the adapter spreads through.
+
+ Nothing on screen moves today: neither a `'tool'` role nor a legacy tool state is producible on this page's own paths, since hydration yields runtime roles and API mode's values come from `mapMessages` with v6 states already. This closes the hole ahead of a value that can reach it, and makes a future vocabulary move surface as a type error at the host rather than as rendering behaviour.
+
+- 8b971f8: The bell's Approvals and Activity tabs fill in on Home, Organizations and the AI screen — from the same fetch the cards below them already use
+
+ The top bar's bell renders on every console surface, but two of the three streams that fill it were gated on `variant === 'app'`: the pending-approvals poll and the `sys_activity` read. Off-app the popover therefore held nothing to show — the Approvals tab read "No pending approvals" and the Activity tab "No recent activity" — on the very page whose own To-do and activity cards were listing both, from the same endpoint and the same object. Neither stream is app-scoped: approvals are scoped to the signed-in user, activity to the tenant. Whichever app happened to be in the URL was never part of either query.
+
+ The badge made the inconsistency arithmetic. It is `unreadTopics + pendingApprovalsCount`, and after objectui#4199 un-gated the inbox half, only the first addend was fetched outside an app — so one user with one set of data read 1 on Home and 3 inside an app, and the popover's own breakdown line disagreed with the number on the bell.
+
+ Un-gating alone would have fixed the emptiness by paying for it twice: on `/home` the bell and the cards mount in one tree, so each owning its own effect means the approvals request and the `sys_activity` read both go out twice per page. They now share one fetch. A module-scoped store (`hooks/sharedUserFeeds`) owns each feed — one in-flight request, one 30s approvals poll, one 404-retires-the-feature rule — and both the bell and `useHomeInbox` subscribe to it. The dedupe is structural rather than agreed: there is no longer a second producer that could drift, so the badge and the card cannot show different numbers. Home's card keeps its narrower cut of the rows (human actors only, `sys_*`/`ai_*` churn dropped) by filtering the shared feed at its own call site rather than by issuing its own query.
+
+ `isApp` keeps the meaning it was introduced for. The presence avatars and the connection dot are app-shell chrome and stay behind it — and presence was never a read to begin with: it is a transport-level subscription (`useTenantPresence`), which is why the effect that used to be called `fetchPresenceAndActivities` only ever fetched `sys_activity`. The boundary this draws is data scope, not surface: user- and tenant-scoped feeds follow the bell everywhere it renders, app-scoped chrome does not.
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
+
+ `packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
+
+ Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a _second_ revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
+
+ Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is _withheld_: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
+
+ Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
+
+- ab37c5f: `DatasourcePreview` no longer renders three key groups `DatasourceSchema` rejects
+ (objectui#4131). `retryPolicy`, `healthCheck` and `capabilities` were removed from the
+ datasource document by objectstack#4583 under ADR-0049 enforce-or-remove — connection
+ retry and health probing belong to the runtime driver, and pushdown is decided by that
+ driver's own `supports.*`, never by datasource metadata. The schema is `.strict()`, so it
+ refuses all three by name, while the preview kept painting a `Retry Policy` SideBlock, a
+ `Health Check` SideBlock and a `Capabilities` chip strip for them. An author who typed any
+ of the three saw the designer confirm a draft that cannot be saved — the preview was the
+ only surface acknowledging the keys at all, so it was also the strongest signal they
+ worked. `pool` and `ssl` are still declared and still render.
+
+ This is the third wave of the same defect on this one file (objectui#3275 deleted
+ `d.type` / `isDefault` / the `Array.isArray(capabilities)` branch; objectui#3143 deleted
+ the read-replica pill), and the removals had been in `main` eight days before anyone
+ noticed — nothing compared the two halves of the contract. They are now compared
+ mechanically: `DatasourcePreview.spec-keys.test.ts` derives the keys the preview reads off
+ the draft from the component's own AST, derives the keys the schema accepts from the
+ schema object's `.keyof()`, and fails on any read the schema would reject. Neither side is
+ written down as a list, so a key added or removed in `@objectstack/spec` moves the pin on
+ the next dependency bump instead of leaving it stale.
+
+- ee66e2e: `DeclaredActionsBar` reads `visible` / `disabled` off the typed action def instead of through `(action as any)`.
+
+ The `disabled` cast was the one the maintainer's 2026-08-06 ruling on
+ objectstack#4075 named: `ActionDef.disabled` was hand-written as
+ `string | boolean` and could not describe the `{ dialect, source }` envelope the
+ spec emits, so the bar had to reach around the type to evaluate it. With both
+ keys now derived from the spec's unified three-arm shape (`@object-ui/core`, step 3) there is nothing left to reach around. The adjacent `visible` casts go with
+ them for the same reason — step 2 declared `visible` and deleted `ActionEngine`'s
+ equivalent casts, but missed this file's.
+
+ No behaviour change: `toPredicateInput` and `hasDeclaredPredicate` both take
+ `unknown`, so the casts only ever affected whether the property access compiled,
+ never which verdict it produced.
+
+- 4cb0562: `DeclaredActionsBar` binds its row through the shared `usePredicateRecordContext`
+
+ The bar held an inline copy of the three-way row binding — `{ ...row, record: row, data: row }`, added by objectui#4077 to fix its root-only predicate scope. objectui#4079 fixed the same fault on the four generic action renderers and gave the rule one name instead of a fifth copy: `usePredicateRecordContext`, exported from `@object-ui/react` beside `useCondition`. The bar now calls it.
+
+ No verdict changes on any row: the two copies agreed wherever a row exists, which is every surface that mounts this bar today. They differed in one corner, and the helper's semantics are the ones kept — with **no** row, the bar now binds nothing where it used to bind `{ record: {}, data: {} }`. Since `useCondition` merges this context over the ambient predicate scope, the old shape blanked out a `record` a host had put in the scope itself; "this surface has no row" and "this surface's row is empty" are now distinct here too.
+
+ Two implementations of one binding rule is what objectui#3367 / #3842 rule against, and this family already paid for it once at the `toPredicateInput` level (objectui#3314: two normalizations drifted, and the same `visible:` predicate reached different verdicts depending on which path surfaced the action). Nothing had drifted here yet — the next edit to the rule is what this closes off.
+
+- b318273: fix(app-shell): the metadata-admin designer's own i18n table uses the typographic ellipsis
+
+ The designer carries its own flat `en`/`zh` table rather than reading the ten locale
+ packs, so objectui#3878 — which converged those packs on U+2026 `…` per the
+ consistency pass on objectstack#6015 — left it behind. Ten values across five keys
+ (`engine.form.select`, `.selectObjectDots`, `.addObjects`, `.selectFieldDots`,
+ `.addFields`) still ended in three ASCII full stops, and the designer renders inside
+ the console shell, so `Select...` sat on screen beside the packs' `Select…`.
+
+ All ten now use `…`, and the table's header records the convention for the next
+ author.
+
+- f762f5b: refactor(app-shell): collapse the metadata-admin designer table's byte-identical key pairs
+
+ objectui#4377 converged five designer i18n values on the typographic ellipsis `…`. Three
+ of the five then held values byte-identical to a sibling key in both the `en` and the `zh`
+ block, so the `Dots` suffix — which used to name "the spelling _with_ trailing dots" —
+ named nothing any more, and one key had no consumer at all:
+
+ - `engine.form.selectObjectDots` → collapsed onto `engine.form.selectObject`
+ - `engine.form.selectFieldDots` → collapsed onto `engine.form.selectField`
+ - `engine.form.select` → deleted (no call site; byte-identical twin of the live
+ `engine.form.selectEllipsis`)
+
+ The two live `Dots` call sites in `widgets.tsx` now read the surviving key. Every rendered
+ string is unchanged — the collapsed values were byte-identical — so this is a table-shape
+ change only: the designer no longer names one placeholder twice, which is where the next
+ author would otherwise re-split the dialect.
+
+- bf2fd3d: `?runAction=create_environment` is no longer consumed when the environments toolbar has no create action to run it on.
+
+ `EnvironmentListToolbar` armed the deep link on `toolbarActions.length > 0` — the presence of _any_ toolbar action. Consumption of this deep link is modelled as stripping the param from the URL, so arming is destructive: a toolbar carrying some other action stripped `?runAction=create_environment` and triggered nothing (measured with the real `action:bar` + `action:button` runner: `urlParam=null execute=0`). Because the strip _is_ the consumption, the user's intent was unrecoverable — reloading could not retry it, and the welcome page's "Create your environment" CTA (#844) simply landed on the list with no dialog and no way to ask again.
+
+ Arming now keys on the create action actually being present. When it is not, the URL is left alone, so the next mount that can act on the deep link still does — a reload, or the action arriving with fresh metadata.
+
+ The two lists that used to disagree are now one. The toolbar filtered placement only (`locations`), while `action:bar` additionally applies the ADR-0066 D4 capability gate (`requiredPermissions`) to what it renders — so a create action the caller may not invoke was counted here and dropped there, which is the divergence that made the bug reachable without any change to cloud metadata. The toolbar now builds its list with both of the bar's predicates (`actionRendersAt` + `useCapabilityGate`, the shared hook that exists precisely to keep self-filtering surfaces from drifting), and every affordance derived from that list follows: the loading skeleton is held only for a create button that can actually arrive, and the plan-locked "Add environment" upgrade CTA — which stands in for the create action — is not offered when there is no create action to stand in for.
+
+ The `#3803`-verified consumption ordering is untouched and re-pinned: the runner still consumes `autoTrigger` before the parent strips the param.
+
+- f8595a0: Using a list's filter panel no longer overwrites the view's source-declared `filter` for everyone
+
+ Opening a Console list view's filter panel and clicking **Add filter** wrote a view-customization overlay into `sys_metadata`. The row that button inserts is incomplete by construction — `{ field: , operator: 'equals', value: '' }` — and a view override is merged over the source-declared view key-wise (`{ ...source, ...override }`), so that one stray condition became the view's _entire_ `filter`. The list then came back `total: 0` on every subsequent visit, for every user of the view, and the panel's own **Clear all** could not undo it: clearing wrote `filter: []`, which is still an override that deletes the source declaration. Recovery required deleting the `sys_metadata` row and restarting the service.
+
+ Two independent changes, because the defect had a write half and a read half.
+
+ **The write is gone.** `persistViewFilter` and both `onFilterChange` persist bindings are removed: a filter-panel interaction is transient state belonging to the session and to `writeListFilterState`'s per-browser restore, never to the view's stored body. The threshold for writing a view overlay is now an explicit save — `handleViewConfigSave`, and `ObjectDataPage`'s "Save as view". `ObjectDataPage` had already made exactly this call for the sibling surface ("Deliberately NO onSortChange/onFilterChange persistence hooks", #2251); this surface was the outlier. The surviving handler still writes localStorage, so the panel is not amnesiac — it just no longer speaks for every other user of the view. `foldFilterGroupToSpecRules` (objectstack#5159) survives untouched and is still the one dialect for the explicit paths; what was removed is the automatic write, not the fold.
+
+ **Already-poisoned installs self-heal on read.** `sanitizeViewOverride` runs on both branches of `loadViewOverrides` and strips conditions an overlay must never impose — an empty value against an operator that wants one, in both the spec `ViewFilterRule` shape and the legacy runtime triple — then drops the `filter` **key** entirely when nothing effective survives. Dropping the key rather than writing `[]` is the whole point, and the merge semantics are measured in the tests rather than assumed: `filter: []` wins the spread and blanks the declaration, whereas no `filter` key falls through to it. So a stored `field equals ""` and a stored `filter: []` both stop overriding, and the source filter wins again on the next load — no `sys_metadata` surgery, no restart.
+
+ One consequence is deliberate and worth naming: an overlay can no longer express "this view has NO filter" over a source view that declares one. That is the strictly safer side of the trade, because the shape that expressed it is the same shape that silently erased source declarations; an author who genuinely wants no filter edits the view, which writes the view body rather than an overlay.
+
+ The existing objectstack#5159 ratchet is retargeted rather than deleted — it now asserts that _no_ filter reaches the view-config persist path, that the `persistViewFilter` seam does not exist to be called, and that no `onFilterChange` handler reaches any persist call, with the explicit-save path pinned alongside as a control.
+
+- 6d8231c: A `type: 'form'` action fired from a record now EDITS that record instead of creating a duplicate
+
+ `ActionRunner.executeForm` forwards the record an action was fired from as `/forms/:name?recordId=`, but the console's internal form route never read that param. The only query params it consumed were the `prefill_` ones, so the route rendered EMPTY inputs, and its submit was an unconditional `POST /api/v1/data/:object` — an insert. An "edit this record" action therefore opened a blank form and, on Submit, created a second record while leaving the original untouched. In the showcase app: open any Task, click **Log Time**, fill it in, Submit — a NEW Task appeared. Until objectui#4109 the damage was hidden behind the anonymous "Your submission has been received" panel; once an internal submit started landing on the record it wrote, the duplicate became visible immediately.
+
+ `?recordId=` now selects the whole read/write pair. The route loads the record with `GET /api/v1/data/:object/:id`, prefills the inputs with its stored values, and saves with `PATCH /api/v1/data/:object/:id` — the verb the data plugin declares (`plugin-rest-api.zod.ts`), the one `packages/rest` registers, and the one every other update client in this workspace already spells. After a successful save the user lands back on the record they edited.
+
+ A `recordId` the route cannot honour now fails closed. A record that 404s or 403s, a payload whose object contradicts the form's target, and a present-but-blank `?recordId=` each render the form's error state; none of them falls back to create mode, because a blank form whose submit inserts a duplicate is this bug's exact harm and silently degrading into it would just re-arm it. A `recordId` naming a record of a different object is not found under the form's own object, so it takes the same refusal path.
+
+ When a URL carries both a `recordId` and `prefill_` params, the explicit params win for the fields they name and the record's stored values fill the rest — a producer that forwards both is expressing intent, and the per-field instruction is the more specific one. Stored nulls and empty strings count as real values and beat a field's create-time `defaultValue`, so opening an edit form never silently proposes a change the user did not make.
+
+ Two surfaces are deliberately untouched. Create mode — no `recordId` — behaves exactly as before, and the public `/f/:slug` path ignores `recordId` entirely: an anonymous visitor controls the URL, so honouring it there would turn a public form into an arbitrary-record reader and writer. In `@object-ui/app-shell` only the URL-param registry's documentation changed, recording that `recordId` now has a second reader on a route that can never match the same URL as the record drawer's.
+
+- de62779: Home's action centre badges everything that is waiting, not the five rows it has room for
+
+ `/home` showed two numbers for one question. The bell badges distinct unread topics plus pending approvals over the shared feed's full 20-row window; the action centre 200px below badged `pendingApprovalsCount + notifications.length` — and `notifications` is the list it renders, which `useHomeInbox` caps at 5. So nine unread messages read as **9** on the bell and **5** on the card, on one page, about one set of rows. The badge was reporting the size of a preview as if it were a total.
+
+ Before objectui#4225 the card could not have said anything else: its own read was `$top: 5`, so nine was not a number it had. Both surfaces now cut from one already-joined feed, so the true count is in hand at Home's call site and the cap is a presentation slice over data the card already holds.
+
+ `useHomeInbox` grows one additive field, `unreadTopicCount`, and `HomeActionCenter` takes it as a required prop: badge = `pendingApprovalsCount + unreadTopicCount`, list = the same unread set, newest first, still capped at 5. Badge means "how much needs you", list means "the newest few of it" — two semantics, each truthful, one number.
+
+ The count is the bell's own fold (`groupNotifications`, by `(topic, title)`) applied to the bell's own rows, deliberately, rather than the pre-slice length of Home's list. That length is title-folded and drops blank titles, so it would agree with the bell on ordinary data and disagree whenever two topics share a title — and "two derivations of one number that agree usually" is exactly the defect objectui#4316 was. One fold, applied twice, cannot drift.
+
+ Two adjacent behaviours are unchanged and now pinned as such: the approvals addend (distinct pending request ids from the shared REST feed, degrading to 0 on 404) and the list's own cap of five. "You're all caught up" is now gated on the total rather than on the rows on show, so it can no longer contradict the badge above it.
+
+- 771466a: Home's action centre no longer says "You're all caught up" to a user whose inbox it failed to read (#4235)
+
+ `useHomeInbox` caught every failed `sys_inbox_message` read to `[]`, so a denial
+ arrived at `HomeActionCenter` wearing the exact shape of an empty inbox and the
+ panel reported a quiet day, with no badge, to a user with nine unread messages.
+ That is the reported symptom, and objectstack#7344 measured its mechanism in a
+ browser: `403 PERMISSION_DENIED` on that object for every non-admin session,
+ while `/api/v1/notifications` — a projection of the very same rows — answered
+ with their messages. It also resolves the cross-run contradiction the card
+ carried: two QA runs on one console pin disagreed because one was an admin and
+ one was not.
+
+ The hook now reports `notificationsStatus` (`idle` / `loading` / `ready` /
+ `error` — `MetadataProvider`'s vocabulary, per #4300's one-dialect ruling), and
+ the affirmative copy renders only on `ready`. An unanswered read gets a quiet,
+ non-affirmative notice instead, rendered alongside the approvals row when only
+ that half answered. A deployment with no inbox object at all is still an answer
+ and still reads as caught up, unchanged.
+
+ The source is unchanged and deliberately so: ADR-0030 names `sys_inbox_message`
+ as the console's consumer channel, and `/api/v1/notifications` projects the same
+ query one hop later.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
+
+ Two consequences worth knowing:
+
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
+
+- f812de6: The bell popover's and Home's "see all" drills open inside an app the user can actually open, not the setup app
+
+ Four navigation producers hardcoded `/apps/setup/...` for pages that are not bound to the setup app at all: Home's notification fallback (`sys_inbox_message?view=mine`, taken whenever a notification carries no `action_url`), Home's "View all activity" (`sys_activity`), and the bell popover's two footer links to the same two pages. `sys_inbox_message` and `sys_activity` are framework-owned objects that resolve at `/apps/{any app}/{object}` — exactly like `system/approvals`, whose sibling entry in the very same popover was already resolving the current app. The in-code comment defending the hardcoded path argued the OBJECT is app-independent, which is true, and which is precisely why rendering it in `setup` does not follow.
+
+ Both halves of the cost were measured in a browser before the fix. A business user whose app list does not contain `setup` clicked "View all notifications" and got the target rendered inside their own app's shell with the page showing "You don't have access" — a softer, more confusing failure than a hard app guard. Every other user, admins included, was switched out of the app they were in: the URL, the sidebar and the header app switcher all flipped to Setup, with no announcement and no way back except the app switcher.
+
+ All four now resolve the host app through one shared helper (`resolveHostAppSegment` in `utils/appRoute.ts`), which generalizes the resolution objectstack#7231 introduced for the approvals entry and which Home and the popover both call, so the two surfaces cannot drift into different answers: the app the user is in (or last had open) re-checked against the live active-app list, then their first active app, then — only when the list names no app at all, i.e. it has not loaded yet — the caller's own hint, and `setup` last. A user whose current app is `setup` still lands in `setup`; a remembered app that has since been deactivated or hidden is not resurrected as a link.
+
+ Two admin-scoped links, `/apps/setup/system/marketplace` and `/apps/setup/system/apps`, are deliberately left alone and pinned as such: those are setup-app surfaces by intent.
+
+ Routing was not the only reason a business user saw an empty inbox — objectstack#7344 (no shipped permission set grants `sys_inbox_message`) is a second, independent cause, and the destination can still refuse the data read until that grant question is settled. This fix is necessary rather than sufficient, and the tests assert the resolved target, never the far end's data grant.
+
+- 7b07832: fix(app-shell): the top-bar bell polls the inbox on every console surface, not only inside an app (#4110)
+
+ The bell's `sys_inbox_message` + `sys_notification_receipt` poll was gated on the
+ header's `isApp` variant flag — the flag that exists to hide the app-only presence
+ avatars and connection dot. The bell itself renders in every variant, so on Home,
+ Organizations and the full-page AI screen its Notifications tab held `[]` forever:
+ "Unread" read "You're all caught up" and "All" — which applies no predicate at all —
+ read "No notifications", while Home's own To-do card listed the same row from the
+ same object. The inbox is scoped to the signed-in user, not to the app in the URL,
+ so the poll is now scoped by `user?.id` only.
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- 5f40de7: Fail when a `t()` call site's arguments are not the holes its `en` value has, and delete the three that were inert
+
+ `scripts/check-i18n-call-site-keys.mjs` gains a fourth failure class, `interpolation-parity`: for a call site whose key resolves to a readable `en` leaf, the set of interpolation option names it passes must EQUAL the set of `{{hole}}` names in that value. Both directions fail, because they fail differently — an argument with no hole is dropped by i18next in silence, and a hole with no argument leaves its own braces in what the user reads.
+
+ Nothing else could see this. `all-locales-key-parity` does compare placeholder shape, but pack against pack, so ten packs agreeing on `Update` while the call site passes `version` is full parity. `check-i18n-en-drift` fires only when an `en` string changes. And objectui#3810's `default-value-drift` is satisfied the moment the call site's inline default matches the pack — which is exactly how `home.welcome` got here: its value was rewritten from `Welcome to {{product}}` to `Build your business system with AI`, the call site's default was later aligned to the new sentence, and the now-inert `product` argument stayed sitting beside it.
+
+ The repo-wide run found **3 inert arguments and 0 unfilled holes**, so the rule lands hard, with no baseline. All three arguments are deleted rather than answered with a new hole in `en.ts`:
+
+ - `marketplace.action.updateTo` — `version`, on a primary button reading a bare `Update` while its sister key in the same file renders `Update → v{{version}}`.
+ - `home.welcome` — `product`, so no white-label deployment's name has ever reached the console hero.
+ - `objectActions.resetPackageSetSuccess` — `label`, copied from the `deleteSuccess` branch next to it, whose sentence does name the record.
+
+ **No rendered output moves, on any path.** With a provider mounted, i18next dropped these arguments already. With none, react-i18next's `notReadyT` returns `optsOrDefaultValue.defaultValue` verbatim — there is no interpolation step on that path at all — and all three inline defaults are hole-free too, so the miss path is unaffected as well. Adding the hole instead would have been a copy change: it moves a string users read today and obliges the other nine packs through objectui#3650. The gate accepts either resolution; choosing between them is not its business, and objectui#3546's slice-five assertion — written to force this decision rather than let it be settled silently — now pins the chosen state.
+
+ One key is registered as filling its hole downstream: `auth.forgotPassword.successDescription` travels through `t()` with `{{email}}` intact because `ForgotPasswordForm` substitutes the address itself, once the form knows it. That entry silences the unfilled direction only — passing `email` to `t()` there would let i18next consume the hole and make the form append the address a second time, and the gate still reports it.
+
+- 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
+
+ `LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
+
+ The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
+
+ **The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
+
+ Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
+
+ While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
+- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
+
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
+
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
+
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
+
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
+
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
+
+- 3032107: metadata-admin: retire the standalone `validation` resource, and move
+ `ValidationPreview` onto the embedded path the framework actually evaluates
+ (objectui#4132)
+
+ `anchors.ts` registered a standalone `validation` resource anchored by
+ `anchorByField('object')`, complete with `createFields` / `createSchema` /
+ `createDefaults`. That gave every object's Related tab a "Validations" group
+ whose `+` routed to `validation/_new`, and the file justified it in a comment:
+ "usually embedded in the object, but standalone variants do exist."
+
+ They do not. ADR-0088 / objectstack#4509 retired `validation` as a metadata
+ kind, and the framework ledger (`packages/spec/liveness/validation.json`)
+ records that the door never led anywhere in the first place:
+
+ > a STANDALONE `validation` item (file `*.validation.ts` or Studio) never
+ > reached any object's write path, because the schema has no object-binding key
+ > and — every variant being `.strict()` — an author could not add one; no merge
+ > code existed […] A state machine authored through that door saved cleanly and
+ > gated nothing.
+
+ Re-measured against the installed `@objectstack/spec` 17.0.0-rc.6 by parsing the
+ shipped registries rather than grepping source: `validation` is in neither
+ `DEFAULT_METADATA_TYPE_REGISTRY` (27 kinds) nor
+ `listUnregisteredKindSchemaTypes()` (`connector`, `sharing_rule`, `webhook`). So
+ the console was offering an authoring affordance for a kind the framework does
+ not have — the "shipped false signpost" shape, where the most confident surface
+ in the product is the one for the thing that does not work.
+
+ **What is gone**: the standalone registration, its create affordance, and the
+ stale comment. **What stays**: the embedded anchor `__object_validation`
+ (`editAs: 'validation'`, `embeddedPath: 'validations'`) — rules live inside their
+ object, and that is the path the framework evaluates.
+
+ **The renderer moved rather than retiring with the door.** `ValidationPreview`
+ renders a rule's label, description, severity/message callout, per-variant body
+ and tags, and until now the only route that mounted it was `ResourceEditPage`'s
+ Preview tab on the retired standalone resource; the governed route (Related tab
+ → `MetadataDetailDrawer` → `EmbeddedItemEditor`) drew a bare `SchemaForm`.
+ `EmbeddedItemEditor` now looks a preview up by the anchor's `editAs` and mounts
+ it above the form on the live draft. The lookup is generic, so this is not a
+ `validation` special case: an embedded sub-type with no registered preview
+ (`index`) is unchanged and grows no empty preview chrome. This also re-opens
+ objectstack#7427's `validation.label` / `.description` / `.tags` ledger rows,
+ which were graded `dead` precisely because the render was unreachable on the
+ evaluated path.
+
+ **One read went with the door.** `ValidationPreview` painted an
+ `object: ` pill, exempted from the objectui#3275 cleanup on the stated
+ ground that "anchors.ts registers a standalone `validation` resource … so a
+ standalone rule really does carry it". With that registration gone the read has
+ no producer, and it never had one on the governed path either — measured,
+ `ValidationRuleSchema.safeParse({ type: 'script', …, object: 'account' })`
+ returns `unrecognized_keys: ['object']`. The pill could only ever confirm a key
+ that makes the rule unsaveable. Its test case is replaced rather than re-spelled:
+ the schema's verdict is now the instrument, and the preview is asserted not to
+ paint the key.
+
+- c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
+
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
+
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
+
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
+
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
+
+- 605b747: The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
+
+ objectui#4133 (PR #4236) put the gate's advisory findings — the ones that ride a **200**, where the save succeeded and the row persisted — in front of Studio authors, but it covered only one of the two client classes that write through `PUT /api/v1/meta/:type/:name`. The wiring lifts at `useMetadataClient`, which is where every app-shell path takes its `MetadataClient` from. `ObjectStackClient.meta.saveItem` — the SDK client hanging off `ObjectStackAdapter` — is a different class reaching the same door, and every one of its callers awaited the call and discarded the response, so an `advisories[]` the server attached was parsed off the wire and dropped one layer further out.
+
+ Those callers all write in **active** mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing. The list is `MetadataService` (five saves behind the Object Manager and Field Designer), `useNavigationSync`, plugin-designer's Create/EditAppPage, and the adapter's own `updateViewConfig` / view / `updateDashboard` paths.
+
+ `ObjectStackAdapter` now carries an `onSaveAdvisory(listener)` subscription and emits on it after a metadata save whose 200 carried a non-empty `advisories[]`; `AdapterProvider` subscribes once and renders through the same `emitSaveAdvisories` the other client class already uses, so both doors produce one wording on the warning tier that says "Saved" first. The emitter is installed **once at the adapter/client seam** rather than at the call sites: every caller above reaches the save door through the adapter's own long-lived `ObjectStackClient`, so one interception covers all of them, plus any future one, without a toast copied into a dozen places — the same reasoning that put #4133's sink at one factory instead of twenty call sites.
+
+ It is a sibling of the `onWriteWarning` channel (#3431/#3455) rather than a second payload pushed down it, which is what `MetadataSaveAdvisoryEvent` already said it was modelled on. `WriteWarningEvent` is a closed shape whose required `droppedFields` means "fields the write legally stripped", so carrying advisories on it would either force every existing subscriber to grow a branch or make the event lie about what happened. The seam's shape is reused; its event type is not. `readSaveAdvisories` is shared unchanged between the two clients — one reader, two call sites — which the response envelopes make possible: the spec puts `advisories` at the save body's top level, and the SDK returns that body verbatim (it strips its `{ success, data }` envelope only when a `data` key is present, and this body has none). That measurement is pinned by tests that drive a real SDK client through a fake `fetch` rather than stubbing the method under test.
+
+- 79a40ad: Set-default on a saved view fires its write again — a stored `id` can no longer rename the tab out from under the overlay read
+
+ "Set as default" could do nothing at all: no toast, no request, no change. The filer measured that the adapter cannot produce that — `ObjectStackAdapter.updateView` has no early return between its read and its `saveItem`, so every patch shape it is handed becomes a write — which put the cause above it, in the view switcher.
+
+ It was an identity seam. Views reach `ObjectView` through two independent reads of the same `type='view'` metadata namespace: the object definition's `listViews`, keyed by the composer's `.` identity, and the adapter's `listViews()` overlay rows. Everything that decides whether a view is mutable — the tab's `readonly` flag, the `isSavedView` guard, and the early return in all five mutating handlers — asks whether a tab's id is among the overlay keys. So the two reads have to spell the same view's identity the same way, and they had two different spellings to do it with. The overlay side stamped `id` last and was safe; the metadata side built each tab as `{ id: , ...body, ...override }`, with `id` FIRST, so any `id` key inside the merged body or the stored override replaced it.
+
+ Both of those are stored documents that really do carry one. `persistViewPatch` writes the whole tab object — its `id` included — back through `updateViewConfig`, so a personalized view leaves an override row carrying an `id`; and a duplicated view copies its source artifact's `id` verbatim into the view body, which `MetadataProvider` spreads into the `listViews` entry. Either way the tab ended up under an id the overlay read had never heard of, the view was classified as system, and — because the set-default, rename and delete entries render only under `!readonly` — the menu entry was **absent** rather than present-and-inert. That is why the symptom reads as "nothing happened" instead of "refused": there was no control left to click, and the guard's toast was never reached.
+
+ The fix is one spelling instead of three. `viewRowId` answers "what is this row's identity" (`name` → `id` → `_id`, empty strings skipped) for the producer and every reader, and is idempotent across the overlay normalization so the key written and the key read back cannot drift. `viewEntry` stamps identity last at all three tab-building sites, so a tab id is a property of the key it was looked up by and never of the data. `isSavedViewId` is now the single predicate behind both the tab's `readonly` flag and the handler guard, so the menu and the handler agree by construction rather than by coincidence.
+
+ No behavior was loosened to get there: the guard is unchanged, and a genuine system view stays read-only with its mutating entries correctly absent.
+
+- b3f665b: `/setup` is a real address again — the console gets a stable deep link into platform administration instead of bouncing you back to home
+
+ Opening `/_console/setup` landed on `/_console/home`. System settings had no direct URL at all: the only way in was clicking the 「系统设置」 card on the home launcher, which meant the entry point could not be bookmarked, could not be pasted into a support runbook, and was asymmetric with Studio, whose front door has been stable for a while.
+
+ The route was never missing — it was occupied. `/setup` mounts the first-run owner-bootstrap wizard (ported here when the Account SPA was retired), and that page evicts everyone it is not meant for: a signed-in visitor via `window.location.assign('/')`, which the landing resolver then turns into `/home` on any multi-app deployment. So the bounce was the wizard doing its job at a URL that had quietly acquired a second, more common meaning.
+
+ `/setup` now decides between the two, on the condition the wizard itself already probes — whether the deployment has an owner (`GET /api/v1/auth/bootstrap-status`). No owner yet: the wizard, unchanged. Otherwise: the platform-administration deep link. A live session short-circuits the probe entirely, because `hasOwner: false` cannot be true while somebody is signed in — which also keeps a failed probe from re-creating the bounce it is meant to remove. The verdict is latched for the lifetime of the mount, because `signUp()` flips the session to authenticated while the wizard is still renaming the bootstrap organization, and re-deciding on that flip would unmount the wizard mid-submission.
+
+ The destination is read from metadata rather than spelled out. `SetupRedirect` (new, exported from `@object-ui/app-shell` alongside `SystemRedirect`, with its policy available as the pure `resolveSetupAppPath`) resolves the Setup app through the same `appRouteSegment()` helper the home launcher's app cards use, and forwards to the app ROOT — so the page you land on is whatever `AppContent` already resolves as that app's landing item, not a second copy of that policy that would drift the next time Setup's navigation is re-ordered. Search and hash carry across the hop, as they do for `SystemRedirect`.
+
+ Two edges are handled rather than papered over. An unauthenticated deep link now goes to `/login?redirect=%2Fsetup` through the console's existing auth-redirect contract — router-derived, so it stays correct under a ` ` mount — and lands back on `/setup` after signing in; previously it reached a bare `/login` and the deep link was dropped. And a viewer whose metadata contains no Setup app (the common cause is not a broken build but a missing `setup.access` permission, which filters the app out server-side) gets the shell's ordinary "App not available" screen, with its retry and its one-shot metadata re-check — never a silent landing on home, and never the bare `/apps/setup` pseudo-route, which would have resolved to whichever app happens to be the default.
+
+ `/_console/studio` was checked for the same asymmetry and needed no change: bare `/studio` is a declared front door rendering the builder landing, and `/studio/:packageId` already redirects to its Data pillar.
+
+- bed18a5: Home's action centre stops counting messages the user has already read, and the inbox is read once per page instead of twice (#4316, #4225)
+
+ `useHomeInbox` read `sys_inbox_message` and nothing else — it never joined
+ `sys_notification_receipt`, where ADR-0030 (resolved decision 2) puts read-state.
+ So Home's "Needs your attention" card could not tell a read message from an
+ unread one: it listed the five most recent unconditionally and badged them. A
+ user who opened the bell, read all nine messages and returned to Home still found
+ up to five of them filed as work waiting on them — while the bell two hundred
+ pixels above correctly showed zero, because its own poll did join the receipts.
+ One page load, two panels, opposite claims about the same rows (#4316).
+
+ The fix is the one #4225 sketched: `hooks/sharedUserFeeds.ts` gains an inbox feed
+ holding the bell's already-joined 20-row window, polled once at the bell's 10s
+ cadence, and BOTH consumers derive from it — the bell lists the window and badges
+ its unread topics, Home takes the unread ones newest-first and caps them at its
+ own smaller limit. Home's second query is gone (one `sys_inbox_message` read and
+ one `sys_notification_receipt` read per page, not two and one), and the two
+ surfaces can no longer disagree about a row's read-state, because there is no
+ second read left to drift from the first.
+
+ Two supporting changes travel with it, both visible only when something goes
+ wrong. The shared store now reports per-feed status in the same four words the
+ rest of the console uses (`idle` / `loading` / `ready` / `error`, per #4300's
+ one-dialect ruling): it used to swallow every failure into "keep the last value"
+ and say nothing, which is indistinguishable from a successful re-read, and would
+ have turned #4235's hard-won `error` state back into stale-but-confident data on
+ its way through the store. A missing object (404 / `OBJECT_NOT_FOUND`) is still
+ an answer — the deployment has no inbox, so nothing is waiting — and a denial
+ still is not. The bell's hidden-tab throttle, its return-to-tab refetch and its
+ failure backoff moved into the store with the poll rather than being dropped, and
+ now apply to every shared feed.
+
+- f046f88: Studio's read-only packages stop advertising writability they do not have — the `access` header badge and the `Data → Form` layout caption now report the gate that actually governs the screen
+
+ Two captions on the Studio package surfaces contradicted the controls beside them. Both are display-side defects: the interaction gating was already correct in each case, and this change does not touch it.
+
+ **The `access` header badge.** `/studio//access` rendered a permission set's title row as `... · Security · writable · ...` while all 207 permission checkboxes and the Save button below it were disabled with the read-only-package tooltip. The two renderings answer the same question — "can you write this?" — from different inputs. The controls read `!!resolved.allowOrgOverride && !readOnly`, i.e. the type registry AND the host package gate, which is the right answer. The header badge read `entry.allowOrgOverride` alone, and `permission` is one of the overlay-allowed types, so that flag is true and the badge said `writable` in every package. `PageShell` now takes a `readOnly` host gate that dominates the type-level flag, and the Studio Access pillar passes the same value it already gives the matrix's own controls; the tooltip names the package as the reason, reusing the wording (`engine.studio.pkg.readonly*`) the Studio top bar and the identity strip already use for this gate rather than inventing a second spelling. The badge slot was extracted into one `WritabilityBadge` component because it was duplicated verbatim in the compact and hero headers — the shape that lets a gate be honoured in one header and forgotten in the other.
+
+ **The `Data → Form` layout caption.** In a read-only package the form tab read `Draft layout — your unsaved changes` with `Save draft` disabled and no draggable element on the surface at all. The mechanism was not a stale diff: the caption was selected by `formMode === 'layout'` — a TAB selector — so it asserted pending edits on every clean layout tab, read-only or not, while the component's real `dirty` flag sat a few lines below driving the preview warning. The claim is now made only when there are real local edits on a surface that can save them, and a neutral `Draft layout` caption (`engine.studio.data.form.layoutBadgeClean`, added in both locales) covers every other case, so the caption still names what you are looking at.
+
+ Nothing about the read-only policy changed. This is the B-half of the objectstack#5768 split ruling — Studio keeps its blanket package-level read-only ("Studio 维持包级只读") while the A-half, whether the overlay-allowed types become individually editable, is still under review. The suites added here pin that deliberately: alongside the caption assertions they carry control tests asserting that a read-only package still disables every checkbox, still offers no Save, and still exposes no add-field affordance — so a change that made anything editable would fail even though the caption assertions would pass.
+
+ Not covered, and correct as they stand: the metadata directory and resource-list badges read the type registry alone, which is the whole truth in those non-package-scoped screens; the OWD overview panel's unsaved badge and the form tab's preview warning were already derived from real edit state.
+
+- 690ae0f: The Unpublished-app banner now reads the ADR-0045 publish gate `_unpublished`, not the navigation flag `hidden` — so a published app that is merely kept out of the launcher no longer wears the "only builders can see it" watermark
+
+ Upstream (framework PR #6942, objectstack#4829 ruling A1) split one flag into two with disjoint meanings: `_unpublished` is the machine-managed publish gate that materialization sets and publish clears, while `hidden` became author-declared navigation presentation and nothing else — "Hidden apps stay fully routable and permission-checked". `UnpublishedAppBar` read `hidden`, which after that split was wrong in **both** directions at once, and both are fixed here:
+
+ - **False positive.** A published, nav-hidden app — the built-in `account` app is the specimen — rendered the amber "Unpublished app — fully functional, but only builders can see it" bar. The app was live to every user; the console told its owner the opposite.
+ - **False negative.** A genuinely unpublished app whose author had not also set `hidden` rendered no bar at all. The server withholds unpublished apps from non-builders, so this watermark is the _builder's own_ only signal that what they are looking at is not live yet — losing it means publishing feels already-done.
+
+ This was not latent: console pin bump objectstack#7308 put an objectui build keying on `hidden` in front of a framework that had already redefined it, so both directions were live in the bundled console.
+
+ The Publish button on that bar writes `PUT /meta/app/{name}` with `{"_unpublished": false}` instead of `{"hidden": false}`. `false` rather than a key delete, matching the server's own `POST /packages/:id/publish-drafts` flip: ADR-0045 §3 makes publish/unpublish symmetric, so the gate stays two-state rather than a key whose absence has to be re-derived. Whatever `hidden` the app carries now rides through the write **untouched** — publishing an app must not silently rewrite the author's navigation choice, which is the regression objectstack#4829 was filed for in the first place. The package-level Publish (`POST /packages/:id/publish-drafts`) is unchanged; the server-side flip it triggers already clears `_unpublished`.
+
+ **Launcher surfaces deliberately do NOT move to the new key** and are pinned against it by test, because the symmetry is a trap rather than an oversight. `_unpublished` is enforced server-side — the REST metadata gate withholds those apps from everyone who should not see them — so an unpublished app that reaches the client at all belongs to a builder who is entitled to navigate to it; filtering it in the launcher would hide a builder's in-progress app from the builder while buying no protection the server was not already providing. `hidden`, by contrast, has no other enforcement point anywhere: that client-side filter _is_ its entire implementation. Two keys, two enforcement layers, so the App Switcher, the `AppContent` launcher list, the home grid and the root landing resolver all keep filtering on `hidden` alone.
+
+ No authoring surface, wire format or exported type changes. The `publish-drafts` response fields `unhiddenApps` / `unhideError` keep their spelling: the framework still emits exactly those names, and renaming only the consumer would be the same silent break this pair of cards exists to close.
+
+- eec2e4f: `useObjectChat` declares the message shape it actually hands back
+
+ The hook typed `messages` — and the `onSend(content, messages)` callback fed from it — as `@object-ui/types`' authoring `ChatMessage`. That was true in local mode only. In API mode the values came out of the runtime mapper and were asserted into place with `as OuiChatMessage[]`, and the authoring contract declares none of what they carry: `buildProgress`, `blueprintProgress`, `charts`, and `pendingActionId` / `draftReview` / `proposedPlan` / `proposedChanges` / `builderHandoff` on every tool invocation. Those keys are the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and the inline charts. They survived only because nothing on the path ever rebuilt a message; anyone writing the obvious thing — reconstruct a message field-by-field from its declared type — deleted all of them, with the compiler agreeing, because the declared type genuinely did not have them.
+
+ The declaration is now the truth, published as `ObjectChatMessage`. The survey behind it found the honest type to be neither of the two `ChatMessage` types on either side, because neither is true of both modes: it stays **wide** where local mode is wide (an authored `'tool'` role and the legacy `'partial-call'` / `'call'` / `'result'` tool states reach this surface unchanged and are folded only at the render seam), **narrow** where both modes are narrow (`timestamp` is `string`, never `Date` — API mode never produces one and local mode absorbs it before emitting), and adds the render-only keys API mode really carries. The `as OuiChatMessage[]` assertion is deleted rather than moved: the mapper's output satisfies the declared type, so the compiler checks that assignment instead of being told to stop looking.
+
+ Nothing about the values changed, and nothing correct breaks. `ObjectChatMessage` is a **subtype** of the authoring `ChatMessage` it replaces, so every consumer that accepted the old declaration still accepts these values — including a host `onSend` callback that types its parameter as `ChatMessage[]`, which keeps type-checking by contravariance. Naming `ObjectChatMessage` is what lets a host _read_ the keys above. The one observable narrowing is deliberate: code that branched on `timestamp instanceof Date` was handling a value this hook cannot emit, and now says so at compile time.
+
+ The seam below it (`chatMessageAdapter.ts`, from objectui#4399) is still necessary and unchanged in behaviour — `'tool'` and the legacy tool states still have to be narrowed for the renderers. What changed is that its pass-through is no longer an act of faith: its input type (`SeamChatMessage`, also exported, alongside `SeamToolInvocation`) names the render-only keys, so the spread preserves them as declared properties the compiler can see, and the pass-through tests type their API-mode fixture directly instead of casting it past the compiler. A cast returning to the hook is now caught by a test rather than by a future outage.
+
+ App-shell carries a comment-only correction on the same family: `AiChatPage` still described `@object-ui/plugin-chatbot` as exporting a second, minimal legacy `ChatMessage` alongside the enhanced one. That collision was retired in objectui#4383 — the barrel publishes one contract and `ChatbotEnhancedMessage` is a deprecated alias of it — so the paragraph was sending readers to look for a hazard that no longer exists.
+
+- d2f6e6b: Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
+
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.
+
+ Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.
+
+ The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.
+
+ No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
+
+- b954120: metadata-admin: diagnose a path on the right-hand side of `==` / `!=` in a visibility predicate
+
+ The predicate evaluator resolves paths only on the LEFT of `==` / `!=`. The right-hand side goes
+ through `parseLiteral`, which hands back anything it does not recognise as a literal verbatim — so
+ `data.a == data.b` compares the value of `data.a` against the seven-character string `"data.b"` and
+ is false however equal the two sides are, with nothing in the console. objectstack#6936's
+ unresolved-path warning cannot see this: it hangs on `resolveValue`, which the right side never
+ enters.
+
+ A dev-mode `console.warn` now fires when that tail returns something path-shaped (a dot-separated
+ identifier chain — the same grammar the left side accepts), naming the text, the predicate carrying
+ it, and the boundary. **No semantics change**: `data.a == data.b` still evaluates false, and the
+ before/after verdicts are pinned identical. The semantic fix belongs to publish-time predicate
+ validation (objectstack#7010) and to the real CEL runtime this file stands in for (ROADMAP M9), with
+ which this diagnostic retires.
+
+- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
+
+ Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
+
+ `ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
+
+ `ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
+
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [bb68488]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [564252c]
+- Updated dependencies [2459a3e]
+- Updated dependencies [2776b11]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [605b747]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [b42558a]
+- Updated dependencies [d2f6e6b]
+- Updated dependencies [85a3082]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/data-objectstack@17.5.0
+ - @object-ui/permissions@17.5.0
+ - @object-ui/collaboration@17.5.0
+ - @object-ui/layout@17.5.0
+ - @object-ui/auth@17.5.0
+ - @object-ui/plugin-editor@17.5.0
+ - @object-ui/providers@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json
index 5703cce123..5c81be4754 100644
--- a/packages/app-shell/package.json
+++ b/packages/app-shell/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/app-shell",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Minimal application shell for ObjectUI - framework-agnostic rendering engine",
diff --git a/packages/auth/CHANGELOG.md b/packages/auth/CHANGELOG.md
index f61225fa45..796fc66475 100644
--- a/packages/auth/CHANGELOG.md
+++ b/packages/auth/CHANGELOG.md
@@ -1,5 +1,29 @@
# @object-ui/auth
+## 17.5.0
+
+### Patch Changes
+
+- 564252c: `features.passkeys` and `features.magicLink` are documented as reserved, so enabling them no longer implies a login-page entry point that does not exist
+
+ `GET /api/v1/auth/config` advertises eight login-surface capability flags. Six of them gate something real — `sso` gates the "Sign in with SSO" button, `phoneNumberOtp` the verification-code mode, `deviceAuthorization` the device-approval page — and each carries a doc comment saying what it gates. Two did not gate anything: `passkeys` and `magicLink` existed only as two undocumented lines in `AuthPublicConfig.features`, consumed by no component, no route and no metadata. A deployer who turned one on got a flag that changed nothing, with nothing anywhere to say so. That is the mirror of the defect the audit was looking for (framework#2874 P2②): not UI advertising a capability the backend lacks, but a backend advertising a capability the UI lacks.
+
+ Both flags are now marked reserved at the two places a deployer meets them. The declarations in `packages/auth/src/types.ts` carry doc comments — which ship in the published `.d.ts`, so the warning appears on hover — stating that enabling the flag adds no entry point and naming the follow-up card. `packages/auth/README.md` gains a "Server Feature Flags" section documenting what the `features` map is for and a "Reserved flags" table saying the same thing in prose. Per the maintainer's ruling on objectui#2514, the UI is deliberately not built here; it is filed as objectui#4179 and left unscheduled.
+
+ No runtime behaviour changes: nothing read these flags before and nothing reads them now. What changes is that the published types and docs stop being silent about it.
+
+ The three artifacts are pinned together by `packages/auth/src/__tests__/reserved-auth-features.test.ts`, which asserts the declarations still exist, that each carries a reserved marking naming the card, that the README section says the same, and — the inverse direction — that no source file under `packages/*/src`, `apps/*/src` or `examples/*/src` references either identifier. The sweep covers `.json` as well as `.ts`/`.tsx` because `AppContent` hands the whole `features` object to `ExpressionProvider`, so authored metadata can reach these flags without any source file naming them. Two calibration cases keep the pin from rotting into a vacuous pass: one asserts the sweep reads a non-trivial number of files, the other that it can still find a flag that _is_ consumed. When the UI is eventually built, the pin fails and its docblock spells out the retirement checklist — the doc comments, the README section and the pin itself come out in the same PR.
+
+ `features.twoFactor` is untouched and explicitly excluded: two-factor is implemented in this package (`enableTwoFactor` / `verifyTwoFactor`) and its challenge is driven by server-side remediation rather than by the flag, so `LoginForm` not reading it is the design rather than a gap. The README says so, and the pin asserts `twoFactor` never appears in the reserved table.
+
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [bb68488]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 88fb5f29fe..f285af8753 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/auth",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Authentication system for Object UI with AuthProvider, useAuth hook, AuthGuard, and form components.",
diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md
index 29096f89f7..e6aba7e1d9 100644
--- a/packages/cli/CHANGELOG.md
+++ b/packages/cli/CHANGELOG.md
@@ -1,5 +1,57 @@
# @object-ui/cli
+## 17.5.0
+
+### Patch Changes
+
+- 64cda47: Fix `objectui init`'s scaffold failing its own `npm run build`, and put the third generator under the real `tsc` gate
+
+ The scaffold `objectui init` writes declares `"build": "tsc && vite build"`, so `tsc` runs on the way to a production build — and its `src/main.tsx` did `import './index.css'` with no ambient declaration behind it. Any user who followed the generated README (`objectui init`, then `npm run build`) got `TS2882: Cannot find module or type declarations for side-effect import of './index.css'` in a file the tool had just written for them, before Vite was ever reached.
+
+ Fixed the same way objectui#3853 fixed the two temp-app generators: the scaffold now writes `src/vite-env.d.ts` (`/// `), where the `declare module '*.css'` declarations live. `vite` was already in the scaffold's `devDependencies`, so nothing new is declared.
+
+ Measured rather than assumed: this scaffold had that one error and none of the other four classes objectui#3853 found in the temp apps — those live in a `src/Layout.tsx` this scaffold does not have. The `tsc` gate in `app-generator.test.ts` now covers the init scaffold too, so the strictness its own `tsconfig.json` declares is enforced instead of decorative.
+
+- 9b9fa49: Make the generated temp app pass the strict `tsconfig.json` the generator writes beside it, and gate it with a real `tsc`
+
+ Both app generators (`createTempApp`, `createTempAppWithRouting`) emit a `tsconfig.json` carrying `strict`, `noUnusedLocals` and `noUnusedParameters`, but nothing had ever run it — `dev`/`serve`/`build` go through Vite, which transpiles without type-checking — so the generated sources had drifted 17 errors past their own declared config. A user who copies the temp app out as a scaffold, or runs `tsc` in it, met all 17 at once.
+
+ Fixed at the templates: dropped five imports that were declared and never used (`Link` in `src/App.tsx`; `cn`, `Button`, `SidebarGroupContent`, `SidebarGroupLabel` in `src/Layout.tsx`), typed `DynamicIcon`'s and `AppLayout`'s props (which also types the `menu`/`children` map callbacks by inference, and makes `className` optional so the two call sites that omit it are legal), and added the `src/vite-env.d.ts` every Vite TS scaffold carries — without it the entry's `import './index.css'` has no declaration behind it, in both generators.
+
+ The Lucide lookup no longer needs `@ts-expect-error`: the namespace is narrowed to the component-by-name shape the layout actually uses. No `any` was added.
+
+ A real `tsc -p` over a generated app now runs in the package's tests, so the declared strictness is enforced rather than decorative.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e3f3da81d4..f2d03cad7c 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/cli",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Standalone CLI for Object UI — scaffold, develop, build and validate JSON/YAML schema-driven applications.",
"type": "module",
"homepage": "https://www.objectui.org/docs/utilities/cli",
diff --git a/packages/collaboration/CHANGELOG.md b/packages/collaboration/CHANGELOG.md
index 92844d5e2f..a67326440c 100644
--- a/packages/collaboration/CHANGELOG.md
+++ b/packages/collaboration/CHANGELOG.md
@@ -1,5 +1,53 @@
# @object-ui/collaboration
+## 17.5.0
+
+### Patch Changes
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [734d186]
+- Updated dependencies [f7c6430]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [78fa331]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json
index 5a43c3c6a7..c1b485ae0a 100644
--- a/packages/collaboration/package.json
+++ b/packages/collaboration/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/collaboration",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Real-time collaboration for Object UI with presence tracking, live cursors, conflict resolution, and comment threads.",
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index ffaaa7c47c..5cdd69da50 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -1,5 +1,423 @@
# @object-ui/components
+## 17.5.0
+
+### Minor Changes
+
+- dc2aa3e: The action renderers publish the modern `UIActionSchema`, and every `forwardRef` renderer's props parameter is annotated so its declared types survive
+
+ **Breaking semantics (declared `minor` per the repo's version-alignment rule — objectui#4403 precedent — never `major`).** Six exported declarations in `@object-ui/components` change the action type they name, from the `@deprecated` legacy `ActionSchema` (`crud.ts`) to `UIActionSchema` (`ui-action.ts`):
+
+ - `ActionBarSchema.actions`, `ActionBarSchema.systemActions`
+ - `ActionMenuSchema.actions`
+ - `ActionGroupSchema.actions`
+ - `ActionButtonProps.schema`, `ActionIconProps.schema`
+
+ The two types are not interchangeable in either direction. `UIActionSchema` requires `name`, which legacy inherits as optional from `BaseSchema`; legacy pins `type: 'action'` where these renderers serve `'script' | 'url' | 'modal' | 'flow' | 'api'`; and only the modern type declares `locations`, `target`, `endpoint`, `bodyExtra`, `bodyShape` and a `variant` union containing `'primary'` — all of which the implementations already read. objectui#4417 measured four compiler errors proving the VALUES were modern while the DECLARATIONS said legacy; this moves the declarations to match, so the contract and the implementation finally agree.
+
+ No runtime behaviour changes, and no published surface is involved: none of the six declarations is re-exported from the package index, and the sweep found zero type-checked consumers outside each declaration's own file. Metadata that renders today renders identically — the renderers read the same keys through the same paths.
+
+ Separately, all fifteen `schema`-reading `forwardRef` renderers in the package now annotate their render function's first parameter directly, and carry the pass-through index signature on that annotation rather than on the `forwardRef` type argument. `forwardRef` routes its type argument through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature — every declared property erased, silently, with `noImplicitAny` reporting clean because the `any` is supplied explicitly by the index signature. That is what hid the declaration/implementation drift above for as long as it lasted. Thirteen renderers recover a real declared type for `schema` (the two raw-tag factories keep `any`, which is what they genuinely declare), and a new structural guard, `forwardref-props-annotation.guard.test.ts`, fails on any future `forwardRef` that reintroduces either half of the trap.
+
+- cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
+
+ The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
+
+ Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
+
+ `@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
+
+ The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
+
+ No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
+
+ `toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
+
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
+
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
+
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
+
+ ## The dependency move
+
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
+
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
+
+- bb68488: `element:record_picker` publishes `sort`, `limit` and `emptyText` as authoring
+ inputs (objectui#4167).
+
+ All three were already READ by the renderer and declared by the contract — the
+ renderer has passed `sort` into `$orderby` and `limit` into `$top` since the
+ block existed, and `emptyText` decides the no-rows message — but none of them
+ appeared in `inputs`, so every layer that reads a manifest said they did not
+ exist. `packages/components/src/renderers/layout/page.tsx` builds the JSX-page
+ compiler's prop whitelist from `getKnownTypes()` plus these `inputs`, so writing
+ any of the three on a JSX page drew an `unknown-prop` warning from
+ `sdui-parser/src/validate.ts` on a key the renderer then went on to honour.
+
+ That is objectui#3407's shape — honoured, undiscoverable — and this is the same
+ repair objectui#3808 made for `record:details.hideFields` and objectui#3830 made
+ for `element:record_picker.filter`. `@objectstack/spec` 17.0.0-rc.6 is what made
+ it actionable: objectstack#5775 declared the three upstream, and the reverse
+ direction of the console's registry parity gate went red demanding them the
+ moment the pin moved — a red the previous exemption had predicted in writing and
+ called "correct and wanted".
+
+ Each description documents the renderer's real behaviour rather than restating
+ the schema, because that is the half an author cannot read off the contract:
+
+ - **`sort`** and **`limit`** are both overridden OUTRIGHT by a node-level
+ `dataSource` binding (`dataSource.sort ?? sort`), not merged with it — so a
+ node that carries a `dataSource` silently ignores them.
+ - **`limit`** defaults to 50 in the renderer, not in the schema, and a record
+ outside the limit cannot be picked at all with nothing in the control to say
+ more exist.
+ - **`emptyText`** is published as `string` against a contract of
+ `string | Record< string, string >`: rc.6 widened it to `I18nLabel`, and this
+ renderer passes the value straight into a text node with no locale resolution,
+ so only the plain-string form renders today. The description says so rather
+ than advertising a shape the renderer drops — the narrowed-type treatment
+ objectui#3832 describes, with the render-site gap tracked in objectui#4163.
+
+ The console's `registry-inputs-spec-parity` suite also drops all twelve of its
+ off-spec exemptions, which rc.6 obsoleted at once (objectstack#6776 declared
+ `page:header.recordChrome` / `showStar` / `showCopyId`, `page:accordion.variant`
+ and `page:tabs.tabStyle`; objectstack#5775 declared the `element:record_picker`
+ trio and `children` on the four page containers). The forward direction of that
+ gate now runs with no cover of any kind.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- d6e5124: An action rendered in the overflow menu, as an icon or inside a group now reaches the runner carrying the same authored keys as the same action rendered inline — `action:menu`, `action:icon` and `action:group` forward `label` and `description`, and the two group/icon surfaces also forward `resultDialog`.
+
+ Every action renderer hands the `ActionRunner` an explicit key WHITELIST rather than the action itself. That is deliberate — a key no renderer honours must not look wired — but the whitelists had drifted, and which renderer a given action gets is decided by `action:bar`'s `maxVisible` split (3 on desktop, 1 on mobile) and by `systemActions`, which are always in the overflow menu. So the same declared action behaved differently depending on the viewport.
+
+ `label` and `description` are what the console's param-collection handler titles its dialog from (`title: action?.label || action?.title`, `description: actionDescription(…, action?.description)`). Dropped, an action with declared `params` opened a dialog titled "Action parameters" while the SAME declaration rendered inline named itself "Create Environment". `resultDialog` is the one-shot reveal spec (a fresh 2FA code, a newly minted OAuth secret): dropped, the runner falls back to the success toast and the value the user was meant to copy is gone — the objectui#3646 defect, still live on two of the four declared surfaces.
+
+ `undoable` and `recordIdField` are deliberately NOT added. Both are read only under a `rowRecord` guard, and `rowRecord` is `params._rowRecord`, written exclusively by the spread-based hosts (`DeclaredActionsBar`, `RelatedRecordActionsBridge`, `ObjectGrid`, `page:header`), none of which dispatch through these renderers. They are unreachable on this path rather than dropped — `action:button` forwards them here inertly — so forwarding them would have added a second inert copy instead of restoring an affordance.
+
+ A new repo gate, `pnpm check:action-forward-parity`, now derives each surface's owed key set (`authorable ∩ runtime-read − retired`) from the spec's own schemas and the consumers' ASTs and fails when a renderer drops one, so the seventh instance of this class fails on the pull request that introduces it rather than shipping green.
+
+- debad27: An `autoTrigger` action that spills past `action:bar`'s `maxVisible` now still runs — `action:menu` consumes the flag instead of dropping it.
+
+ `autoTrigger` is the client-composed "run this action as soon as a renderer receives it" flag behind deep links like the welcome page's "Create your environment" CTA (#844). It was consumed only by `action:button`. `action:bar` splits its post-gate list at `maxVisible` (3 on desktop, 1 on mobile) and hands the tail to `action:menu`, which had no `autoTrigger` handling at all — so an auto-triggered action that happened to sort past that threshold was rendered as an ordinary "More" menu entry and never ran, while the caller had already spent the one-shot signal it stood for. The `?runAction=create_environment` deep link is consumed by stripping it from the URL, so the measured end state was `urlParam=null execute=0`: no dialog, and no URL left to retry from. Which actions lost their auto-trigger was partly a function of viewport width, since `maxVisible` drops to 1 on mobile, and `systemActions` — always in the overflow menu, whatever the viewport — could never fire one at all.
+
+ The flag's contract is now stated and enforced as "execute once on mount by whichever renderer receives the action". `action:menu` consumes it by EXECUTING, through the same path a click on that item takes; it does not open the dropdown, so a transport flag never moves what the user sees. Consumption happens where the action provably arrives — the menu renderer receiving it — not in the menu items, which Radix mounts only once the dropdown opens and which would therefore have waited on the very click the flag exists to avoid.
+
+ Once-ness has one implementation (`renderers/action/auto-trigger.ts`), now shared by both renderers rather than written twice: a guard ref per rendered action, so re-renders never re-fire it and a flag that flips true later still fires exactly once. Container visibility still governs mounting — a hidden `action:bar` or `action:menu` renders no children and auto-triggers nothing — while the action's own `visible` gate does not suppress the trigger, matching `action:button`'s long-standing behaviour so that a deep link cannot depend on where the bar happened to put the action.
+
+ The `action:bar` split, the inline `action:button` path and #4166's arming pins are unchanged.
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 4dadf0d: `@object-ui/components` compiles under `noImplicitAny` — the workspace's last strict-relaxing package
+
+ `packages/components/tsconfig.json` carried `"noImplicitAny": false`, the only place in the workspace that relaxed a `strict` sub-flag, under a comment that explained the neighbouring `rootDir` removal rather than the flag itself. `tsconfig.test.json` mirrored the one flag deliberately, so that a test project could not become the compiler of record for a source strictness decision the build config owns. Both now simply inherit `strict: true` from the root config, and the mirror's reasoning is rewritten to record why the mirror is gone rather than deleted silently.
+
+ Turning the flag on reported 26 implicitly-`any` sites in five renderer source files and 2 in the package's own tests, all of which now have real types. Nothing about the runtime changed; every one of the package's 1077 tests passes untouched.
+
+ Two of those signatures were typed by measurement rather than by preference, and both are worth recording:
+
+ The ten `sidebar.tsx` entry points follow the convention the package's other registered renderers already use — an inline `{ schema: Schema; [key: string]: any }` annotation naming the registered component's own schema type (21 occurrences across the renderer tree, against zero uses of `ComponentRendererProps`). Only `'sidebar'` itself has a schema type in the registry map; the other ten registrations are sidebar _parts_ with none of their own, so they take `BaseSchema`, the type every registered node satisfies. Annotating them `SidebarSchema` would have asserted `type: 'sidebar'` on a node whose type is `'sidebar-header'`.
+
+ The action renderers' callbacks are typed from `UIActionSchema`, not the legacy `ActionSchema` those three files import for their declarations. The legacy interface (`crud.ts`, already `@deprecated`) has no `locations`, so the shared `actionRendersAt` placement predicate rejects it outright; its `variant` union has no `'primary'`, the value the objectui#2339 ordering tie-break compares against; and its `type` is the literal `'action'`, while the actions actually flowing through these renderers carry `'form' | 'script' | 'url' | 'flow' | 'api' | 'modal'`. `action:bar`'s own documented example is a `UIActionSchema`. None of this was checkable before, because the props type never reached the callbacks at all: `forwardRef` routes props through `PropsWithoutRef`, whose `Omit` collapses a props type carrying `[key: string]: any` down to the bare index signature, so `schema` arrived as `any` and every callback under it inferred `any` too. The fix annotates each action list once where it enters and lets the `filter`/`some`/`map` chains below infer.
+
+ Graded `patch`: no declaration this package publishes changes shape. The three action schema interfaces and the leaf components whose props moved to `UIActionSchema` are internal — none is re-exported from `src/index.ts`. The `actions?: ActionSchema[]` keys those interfaces still declare remain on the legacy type; reconciling that declaration with the type the implementation actually receives reaches roughly 46 sites across 12 files and is filed separately.
+
+- 4b70d28: data-table row menu — the built-in Edit/Delete predicate parameters are derived from the authoring type, not hand-restated
+
+ One authoring shape (`DataTableSchema.rowEditPredicates` / `rowDeletePredicates`, objectui#2614) had grown four separate declarations in `renderers/complex/data-table.tsx`: the shared `isBuiltinRowActionVisible` gate and `planDataTableRowMenu` each hand-wrote `{ visibleWhen?: unknown }`, and the row-menu ITEM component hand-wrote the full `{ visibleWhen?: unknown; disabledWhen?: unknown }` pair. Nothing tied any of them to the type whose values they receive, so a rename in `@object-ui/types` would have left all four compiling against a shape that no longer existed — the objectui#3009 hand-copy family, in miniature.
+
+ Each one now derives. The planner keeps its deliberate visibility-only subset, `Pick`ed from the very key its caller passes (`Pick, 'visibleWhen'>` and the delete twin), so the signature still tells the truth about what the function reads while becoming structurally unable to drift from what it subsets. The two consumers that serve both built-ins share one derived alias taken from the union of the twins, so they may only read keys both schema keys declare. Measured: with `visibleWhen` renamed in `@object-ui/types`, the previous hand-written declarations still type-check clean, the derived ones fail to compile.
+
+ No behavior change — no runtime code was touched, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed.
+
+- b4d3c22: `element:button`'s action forward is excess-property checked again — a misspelled key on that payload is now a compile error, not silence
+
+ The payload `element:button` hands to `execute(…)` closed with `as any`. An assertion asks only for comparability, so it switched TypeScript's excess-property (freshness) check off for the whole literal: all sixteen keys the renderer forwards rode unchecked, and `ActionDef` being a closed type (objectui#4046) bought this surface nothing. A typo added to that list — `refreshAftr`, `confirmTxt` — would have compiled, published, and reached a runner that silently does nothing, which is the objectstack#2169 "Mark Done does nothing" shape the closed type exists to prevent.
+
+ The exemption that recorded this assumed the cast was load-bearing, on the reasoning that `element:button` receives a bare `action?: Record< string, any >` prop rather than a typed action, so removing the cast would be a contract change. Measured on TypeScript 6.0.3, that was wrong on the point that mattered: dropping the cast type-checks clean as-is, because every key the literal writes is already declared on `ActionDef`. The prop's type is a separate question and is deliberately untouched here — the forward literal never needed the cast to compile.
+
+ Two literals needed it, not one. The `execute(…)` argument is contextually typed by `execute(action: ActionDef)`, so dropping the cast is enough for the explicit keys. The `paramsPayload` binding spread into it is the second, easily-missed half: a spread source's own keys are not checked _through_ the spread, so `{ actionParams }` / `{ params }` could still invent a key while the payload around them was checked. That binding is now annotated `ActionDef` too.
+
+ Runtime behaviour is unchanged — the object reaching `execute` is identical, key order included, and the emitted `.d.ts` does not move. What changes is that the compiler now rejects an invented key here, verified in both directions: the same probe key produces `TS2353` after this change and no diagnostic at all before it.
+
+ With this surface fixed, `check:action-forward-parity` has no payloads exempt from its freshness rule: all five action-forwarding renderers write into a literal the compiler checks, and the gate's ratchet removed the exemption entry itself as designed.
+
+- bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
+
+ The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is _gated_ — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
+
+ Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
+
+ Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
+
+ Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- bfdf3d4: `element:record_picker.filter` is now discoverable from the published `inputs`
+
+ The fourth A-class gap of objectui#3808's own list, and the one its three-way
+ triage dropped: `filter` appears in that issue's raw key dump for this block and
+ then in none of its A / B / C lists, so the change that added the repo-wide
+ parity gate exempted it by name instead of declaring it. It is the same shape as
+ the four #3808 fixed — `@objectstack/spec` declares
+ `ElementRecordPickerProps.filter`, the renderer has read it all along
+ (`composed?.filter ?? props.filter`, straight into the picker query's `$filter`),
+ and the registry `inputs` never mentioned it.
+
+ `element:record_picker` is not in the public tier ("record picking is a field
+ widget, not a page block"), so the gap was not in `sdui.manifest.json` — it was
+ in the JSX-page compiler's prop whitelist, which `renderers/layout/page.tsx`
+ builds from `getKnownTypes()` plus these same `inputs`. A JSX page writing
+ `filter` therefore got an `unknown-prop` warning from `sdui-parser`'s prop walk
+ on the very key that decided which records the picker offered, and the designer
+ panel gave an author no way to discover the key existed at all.
+
+ The description is derived from what the renderer does, not from restating the
+ spec's one-liner, because the one thing an author cannot read off the spec is
+ which of the two places they may write a filter wins: a node-level `dataSource`
+ filter (itself AND-combined with any saved `view` it names) is taken and this
+ top-level `filter` is DROPPED, not merged — so this key applies only when the
+ node carries no `dataSource` filter.
+
+ `type` is `'object'`, taken from the spec's actual shape on the resolved pin
+ rather than the `'array'` the issue's landing sketch guessed:
+ `FilterConditionSchema` is `z.record(z.string(), z.unknown())` intersected with
+ the `$and` / `$or` / `$not` group, so a rule array is rejected. This is the one
+ key in the family where `ComponentInput`'s coarse typing costs nothing —
+ `sdui-parser`'s `checkType` accepts exactly the values the spec accepts here, so
+ unlike `element:text_input.defaultValue` there is no narrowing to disclose.
+
+ The parity gate's explicit exemption for this key is deleted in the same change
+ (its own `carries no stale unpublished-key exemption` assertion demands it), and
+ the key joins #3808's four in the by-name "declared, not merely not-failing" pin.
+
+- b1e42d0: Conditional required (`requiredWhen`) now decides at SUBMIT time too — the star and the validator can no longer disagree
+
+ A `requiredWhen` predicate that flipped to FALSE after the dialog mounted updated only half the form. The display layer re-evaluated correctly — the asterisk and `aria-required` both disappeared — while submit stayed refused with " is required" and no write was ever issued. The user saw an optional field and a form that would not save, with nothing on screen naming the field it was still waiting on (objectui#4161).
+
+ The cause is not a mount-time snapshot, which is what the symptom looks like. The renderer hands react-hook-form its per-field rules as a `` prop, and RHF _merges_ that object into the field descriptor it already holds — `_f: { ...previous._f, ...options }`. A rule key that stops being spelled is therefore never removed. Rules could be ADDED live (a predicate flipping TRUE after mount did start enforcing, correctly) but never withdrawn: the `validate.required` entry installed the first time the predicate evaluated TRUE outlived every later FALSE verdict. The validation layer was append-only, latched on the first TRUE the field ever produced.
+
+ The `validate.required` entry is now registered unconditionally and decides required-ness when it _runs_, reading the live verdict the renderer publishes on every render — the same single `resolveFieldRuleState` result that draws the asterisk, not a second evaluation of the predicate with its own copy of the record assembly. Both directions are pinned: a predicate flipping FALSE re-opens submit, a predicate flipping TRUE starts enforcing, and statically required fields are unaffected.
+
+- f5e1143: A collapsed sidebar now survives a reload — `SidebarProvider` reads the `sidebar_state` cookie it has always written
+
+ The cookie half of this feature only ever ran in one direction. `setOpen` wrote `sidebar_state` on every toggle with a 7-day max-age, and nothing ever read it back: `SidebarProvider` seeded its state from `defaultOpen` (default `true`), so a sidebar you collapsed came back expanded on the next load with the correct cookie sitting right there, unread. QA measured it at 255px and `data-state=expanded` at +2s, +4s and +8s after load, reproduced three times.
+
+ Upstream Shadcn closes this loop in a **server component** — it reads the cookie there and passes the value down as `defaultOpen`. A pure SPA like the console has no such step, which is why nothing downstream could paper over it: passing a cookie-derived `defaultOpen` from one shell would have fixed that shell and left every other consumer of the primitive broken. The read therefore happens client-side, in the provider, as a lazy `useState` initialiser rather than a mount effect — the state has to be right on the first render, since a post-mount correction would still flash an expanded sidebar at the user.
+
+ Precedence is now pinned, in this order: a controlled `open` prop, then the cookie, then `defaultOpen`, then `true`. The cookie overrides the _default_, never a controlled usage. With no cookie present the behaviour is exactly what it was before, which is what keeps explicit `defaultOpen={false}` call sites — the marketing demos in `apps/site` — rendering unchanged; those cases are controls in the new test file and are green on both sides of the change.
+
+ Only the two values the writer produces are honoured (`"true"` / `"false"`), matched on an exact cookie name; anything else, including an absent or malformed value, falls through to `defaultOpen` rather than inventing a preference the user never expressed. The reader is SSR-safe, which `apps/site` needs: those primitives are `"use client"`, and Next still renders them on the server for the initial HTML, where there is no `document`.
+
+ Because `packages/components/src/ui/**` is regenerated from the Shadcn registry, the primitive itself only gains two anchored one-liners. All of the parsing lives in `packages/components/src/lib/sidebar-cookie.ts`, which the sync never touches, and the two edits are declared in `scripts/shadcn-local-patches.mjs` so `pnpm shadcn:update` re-applies them instead of silently reverting the fix — the same mechanism already used for the translated `Sheet`/`Dialog` close labels.
+
+- 5bf09fd: `ActionParamDialog`'s `select` branch no longer renders a hardcoded English `Select...` placeholder. The fallback used when an action param declares no `placeholder` of its own now reads the existing `common.select` pack key, so it is translated in all ten locales and carries the typographic ellipsis (U+2026) that #3878 converged the packs on. Authored `placeholder` metadata keeps priority, and no locale pack changed — the key was reused from `LookupField`'s identical select-trigger use.
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/sdui-parser@17.5.0
+ - @object-ui/react-runtime@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/components/package.json b/packages/components/package.json
index 326ba474fa..fe098c0558 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/components",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Standard UI component library for Object UI, built with Shadcn UI + Tailwind CSS",
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 06f062ea57..409954fbe3 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,435 @@
# @object-ui/core
+## 17.5.0
+
+### Minor Changes
+
+- ee66e2e: Close `ActionDef` — delete the `[key: string]: any` index signature and converge `visible` / `disabled` on the spec's unified shape.
+
+ `ActionDef` accepted any key of any type, so a typo (`targt`) and a retired spec
+ key (`execute`) both type-checked and the runner then silently bound no handler
+ — the objectstack#2169 "Mark Done does nothing" shape. Step 1
+ (objectstack#4075) made that audible with a dev-mode warning; step 2 promoted
+ the 18 spec-owned keys to real fields. This is **step 3**, executing the
+ maintainer's 2026-08-06 ruling now that its upstream half shipped in
+ `@objectstack/spec` 17.0.0-rc.6 (objectstack#5970).
+
+ - **`visible` and `disabled` now have ONE shape, derived from the spec** —
+ `boolean | string(CEL) | { dialect, source }`. The ruling was "统一形状,spec
+ 采纳": boolean is the degenerate literal verdict, the string is CEL shorthand,
+ the envelope is the full form. `visible` loses its hand-written `| boolean`
+ (the spec adopted that arm, so restating it locally would be a second
+ contract), and `disabled` gains the envelope arm it never had — it was
+ `string | boolean`, which is why the envelope the spec emits could only be
+ read through a cast.
+ - **The index signature is gone.** `tsc` now rejects an unknown or retired key
+ at any site that authors an action literal in code.
+ - **Five keys the deletion surfaced, promoted to real fields.** `to`,
+ `external`, `newTab`, `replace` — the `navigation` alias's own spelling, ruled
+ legitimate by step 1 and listed in `NAVIGATION_ALIAS_KEYS` ever since, but
+ declared only as data; and `description`, which every action renderer forwards
+ (`check:action-forward-parity` requires it) and the param-collection dialog
+ reads for its subtitle (objectui#4192). These were the only two `TS2353`s the
+ deletion produced across the whole workspace.
+ - **`ActionContext` keeps its index signature**, deliberately. It is a runtime
+ data bag whose keys are genuinely open; `ActionDef` is a declared metadata
+ contract. That asymmetry is the point, and it is now pinned in both
+ directions.
+
+ **Breaking edge, deliberate — same class as step 2's, one step further.** An
+ `ActionDef` literal carrying a key this interface does not declare is now a
+ compile error where it previously compiled and did nothing at runtime. That
+ includes the retired `execute` (rename it to `target`; `os migrate meta --from
+16` rewrites it) and plain typos. Values that were only ever absorbed silently
+ are the ones that stop compiling, so the failure moves to where it can be fixed
+ rather than appearing as a button that does nothing.
+
+ **What did NOT retire with the index signature**, contrary to step 1's
+ expectation: the dev-mode `warnOnUnknownActionKeys` shim and `executeScript`'s
+ `execute` rename prescription both stay. `tsc` only ever sees actions authored
+ as TypeScript, while stored `sys_metadata` rows are rehydrated UNPARSED
+ (objectstack#3903) — which is the population `execute: 'markDone'` actually
+ lives in. The two mechanisms cover disjoint populations; retiring the runtime
+ half would have re-opened the gap it was written for.
+
+- e901131: `DatasetResultField` is now `@objectstack/spec`'s `AnalyticsResult.fields[]` element itself, not a hand-written restatement of it
+
+ `packages/core/src/utils/dataset-format.ts` declared its own six-key interface for the analytics result column, under a doc comment describing the server's contract. The key set happened to match the spec today, so nothing was broken — but it was the last surviving member of the derive-don't-restate family (#3613 / #3753 on the parameter side, #3752 on the adapter return side), and it was the member with no compile-time tripwire: three surfaces (`plugin-dashboard`'s `DatasetWidget`, `plugin-report`'s `DatasetReportRenderer`, app-shell's `DatasetPreview`) consume this name AS the real column type, so the next spec column key would simply never appear here and no build would complain. It is now `AnalyticsResult['fields'][number]`, so it cannot lag the contract again.
+
+ **Consumer-visible type tightening (the reason this is a minor, not a patch).** The restatement had relaxed `type` to optional; the contract requires it. Anything that assigned a column literal without `type` — or a bare `{ name, label?, format? }` — to `DatasetResultField` will now fail to compile, and the fix is to supply the `type` the server always sends. Nothing in this repo needed changing: every value of this type originates in `ObjectStackAdapter.queryDataset`, which already declares the spec element, and no consumer reads `.type` at all, so the widening had bought no caller anything while advertising a `string | undefined` the wire never produces. Marked `minor` per the repo's bump policy, which reserves `major` for following `@objectstack/spec` across a major.
+
+ The exported name is unchanged and the `PercentScale` re-export from this module is untouched, so existing import paths keep working. `packages/core/tsconfig.typetests.json` (chained off the package's `type-check`) compiles the new parity test, so the pins are checked by CI rather than merely written down — including a negative pin that goes red if the hand-written interface is ever restored, and the `ChartResultField` superset relationship the module's comment claims.
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
+
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
+
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
+
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
+
+ ## The dependency move
+
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
+
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
+
+- 2459a3e: Retire `ActionEngine`'s event-mapping API (objectui#3368). `ActionEngine.addMapping()`,
+ `ActionEngine.dispatch()`, the private `mappings` registry behind them, and the exported
+ `ActionMapping` interface are removed under enforce-or-remove: all four were public surface
+ of `@object-ui/core` with zero production callers. Nothing in the repo ever registered a
+ mapping, so `dispatch()` had no reachable caller either, and every call site was in the
+ engine's own test file.
+
+ Breaking for anyone who typed against or called the removed declarations, marked `minor`
+ per this repository's version-alignment convention (the major tracks `@objectstack`, never
+ an API-break count). Actions are still entered by name (`executeAction`), by location
+ (`getActionsForLocation`), by shortcut (`handleShortcut`) and in bulk (`executeBulk`) —
+ only the event-keyed entry point is gone, and no runtime behaviour changes because no
+ runtime path reached it.
+
+ The three ways the retired condition gate had drifted from the `visible` contract that
+ `getActionsForLocation` implements die with the path rather than being fixed on it: it
+ entered on a raw truthy check (`condition: false` dispatched anyway), typed `condition` as
+ `string` only (a `{ dialect: 'cel', source }` envelope could not reach the canonical
+ `@objectstack/formula` engine), and evaluated without `throwOnError` (a throwing predicate
+ failed OPEN, the opposite of `visible`'s fail-closed posture). Aligning the contract of an
+ API nobody calls would only have widened behaviour nobody uses.
+
+- fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
+
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
+ density vocabulary, and since objectui#4352 they answered differently for the
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
+ `comfortable`. One metadata-driven system, two answers for one input
+ (objectui#4440).
+
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
+ still fails the build here.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Two things change:
+
+ - **Published type.** `rowHeightToDensityMode` is exported from
+ `@object-ui/core`, and its return widens from `DensityMode` to
+ `DensityMode | undefined`. A host assigning the result straight into a
+ `DensityMode` now has to say what an off-spec row height should mean to it.
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
+ A sweep of this repo, the `objectstack` example apps and one downstream app
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
+ `Record< DensityMode, RowHeight >`).
+
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
+ is an own-property check now.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
+
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
+
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
+
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
+
+- 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
+
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
+
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions...` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
+
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
+
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
+
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
+
+ The label lookup read options as `baseObject.fields[]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
+
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
+
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
+
+- abb0f81: A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
+
+ `@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
+
+ Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
+
+ No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
+
+ The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
+- 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- d6aa172: Retire `params.newTab` on a url action — `openIn: 'new-tab'` is the sanctioned spelling
+
+ `ActionRunner`'s navigator read a legacy `params.newTab` escape hatch below `openIn` and above the external-URL heuristic. That read is removed, executing the objectstack#6828 maintainer ruling of 2026-08-10, whose contract half shipped in objectstack PR #7375: the url-side readings of an object-form `params` are retired, not renamed.
+
+ Nothing that ever validated can regress. `params` is declared as `z.array(ActionParamSchema)`, so an object-form `params` has always failed the props parse — the fallback could only fire on a stack the spec refuses. The removal also closes a collision hazard: a params dialog declaring a field named `newTab` had the user's own collected input silently steering navigation.
+
+ `openIn: 'self' | 'new-tab'`, the legacy `navigate.newTab` modifier on the `navigation` shape, and the external/relative default are all unchanged.
+
+- 9461dd3: Form actions no longer carry a record id across an object boundary (#4292).
+
+ `ActionRunner.executeForm` forwarded `/forms/:name?recordId=` unconditionally,
+ and that URL says nothing about which object the id belongs to — so the form route
+ resolved it against the FormView's own target object. When an action fired from a
+ record of a DIFFERENT object and ids collide across objects (per-table integer
+ keys), the form silently prefilled and, since the route learned to honour the param,
+ `PATCH`ed a same-id record of the wrong object.
+
+ - **Producer**: the id is forwarded only when the firing context record's object
+ (`context.objectName`) matches the target view's object; on a mismatch no id is
+ forwarded, preserving create semantics. When it IS forwarded, the object travels
+ with it as `?recordObject=`.
+ - **Consumer**: `/forms/:name` refuses — no record read, no write — when
+ `recordObject` disagrees with the FormView's object. A URL without the param
+ behaves exactly as before, so existing deep links are unaffected.
+
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [bb68488]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index 4f556704cb..ed3301a748 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/core",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/create-plugin/CHANGELOG.md b/packages/create-plugin/CHANGELOG.md
index 5be14aa2d8..edc5e15205 100644
--- a/packages/create-plugin/CHANGELOG.md
+++ b/packages/create-plugin/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/create-plugin
+## 17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json
index c6f588f62b..feed0db374 100644
--- a/packages/create-plugin/package.json
+++ b/packages/create-plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/create-plugin",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "CLI tool to scaffold ObjectUI plugins",
"type": "module",
"license": "MIT",
diff --git a/packages/data-objectstack/CHANGELOG.md b/packages/data-objectstack/CHANGELOG.md
index 36169c9515..aaddca266c 100644
--- a/packages/data-objectstack/CHANGELOG.md
+++ b/packages/data-objectstack/CHANGELOG.md
@@ -1,5 +1,184 @@
# @object-ui/data-objectstack
+## 17.5.0
+
+### Minor Changes
+
+- 2776b11: data-objectstack: retire the phantom `CloudOperations` surface — the class, its three `Cloud*` types, and the module that claimed to integrate a cloud namespace no client has ever shipped
+
+ `src/cloud.ts` exported a `CloudOperations` class with four methods, all
+ re-exported from the package entry, so this was published surface of
+ `@object-ui/data-objectstack`. Every method optional-chained into
+ `client.cloud?.…`, and no released `@objectstack/client` has ever exported a
+ `cloud` namespace. Re-measured at `17.0.0-rc.6` before deleting: the module's
+ export list is `ObjectStackClient`, `ScopedProjectClient`, `RealtimeAPI`,
+ `QueryBuilder`, `FilterBuilder`, `createQuery`, `createFilter`, and a
+ constructed client's `.cloud` is `undefined`. The nearest real namespaces on the
+ instance — `projects` (which owns `/api/v1/cloud/environments`) and `packages`
+ (which owns marketplace installs) — are not what these methods reached for.
+
+ So every call resolved `undefined` and fell through to a literal:
+
+ | method | what it returned, always |
+ | :-------------------- | :------------------------------------------------------------ |
+ | `deploy` | `{ deploymentId: 'deploy-' + Date.now(), status: 'pending' }` |
+ | `getDeploymentStatus` | `{ status: 'unknown' }` |
+ | `searchMarketplace` | `[]` |
+ | `installPlugin` | `{ success: false }` |
+
+ The maintainer's 2026-08-11 ruling removed it rather than repairing it, and named
+ the reason: `deploy()` did not degrade to an error, it **manufactured a
+ plausible success**. A caller got a well-formed `deploymentId` for an operation
+ that never left the process and then polled it forever against
+ `{ status: 'unknown' }`. That is the most dangerous shape for an AI consumer,
+ which builds downstream logic on the fake id instead of getting suspicious.
+ Under the startup-focus principle a declared capability with no producer, no
+ consumer and no business pull is retired, not stubbed.
+
+ **Breaking, in FROM → TO form.** `CloudOperations`, `CloudDeploymentConfig`,
+ `CloudHostingConfig` and `CloudMarketplaceEntry` are no longer exported from
+ `@object-ui/data-objectstack`. It is a `minor` under this repo's version policy
+ (objectui's own breaking changes never declare `major`). Nothing broke that was
+ working: the only in-repo construction site was a test, and every method's
+ observable behaviour was a fabricated constant.
+
+ **No compile-compat stub was left.** The ruling allows one — throwing loud
+ `NotImplemented` — only where a compile need is demonstrated. Measured across the
+ whole repository, the sole importers were the package's own `index.ts`,
+ `v3-compat.test.ts` (three cases asserting the fallback had the right _keys_,
+ which is how the emptiness stayed green) and objectui#3720's vocabulary pin. No
+ app, no other package, no doc. With no consumer to keep compiling, a stub would
+ be a second phantom surface guarding the first.
+
+ The false module header went with it — it read `Cloud namespace integration for
+@objectstack/spec v3.0.0 / Replaces the legacy Hub namespace`, against a resolved
+ spec of `17.0.0-rc.6` and schemas this package never consumed.
+
+ **objectui#3720's pin retires with its subject.** `cloud-environment-vocabulary.pin.test.ts`
+ pinned the doc comment on `CloudDeploymentConfig.environment` — the deliberate
+ three-member deploy-target vocabulary and the `staging`-is-not-a-discovery-member
+ trap. Every fact it held was a claim _about_ that comment, and its spec-side
+ assertions existed only to keep those claims honest; with the type deleted they
+ would pin `@objectstack/spec`'s enums on behalf of no local reader — the same
+ phantom shape this change closes. #3720's conclusion is unaffected and now moot:
+ it found no producer-side deploy-target type to converge onto because the
+ producer did not exist, and this change removes the consumer that was waiting for
+ it. Its pending empty changeset (`cloud-deploy-environment-vocabulary-3720.md`,
+ never released) is removed too, since it announced a deliberate vocabulary on a
+ type this same release deletes.
+
+ A negative pin (`src/cloud-surface-retired-4152.pin.test.ts`) replaces the
+ retired cases and fails if any of the four names returns — reading both the
+ runtime export list (which catches the class) and `index.ts`'s source text
+ (which is the only instrument that can catch a returning `export type`).
+
+### Patch Changes
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
+
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
+
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
+
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
+
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
+
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
+
+- 605b747: The second metadata client class surfaces the runtime authoring gate's advisories instead of discarding them
+
+ objectui#4133 (PR #4236) put the gate's advisory findings — the ones that ride a **200**, where the save succeeded and the row persisted — in front of Studio authors, but it covered only one of the two client classes that write through `PUT /api/v1/meta/:type/:name`. The wiring lifts at `useMetadataClient`, which is where every app-shell path takes its `MetadataClient` from. `ObjectStackClient.meta.saveItem` — the SDK client hanging off `ObjectStackAdapter` — is a different class reaching the same door, and every one of its callers awaited the call and discarded the response, so an `advisories[]` the server attached was parsed off the wire and dropped one layer further out.
+
+ Those callers all write in **active** mode, so this is not the draft case where the gate never runs: the gate does run for them, produces findings, and the author was told nothing. The list is `MetadataService` (five saves behind the Object Manager and Field Designer), `useNavigationSync`, plugin-designer's Create/EditAppPage, and the adapter's own `updateViewConfig` / view / `updateDashboard` paths.
+
+ `ObjectStackAdapter` now carries an `onSaveAdvisory(listener)` subscription and emits on it after a metadata save whose 200 carried a non-empty `advisories[]`; `AdapterProvider` subscribes once and renders through the same `emitSaveAdvisories` the other client class already uses, so both doors produce one wording on the warning tier that says "Saved" first. The emitter is installed **once at the adapter/client seam** rather than at the call sites: every caller above reaches the save door through the adapter's own long-lived `ObjectStackClient`, so one interception covers all of them, plus any future one, without a toast copied into a dozen places — the same reasoning that put #4133's sink at one factory instead of twenty call sites.
+
+ It is a sibling of the `onWriteWarning` channel (#3431/#3455) rather than a second payload pushed down it, which is what `MetadataSaveAdvisoryEvent` already said it was modelled on. `WriteWarningEvent` is a closed shape whose required `droppedFields` means "fields the write legally stripped", so carrying advisories on it would either force every existing subscriber to grow a branch or make the event lie about what happened. The seam's shape is reused; its event type is not. `readSaveAdvisories` is shared unchanged between the two clients — one reader, two call sites — which the response envelopes make possible: the spec puts `advisories` at the save body's top level, and the SDK returns that body verbatim (it strips its `{ success, data }` envelope only when a `data` key is present, and this body has none). That measurement is pinned by tests that drive a real SDK client through a fake `fetch` rather than stubbing the method under test.
+
+- b42558a: Renaming a freshly-created view now persists — `updateView` reads and writes the same row, instead of reading the published overlay and losing the edit into a rejected partial write
+
+ ADR-0034 stages every runtime-created view as a per-item **draft**: a view made from the `+` tab lives only in the draft row until an explicit Publish, and the UI reads it back through `?preview=draft`. `updateView` addressed neither half of that. Its read went to the published overlay (`client.meta.getItem`, no draft qualifier), which 404s for a draft-only view; a `catch {}` labelled "treat missing as create-equivalent" then substituted `current = {}`, so the read-merge-write cycle merged onto nothing. What went out was the fragment that merge produces — literally `{label, name, object}`, no `viewKind`, no `config` — which the server rejects as an invalid ViewItem (422). Nothing surfaced to the user, and the draft row still held the old label, so the rename simply did not happen. Create, pin and delete were unaffected: they never take this path.
+
+ The read now probes the draft row first and, on a hit, merges onto that body and writes it straight back with `mode: 'draft'`. Whichever row the read resolved is the row the write updates, so the two halves agree by construction rather than by coincidence. Probing the draft **before** the published overlay is what makes it correct for a view that has both: writing the published row while a draft is pending would put the edit somewhere the draft shadows, and Publish would later overwrite it with the pre-edit body — losing the change a second time, further from the cause. A draft edit stays a draft, preserving ADR-0037's guarantee that nothing the preview shows goes live until Publish. Renaming a published view with no draft pending is unchanged, published read to published write.
+
+ The silent catch is gone. A view that resolves in neither home now throws naming the view and the object (creating one is `createView`'s job — no caller of `updateView` relied on the create-equivalent behaviour), and a network, permission or server fault on either read propagates instead of degrading into the partial write that corrupted the row. This turns a class of failure that was previously invisible into an error the existing call sites already catch and surface.
+
+ Set-default and reorder drive the same read-merge-write cycle with `{isDefault}` / `{sortOrder}` patches, so they were emitting the same partial write and are fixed by the same change.
+
+- d2f6e6b: Publishing a view from the console no longer serves a five-minute-stale override map — every writer now routes through one invalidation seam
+
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{name}` and `listViewOverrides` under `view-overrides:{object}`, with `MetadataCache`'s default 5-minute TTL. objectui#4363 made the adapter's own four write paths drop both. But the console's real create-a-view flow never calls any of them: `ObjectView.handleViewCreate` writes through the ADR-0034 metadata seam (`createRuntimeMetadata` → `metadataClient.save`), and Publish goes `RuntimeDraftBar` → `publishRuntimeMetadata` → `metadataClient.publish`. Two writers into the same `/meta/view/:name` rows; only one of them invalidated anything.
+
+ Publish is the sharp end. A create lands an invisible per-item draft, and `listViewOverrides` enumerates published rows, so the map is still honest there. Publish promotes the row into exactly the world the map describes — and nothing dropped the key, so the object page kept applying its pre-publish snapshot for the rest of the TTL. It does not self-heal: `loadViewOverrides` treats a resolved map as authoritative and deliberately does not re-probe per view (objectui#3774, correct — re-probing reinstates the 404 flurry the batch read exists to remove), so the per-view `getView` fallback that would have masked a stale map is by design unreachable.
+
+ The fix is one seam rather than a fifth copy of the key list. `ObjectStackAdapter.invalidateViewKeys(objectName, viewName)` is now the only place that knows which keys a view-row write drops; the adapter's four write paths call it instead of restating the pair, app-shell's ADR-0034 persistence module calls it for `view` saves, creates, publishes and discards, and `MetadataService.saveMetadataItem` calls it when the category is `view` (where it previously named `view:{name}`, which no reader has). Restatement is what this repo keeps paying for — objectui#3778 removed five copies of a key no reader populated, objectui#4363 fixed four copies that named half the live set, and objectui#4373 is the measured proof that a new writer forgets the list by default. A pin suite can only guard writers that exist; a seam makes the next one unable to forget.
+
+ No cache key, no read path and no public signature changed. The adapter's eight existing invalidation pins pass unchanged, which is the evidence that routing four paths through a seam changed nothing observable; two new structural guards keep the key set from being restated again — one asserting each key template appears exactly twice in the adapter (its reader, and the seam), one asserting no app-shell file spells either.
+
+- 85a3082: Every view write path now invalidates the override map — a created, renamed or deleted view is no longer shadowed by a five-minute-stale batch read
+
+ `ObjectStackAdapter` caches two view-shaped reads: `getView` under `view:{object}:{viewId}`, and `listViewOverrides` under `view-overrides:{object}`. Four write paths touch view rows, and until now exactly one of them — `updateViewConfig` — invalidated the second key. `createView`, `updateView` and `deleteView` invalidated only the per-view key, so the batch override map kept answering from a snapshot taken up to `MetadataCache`'s default 5-minute TTL earlier.
+
+ That gap does not heal itself. `loadViewOverrides` in app-shell's `ObjectView` treats a resolved map as authoritative and deliberately does not re-probe per view — that is objectui#3774's fix, and it is correct, since re-probing reinstates the 404 flurry the batch read exists to remove. So the per-view `getView` fallback that would have masked a stale map is by design unreachable, and the stale map is served in full. Meanwhile `listViews` is uncached and answers fresh, so the view switcher could list a view whose override body came from a map written minutes earlier: the sharpest shape is the rename/pin path (`updateView`), where a user edits a view, returns to the object, and is served the pre-edit override.
+
+ All four paths now emit the same ordered pair — the per-view key, then the object's override map. The rule is uniform per method rather than per branch: `updateView`'s draft half invalidates both keys as its published half does, which is deliberate over-invalidation (both readers enumerate published rows, so a draft write stales neither) chosen because an unnecessary invalidation costs one refetch while a missed one costs the full TTL. `createView` names the per-view key too, because `saveItem` is an upsert and an explicit `spec.name` that already exists overwrites a published row a prior `getView` may hold.
+
+ No signature, no cache key and no read path changed; the only difference is which keys each write drops. The pin suite added by objectui#4328 now asserts the full invalidation key set for all five call sites, with the sweep's two pins kept as untouched controls: `listViews` stays uncached, and no write path names the retired `views:{object}` key.
+
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/data-objectstack/package.json b/packages/data-objectstack/package.json
index 0715bcb71a..69a5036b56 100644
--- a/packages/data-objectstack/package.json
+++ b/packages/data-objectstack/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/data-objectstack",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "ObjectStack Data Adapter for Object UI",
"license": "MIT",
"type": "module",
diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md
index cc718314e7..74c7f7bb54 100644
--- a/packages/fields/CHANGELOG.md
+++ b/packages/fields/CHANGELOG.md
@@ -1,5 +1,394 @@
# @object-ui/fields
+## 17.5.0
+
+### Minor Changes
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- e2e6360: A `Field.address` value now reads as a formatted postal address on the record detail page, instead of stringified JSON.
+
+ The display (read) registry mapped `address` straight to `JsonCellRenderer`, so a populated address rendered as `{"street":"中策路 1 号","city":"杭州",…}` — while a `location` field sitting next to it in the same field group rendered formatted, and the create/edit dialog rendered the very same value as proper Street / City / State / ZIP / Country inputs. The gap was display-side only: the input registry has always carried `address`. Both read surfaces the detail page exposes are affected and both are fixed, because they share one `displayValue` path — read mode, and the inline-edit read state (the row carrying the pencil affordance, before a field is actually being edited).
+
+ Layout is not invented for the read side. `AddressField`'s readonly branch already collapsed a stored address to a single line, and that rule — `Street, City, State ZIP, Country`, with `state` and the postal code sharing one comma group — is now the _only_ implementation, moved into a pure `address-format` module that both surfaces call. A readonly form and a detail page therefore cannot spell one stored address two ways; a second copy next to the renderer would have been a rule that drifts. The module is deliberately React-free, so the eagerly-loaded barrel can format a cell without pulling `AddressField` and its inputs out of the lazy widget chunk.
+
+ Partial values degrade the way the readonly line already did: absent, non-string and whitespace-only parts are dropped rather than spaced over, so a street-only address renders as `中策路 1 号` and never as `, , ,` or as the string `undefined`. Legacy records whose postal code was written under `zipCode` (objectstack#5143) still render it, matching what the input widget reads.
+
+ Nothing is silently swallowed by the change: a value the formatter cannot recognize — an object carrying no known part — keeps today's compact-JSON rendering rather than disappearing, and `{}` or a null value shows the usual empty placeholder. A plain string address passes straight through. `location`, `geolocation`, and the genuinely structural `json` / `object` types are untouched.
+
+ The address _input_ is unchanged on every surface, including the create/edit dialog.
+
+- 0f21348: Currency amounts now follow each currency's own ISO 4217 fraction-digit
+ convention instead of a hardcoded 2 (objectui#4361).
+
+ Both currency formatting paths in `@object-ui/fields` picked a fraction-digit
+ width and handed it to `Intl.NumberFormat`, which OVERRIDES the digit count
+ `Intl` already knows for the currency being rendered. `formatCurrency` derived
+ its width from the VALUE's wholeness alone (`isWhole ? 0 : 2` — a literal 2 for
+ every currency on earth), and `CurrencyField` defaulted an undeclared
+ `precision` to the same literal. So a yen amount was printed with cents the
+ currency does not have and a dinar amount with one digit fewer than it does:
+
+ | | before | after |
+ | ------------ | -------------- | ----------- |
+ | JPY `1234.5` | `¥1,234.50` | `¥1,235` |
+ | KWD `1.5` | `KWD 1.50` | `KWD 1.500` |
+ | CLP `1234.5` | `CLP 1,234.50` | `CLP 1,235` |
+ | BHD `2.5` | `BHD 2.50` | `BHD 2.500` |
+ | USD `1234.5` | `$1,234.50` | `$1,234.50` |
+ | USD `1234` | `$1,234` | `$1,234` |
+
+ Both call sites now derive the width from the currency itself
+ (`Intl.NumberFormat(undefined, { style: 'currency', currency })
+.resolvedOptions().maximumFractionDigits`, memoized per code) and switch
+ wholeness against THAT.
+
+ **The whole-number convention is extended, not retired.** Simply dropping both
+ bounds and letting `Intl` decide would have fixed the digit count while turning
+ `$1,234` back into `$1,234.00` — the Salesforce convention `formatCurrency`
+ documents and objectui#4033 pinned. A whole amount still drops the fraction, now
+ for every currency: `KWD 1` renders `KWD 1`, not `KWD 1.000`. Two-decimal
+ currencies are byte-identical to before, which is why the objectui#4033 and
+ objectui#4332 pins pass unchanged.
+
+ **On `CurrencyField`, an explicitly authored `precision` still wins** — it is
+ authored metadata and authored metadata keeps priority, so a JPY field declaring
+ `precision: 2` still renders `¥1,234.50`. Only an ABSENT `precision` derives from
+ the currency; because that derivation is the widget's one precision, it also
+ reaches the spinner `step` and the blur rounding, so a JPY field no longer offers
+ a `0.01` step for a currency with no minor unit. Whether a declared `precision`
+ that contradicts the currency's ISO 4217 digits should be REJECTED at publish
+ time is a contract question, filed upstream in `@objectstack/spec` rather than
+ answered here by overriding the author.
+
+ Reachable wherever the resolved currency is not a 2-decimal one — the field's
+ `currency`, `currencyConfig.defaultCurrency`, or the tenant default (ADR-0053).
+
+- d2e2caf: fix(fields): `formatCurrency` keeps both cents digits on a fractional amount
+
+ The symbol branch passed `minimumFractionDigits: 0` against a
+ wholeness-switched `maximumFractionDigits`, which handed `Intl` the range
+ `[0, 2]` — and `Intl` emits the shortest representation in range, so a real
+ cents value of `.50` was printed as `.5`. Any price ending in a zero cent digit
+ rendered one digit short: `$1,234.50` as `$1,234.5`, `$19.90` as `$19.9`,
+ `$0.50` as `$0.5` — money on a record page and in grid cells reading as a data
+ error rather than a formatting one.
+
+ Both bounds now take the same wholeness-switched width, so the function
+ delivers the contract its own doc comment states: a fractional amount shows
+ exactly two digits, a whole amount still drops `.00` (`$1,234`). The
+ no-currency branch and the bad-currency fallback already behaved this way; only
+ the symbol branch disagreed.
+
+ Reaches every consumer of the shared helper: `CurrencyCellRenderer`,
+ `ObjectGrid`, the dashboard `recordFields` and `ObjectGantt`.
+
+- 3a9021e: fields: the currency adornment has one symbol channel
+
+ `CurrencyField` carried the same one-entry fact twice — a dead `CURRENCY_SYMBOLS`
+ map that nothing read, and a live `currency === 'USD' ? '$' : currency` ternary
+ two lines below it. Both were hand copies of knowledge `Intl` already carries,
+ and both are gone: a new `currencySymbol(currency, locale)` beside
+ `currencyFractionDigits()` reads the `currency` part of the very format the
+ widget's readonly branch already renders amounts with.
+
+ USD is unchanged at the display-locale default. Other currencies now show their
+ real symbol instead of the bare ISO code — `€` for EUR, `¥` for JPY, `£` for
+ GBP — which is what the same widget's readonly mode has always displayed; the
+ edit adornment simply stopped disagreeing with it. Currencies CLDR has no symbol
+ for (KWD, BHD, CHF, ISK, CLP) still render their code, exactly as before.
+
+- cb13400: One fullscreen long-text editor, hoisted to the package both render paths may import
+
+ The "expand to a full-height dialog" interaction had two independent implementations. `FullscreenTextarea` lived inside the form renderer's built-in (unregistered) `textarea` branch in `@object-ui/components`; `FullscreenFieldEditor` lived in `@object-ui/fields` and served the registered `TextAreaField` / `RichTextField` widgets. They exist because ONE form-level promise — `ObjectFormSchema.mobile.fullscreenLongText`, projected onto every long-text field as `mobile_fullscreen` — is honoured on two render paths, and each path grew its own answer.
+
+ Two copies of a state machine drift, and these did, in both directions: objectui#3400 measured a read-only long-text field that was fully editable through the built-in branch's dialog (and "Done" wrote the edit into form state), objectui#3402 measured the same write-back hole for `disabled` on the registered path, and objectui#3393 (the dialog title needs the field label) and objectui#3272 (the copy needs i18n) each landed on one side before the other. Every repair was correct and none of them scaled.
+
+ `@object-ui/components` now exports `FullscreenEditor`, a single primitive owning the affordance, the dialog, the draft/commit state machine and the copy. The direction follows the measured import graph rather than fighting it: `@object-ui/fields` depends on `@object-ui/components`, and `components` declares no dependency on `fields` in either `dependencies` or `peerDependencies`, so the shared code can only live in `components`. `FullscreenFieldEditor` becomes a thin wrapper over it and keeps its name, its props and its test-id namespaces, so both hosts and their pins are unchanged.
+
+ The load-bearing part of the merge is that the primitive DEFINES `readOnly` and `disabled` instead of inheriting them by accident. Neither copy defined both: the built-in one grew them under objectui#3400, while the fields one declared only `disabled` and was shielded from `readonly` by its hosts' early return — a single implementation cannot be shielded by one caller's control flow. So both are answered once, and both call paths inherit the same answers: `readOnly` renders no affordance at all (it means "shown plainly", so advertising an expand button the user cannot use is worse than showing none), `disabled` leaves an inert one (it means "not interactive, muted"). Neither relies on the toggle alone, because `disabled` also carries the form's `isSubmitting` and can flip to true while the dialog is already open — so opening refuses independently of the attribute, the injected editor is told, "Done" is disabled, and `onCommit` is gated as the single point where a value leaves for host state.
+
+ No copy changed and no locale pack needed an edit: the primitive consumes the same `form.fullscreen.*` / `common.cancel` keys both copies already read, through `createSafeTranslation` with English defaults byte-identical to the literals, so provider-less hosts render exactly what they did. The now-unread `form.fullscreen.*` defaults are dropped from `useFieldTranslation`, where they would have re-created in the defaults map precisely the duplication this change removes from the components.
+
+ `toggleClassName` is not carried into the new primitive. It was declared on `FullscreenFieldEditorProps` and written by nobody — zero producers repo-wide — and `FullscreenFieldEditor` is not exported from the `@object-ui/fields` barrel, so no consumer outside the package could ever have set it. Minting it as part of a NEW public export in `@object-ui/components` would have published a prop with no producer, the shape objectui#3232/#3233 keeps deleting.
+
+- bc64bfe: A dependency-gated option list no longer deletes the field's stored value on mount
+
+ The four fixed-option widgets (`SelectField`, `MultiSelectField`, `CheckboxesField`, `RadioField`) end their cascade resolution with a "drop what is no longer offered" effect, and the form renderer runs an equivalent clear of its own over every option field. Both read `resolveCascadingOptions`, which returns an **empty** offered set whenever the list is _gated_ — a declared `dependsOn` parent is still empty. Nothing the field held could be "still offered" against an empty set, so both paths wrote the field empty **on mount, with no interaction**, while the control rendered "Select Country first" beside it: it told the user it could not offer anything, and deleted what they had.
+
+ Gated means **unknown**, not invalid. The cascade clear exists (ADR-0058) so a user-driven parent change prunes a now-invalid child; a withheld list on mount is missing information — the record simply arrived with its controlling field empty (a later-cleared parent, an import, a partially-migrated row) — and that is not a reason to destroy stored data. Both clears now skip while gated, reading the resolver's own `gated` flag rather than re-deriving it from an empty offered set, which would collide with the distinct never-configured case guarded separately in objectui#4220.
+
+ Convergence stays exactly where it belongs: once the parent **is** chosen and the resolved set genuinely excludes the stored value, the prune applies unchanged — including at the moment the gate lifts, so picking a parent whose list does not contain the old value still clears it on that transition. The three states are pinned apart (never-configured / gated / resolved-and-excludes) across all four widgets and the form host, so a future edit cannot collapse them back into one empty-set test.
+
+ Reachable on every host that mounts these widgets with a live record: the form renderer, the grid's inline cell editor, and the detail page's inline editor — where each `onChange` went straight into the record draft the save bar commits.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- 433ff9f: An image field's declared `maxSize` is enforced before the upload starts, not after it finishes
+
+ `ImageField` received a `maxSize` and ignored it. `paramToField` copies `maxSize` onto the field config for every action param regardless of type, so an image param declared with a 5 MB limit handed the widget its constraint and the widget uploaded anyway: a 6.3 MB PNG fired the full `presigned → PUT → complete` chain and rendered a thumbnail, with no rejection anywhere. The sibling file param, declared the same way, refused the identical pick without a single request. Reported from a QA run driving the two side by side (objectui#4141).
+
+ Both of the widget's upload doors now check the limit first. The native picker rejects oversize picks before any request, keeping FileField's partial-acceptance rule — the in-limit members of a multi-select still upload, and only the oversize ones are reported. The crop dialog is the second door and needed the check in its own right: the cropper re-encodes to PNG, so cropping an in-limit JPEG can produce a blob _over_ the limit, and it is the crop's size that is uploaded. A rejected pick or crop now surfaces the same message the file widget has always shown, in a new error row — this widget had no surface for rejections before, because it never rejected anything.
+
+ The guard itself moved into one shared `maxSizeError` helper that both widgets call, rather than a second copy living in the image widget. The check is the only thing between a declared limit and a real upload, and a per-widget copy is what let these two drift apart unnoticed in the first place. Both widgets also share the existing `fields.file.exceedsMaxSize` message: it names a file and a limit, says nothing file-specific, and is already translated in all built-in locales, so no new key was added and no translation is pending. FileField's own behavior is unchanged — same threshold, same message, same partial acceptance.
+
+ An undeclared `maxSize` still means unrestricted; the falsy check is preserved deliberately, so a missing limit can never be read as a zero-byte one.
+
+- e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
+
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
+ and written back as whatever the user typed — a bare string.
+
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
+ `checkboxes`, an options-less multi picklist) were offered for editing as
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
+ value its option list never offered.
+
+ Types the switch already routes keep their editors. Everything else that the
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
+ control the form renders, `json` → the code editor included — and only genuinely
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
+ input. A drift guard asserts every field type is exactly one of routed /
+ excluded / delegated / benign, so a new type can no longer inherit the
+ value-destroying default in silence.
+
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
+ value when the field declares no `options` at all. An empty offered set had two
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
+ authored (nothing to decide) — and the second deleted the value on mount, which
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
+ also forwards `autoFocus` to the widget it renders.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
+
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
+ built its dialog placeholder by concatenating the key with three ASCII full
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
+ readers repo-wide while it still existed in all ten packs.
+
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
+ `common` namespace takes a two-member union parameter (`'openChat' |
+'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
+
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
+ module-local `Record` read only when no `LocalizationProvider`
+ is mounted; it is not exported, so removing an entry no reader asks for changes
+ no rendered output and narrows no public type. Hence patch for that package,
+ while the pack change is a minor: deleting a key from `en` narrows the exported
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
+ `common.search` stops type-checking. Same grading, for the same reason, as
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
+ break.
+
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
+ reader inherits an unreviewed contract, and a dormant key beside a live
+ `table.search` is where a second dialect gets started. The objectui#4328
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
+
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
+ PR #4397) is a different key and is untouched.
+
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
+ fails if the key returns to any pack, if any package reads or re-declares it, or
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
+ runs call site to key, and none of them can see a key with no call site.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/providers@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/fields/package.json b/packages/fields/package.json
index a40cafe3f6..e2e31bd42e 100644
--- a/packages/fields/package.json
+++ b/packages/fields/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/fields",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Field renderers and registry for Object UI",
"license": "MIT",
"type": "module",
diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md
index 8b94e715ae..5d0fa4e20a 100644
--- a/packages/i18n/CHANGELOG.md
+++ b/packages/i18n/CHANGELOG.md
@@ -1,5 +1,318 @@
# @object-ui/i18n
+## 17.5.0
+
+### Minor Changes
+
+- 0e67b53: `/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation
+
+ Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted.
+
+ Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one.
+
+ One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a ` ` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test.
+
+ Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched.
+
+- 66fb4fa: The console's language menu now asks the app which locales it actually ships, instead of always offering the same ten.
+
+ `LocaleSwitcher` built its items from a module-level `LANGUAGES` constant — exactly the ten codes `@object-ui/i18n` ships packs for — and never consulted the app, even though `GET /api/v1/i18n/locales` has been serving that list all along. It failed in both directions at once. An app shipping a locale outside those ten (`th`, a regional `pt-BR`) had no way to be selected from the console: the bundle could be complete and lint clean, and the menu simply had no entry for it. An app shipping only `en` and `zh` still listed all ten, so picking 日本語 handed the user the console's own chrome in Japanese with everything app-authored in the fallback language — a half-translated UI its author never opted into.
+
+ The menu is now the **intersection**: the app's own locale list ∩ what the renderer can actually resolve (built-in packs, `config.resources`, and — for an app that wires a dynamic `loadLanguage` loader, which is how the console gets its packs — the locales that loader can fetch). Both failure directions close in the same change: an app-shipped locale becomes offerable, and locales the app does not ship disappear. The endpoint is reached through a new `loadLocales` prop on `I18nProvider`, wired exactly like the existing `loadLanguage`: the app owns the transport, the provider owns what is done with the answer. An app that does not wire it keeps today's menu unchanged.
+
+ **The restore validation widened in lockstep, because otherwise this fix would have minted the next bug.** A restored language was validated against "the locales this provider can produce" — built-in packs plus `config.resources` — a bound that was correct only for as long as the menu offered exactly the built-in ten. The moment the menu grows to the app's real list, a locale the user can now pick is a locale that bound rejects, so a user-picked app locale would have been purged on the next page load. It now also accepts a locale a wired dynamic loader may be able to fetch, and the bound stays honest rather than absent: only well-formed BCP-47 tags qualify (`constructor`, `__proto__`, `en_US` are still rejected, as is any stored locale in an app with no loader), and the app's own locale list adjudicates the choice for real once it arrives — a locale the app has since dropped is reverted and purged rather than left locking the UI to a language with no translations.
+
+ Labels come from the built-in native names where they exist (`中文`, `日本語`, … are unchanged) and from `Intl.DisplayNames` for everything else, so an app locale is named in its own language rather than by its code. The endpoint's own `label` is deliberately not used for display: the server sets it to the code echoed back, which would have put `th` in the menu where `ไทย` belongs.
+
+ While the app's list is in flight the switcher renders nothing, following the sibling menus in the same folder — the ten never flash past on an app that only ships two. When there is no backend, the endpoint fails, or the app answers with nothing this renderer can produce, the built-in ten remain as the offline fallback, so the menu is never empty and never unusable.
+
+- ac853ce: i18n: retire the reader-less `common.search` key from all ten locale packs
+
+ `common.search` (`Search`, no ellipsis) had exactly one consumer: `LookupField`
+ built its dialog placeholder by concatenating the key with three ASCII full
+ stops. objectui#4375 / PR #4391 retired that concatenation — the placeholder is
+ the reused `table.search` pack value (`Search…`, one U+2026 glyph), which is what
+ brought it under objectui#3878's glyph pin. That left `common.search` with zero
+ readers repo-wide while it still existed in all ten packs.
+
+ Re-verified before deleting, repo-wide: no `t()` call site in any package or app,
+ no MDX or JSON reference, and the one dynamic template-literal reader of the
+ `common` namespace takes a two-member union parameter (`'openChat' |
+'closeChat'`) that cannot resolve to it. No user-visible string changes — this key never rendered.
+
+ The dormant copy in `@object-ui/fields`' no-provider fallback table
+ (`useFieldTranslation.ts`'s `FIELD_DEFAULTS`) goes with it. That table is a
+ module-local `Record` read only when no `LocalizationProvider`
+ is mounted; it is not exported, so removing an entry no reader asks for changes
+ no rendered output and narrows no public type. Hence patch for that package,
+ while the pack change is a minor: deleting a key from `en` narrows the exported
+ `TranslationKeys` type (`typeof en`), so code indexing `TranslationKeys` at
+ `common.search` stops type-checking. Same grading, for the same reason, as
+ objectui#4145's `report.editor.*` retirement. No runtime consumer existed to
+ break.
+
+ Retiring a key from `common` was the ruled decision on objectui#4392 rather than
+ keeping it as vocabulary: nothing pins a dormant key's meaning, so its next
+ reader inherits an unreviewed contract, and a dormant key beside a live
+ `table.search` is where a second dialect gets started. The objectui#4328
+ dead-surface family has consistently chosen removal for zero-consumer surfaces.
+
+ The neighbouring `common.select` (minted one commit earlier by objectui#4386 /
+ PR #4397) is a different key and is untouched.
+
+ A negative pin (`packages/i18n/src/__tests__/common-search-retired-4392.test.ts`)
+ fails if the key returns to any pack, if any package reads or re-declares it, or
+ if a dynamic `common.*` reader grows a `search` member — every existing i18n gate
+ runs call site to key, and none of them can see a key with no call site.
+
+- fa51109: i18n: retire the orphaned `report.editor.*` namespace — 105 of its 106 keys, in all ten locale packs (~1050 translated strings)
+
+ The namespace labelled the hand-rolled report editor form. That form no longer
+ exists: `ReportConfigPanel`'s body is `ReportDefaultInspector`, a spec-driven
+ inspector whose labels come from the report spec's own metadata rather than from
+ a pack namespace. Until objectui#4137 the namespace had exactly one live reader,
+ and it was the objectui#4118 defect itself — the panel borrowing
+ `report.editor.title` (the label of the report's Title _field_) to name itself.
+ Moving that slot onto a purpose-built `report.editor.panelTitle` left the other
+ 105 keys with no reader anywhere.
+
+ Re-verified before deleting, repo-wide and per key: no `t()` call site, no
+ dynamic `t()` template form, and no JSON or MDX reference reads any of the 105.
+ No user-visible string changes — these keys never rendered.
+
+ `report.editor.panelTitle` survives in all ten packs and is untouched; the
+ deletion sweeps around it. `report.editor` therefore remains a live namespace
+ holding exactly that one key.
+
+ This narrows the exported `TranslationKeys` type (`typeof en`), which is why it
+ is a minor rather than a patch: code indexing `TranslationKeys` at a retired key
+ stops type-checking. No runtime consumer existed to break.
+
+ A negative pin (`packages/i18n/src/__tests__/report-editor-retired-4145.test.ts`)
+ names all 105 retired keys and fails if any returns to any pack, since every
+ existing i18n gate runs call site to key and none of them can see a key with no
+ call site.
+
+- 78fa331: console: seed the UI language from the tenant's server-side locale
+
+ `GET /auth/me/localization` has always been fetched on every boot, but its
+ `locale` only ever fed currency/date formatting — the UI language was decided
+ entirely client-side, so a tenant configured `zh-CN` still handed every new
+ device an English console until each user switched by hand.
+
+ The tenant locale now sits in the language precedence chain, between the user's
+ own choice and the browser's:
+
+ 1. the user's explicit choice (`objectui-locale`)
+ 2. the tenant's server locale, cached at `objectui-locale-seed`
+ 3. the browser language
+ 4. `en`
+
+ The server value is cached in a slot of its own and is never written into the
+ explicit-choice slot, so it can never masquerade as a preference the user
+ expressed: only a manual switch promotes a language to an explicit choice. A
+ cached seed applies synchronously at bootstrap, and the in-app fetch refreshes
+ that cache from every successful answer, so a tenant that changes its locale
+ reaches choice-less devices on their next boot without an old seed pinning
+ them. On a device's true first visit the fetch is raced against a ~500ms
+ timeout alongside the console's existing pre-mount round-trips and fails open
+ to the browser language; a seed that arrives after the bound is cached for the
+ next boot rather than re-languaging a live session. A tenant locale this build
+ ships no pack for falls through to the next tier instead of half-rendering.
+
+ No platform additions: no new endpoint, no client read/write API, and
+ `sys_user_preference` is untouched.
+
+### Patch Changes
+
+- 734d186: The console's Applications page is localized — its own chrome only, never the
+ server's words (objectui#4307).
+
+ `AppManagementPage` was raw English end to end: headings, the search field, the
+ selection and bulk controls, the six per-row actions with their tooltip/ARIA
+ pairs, the status badges, and every toast. It was the last un-i18n'd system page,
+ and #4233 / PR #4300 had just given it four live mutations — so the gap became
+ user-visible on every non-English console at the moment operators started using
+ it. 45 keys land under `appManagement.*` in all ten packs, reached through
+ `useObjectTranslation` with the call site's `defaultValue` inline, which is the
+ convention the neighbouring system pages already follow.
+
+ The split that shapes this change is between the strings the PAGE authors and
+ the strings the SERVER authors. `PUT`/`DELETE /api/v1/meta/app/:name` is gated on
+ `manage_metadata` (ADR-0066 D1), so a refusal like `forbidden: manage_metadata
+required` is the server's diagnosis of one specific request; there is no fixed
+ catalogue of those sentences to key against. Each failure toast is therefore a
+ keyed template with a `{{reason}}` hole, and what fills the hole is passed
+ through byte for byte, untranslated. The one part that IS the page's own — what
+ it says when the server sent no message at all — is keyed as
+ `appManagement.toast.unknownError`.
+
+ Two smaller things follow from doing the conversion properly rather than
+ mechanically. The per-failure entry of a bulk toast and the separator between
+ entries are keys, not literals, because bracket style and list punctuation are
+ locale properties (the same rule, and the same past defect, as
+ `validation.formInvalidJoiner`). And the row's controls now name an app through
+ the resolver the visible heading two lines away already used, with `t` passed:
+ an app carrying objectui's keyed label form previously rendered `Select [object
+Object]` into its checkbox's ARIA label.
+
+- f7c6430: The build-history panel tells an operator a 503 means "the commit store could not be reached — retry", instead of `commits HTTP 503`
+
+ `packages/app-shell/src/preview/commitHistory.ts` flattened every non-OK response to a bare status code (`commits HTTP {status}` for the read, `HTTP {status}` for the revert). Nothing was ever swallowed and no fictional "no history" was ever rendered — those fail-loud properties held, and still hold, which is why objectstack#5980's 503-ification (ADR-0110 D3) needed no follow-up here. What was lost is the meaning the backend already sends, on the one screen where it matters most: this is the rollback surface, read by an operator who is usually mid-incident. A 503 says the read/write did not happen and is worth retrying; a 404 says the store answered "no". They now read differently, and 404, 500 and 503 stay tellable apart.
+
+ Failures now throw a `CommitStoreError` carrying `status`, the ADR-0112 `code`, and a `retryable` flag, and the panel renders a sentence rather than a number. The revert half gets a deliberately different sentence: a write that could not reach the store may still have landed, and re-issuing it appends a _second_ revert commit to an append-only log, so the copy asks the operator to re-read the timeline before retrying rather than simply saying "try again".
+
+ Two details of the report this fixes were checked against the producer and came back different, and both are the reason the copy is authored client-side. The semantic code arrives at **`error.code`**, not `details.code` — `HttpDispatcher.errorFromThrown` parks it in `details` and `buildApiError`/`splitSemanticCode` lift it out and drop `details` (objectstack#3842) — so a consumer reading `details.code` would run a check that can only pass vacuously. And the envelope's own `message` for this class is _withheld_: `declaresServerFault` (objectstack#5811) is true for a 5xx carrying a string code, so the prose on the wire is the generic `Internal server error`. Rendering it would have been strictly worse than the bare status code it replaced. Classification therefore keys on the HTTP status first and treats the code as a second signal, which also means a 503 shed by a proxy with an HTML body still produces the retryable reading.
+
+ Adds `preview.history.loadFailedUnavailable` and `preview.history.revertUnavailable` to all ten locale packs.
+
+- 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
+
+ `gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
+
+ The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
+
+ `gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
+
+ Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
+
+- e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
+
+ Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
+
+ Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
+
+ During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
+
+ Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
+
+ One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- 33c32bf: List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
+
+ `filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for _filtering_ silently became the _sort_ whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
+
+ Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
+
+ One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
+
+ The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- 405e808: `pickLocalized` reads own properties only, and takes only string values, on every limb
+
+ The resolver read four of its six limbs — the exact tag, the base language, `default` and `en` — with a bare bracket access. Bare access walks the prototype chain, so a locale that happened to name an `Object.prototype` member resolved to that member and the function stringified it into the label: `pickLocalized({ en: 'Pricing' }, 'constructor')` returned `function Object() { [native code] }`, and the same held for `toString`, `valueOf`, `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`. Those same four limbs also skipped the `typeof === 'string'` filter the regional and last-resort limbs already applied, so a non-string value short-circuited the chain and rendered as `[object Object]`.
+
+ Both guards now apply uniformly. A guarded limb **misses** rather than aborting the resolution, so an unusable entry falls through to the next limb exactly as an absent one does — `pickLocalized({ en: 'Pricing' }, 'constructor')` is now `'Pricing'` (the `en` limb), and only a map with no usable entry at all resolves to `''`. An empty-string value is still a hit, because `''` is a label the author wrote.
+
+ No real language tag can observe this: no BCP-47 tag is an `Object.prototype` member, and the inline locale map is declared `z.record(, z.string())`, so every in-contract input resolves byte-identically to before. What it changes is agreement with the backend twin `resolveI18nLabel` (objectstack#6765), which shipped with exactly these two narrowings recorded as deliberate departures from this function because on a server the locale can arrive in an `Accept-Language` header. That recorded rule divergence is now zero; the only remaining difference is how each side spells a miss (`''` here for a text node, `undefined` there for a producer's fallback chain), which is pinned as an identity in the cross-resolver parity table.
+
+- c0f9a4b: Studio surfaces the runtime authoring gate's advisory findings instead of discarding them client-side
+
+ The framework's runtime authoring gate produces two kinds of verdict on a metadata write. Errors become a 422 and the author sees them. Advisories ride a **200** — the save succeeded, the row persisted, the version bumped — and until objectstack#7435 the server dropped them into a deduped `console.warn` behind a process-level set. That landing put them on the wire as an optional `advisories[]` on the save response, emitted only when non-empty, and objectui was still throwing them away one layer further out: `MetadataClient.save` parsed the body, returned it as an opaque `T`, and every call site awaited it for its side effect and discarded the value.
+
+ The measured case the fix is built on: a `nightly_purge` flow whose only defect is a `delete_record` node with `multi: true` and no filter yields `errors = 0 / advisories = 1`. The save returns 200, the flow goes live, and nothing anywhere tells the author it deletes every row. That matters most for exactly the authors Studio serves — a Studio tenant or an MCP/AI author has no `os lint` and no CLI config for `sys_metadata` overlay rows, so this gate is not the weakest of four doors, it is the only one.
+
+ `MetadataClient` now carries an `onSaveAdvisory` sink, invoked after a save whose response carried a non-empty `advisories[]`, and the console wires it in `useMetadataClient` — the one hook every app-shell write path takes its client from, so a single wiring covers `ResourceEditPage`, `StudioDesignSurface`, `EmbeddedItemEditor`, `DatasourceResourcePage`, `ObjectHooksPanel` and any future call site rather than a toast copied into twenty of them. The finding shape is re-exported from `@objectstack/spec` (`RuntimeAuthoringIssue`) rather than restated, so it cannot fork from the 422 `issues[]` it deliberately shares a declaration with.
+
+ The affordance is the warning tier and says "Saved" first. A successful save that reads as a failure is the specific defect this surface must not ship, so the toast acknowledges the write, lists `rule` + `message` + `hint` per finding with `where` as secondary context, and renders that text **verbatim** — `message` and `hint` are server prose composed by the gate's rules, not i18n keys. Only the frame around them is translated (`console.saveAdvisoryTitle`, ten packs). The sink is best-effort in both directions: a malformed finding is dropped rather than printed as blanks, and a throwing renderer cannot turn a save the server already committed into an error.
+
+ **What this does not surface yet, and why.** Studio's designer saves as a **draft** on every edit, and drafts are never gated — the framework returns at its D1 early-return (`if (args.state !== 'active') return null`) before running a single rule, so a draft save produces no findings at all rather than producing some that get withheld. The publish step that promotes a draft to active _does_ run the gate, but the publish route returns no `advisories` field until objectstack#7294 lands. So a draft-then-publish flow renders nothing today, at both of its doors, for two different reasons; the active-mode save door renders findings now. That gap is pinned as a test rather than left for a reader to rediscover.
+
+- d46f9b8: i18n: `createSafeTranslation`'s provider-less fallback now honours a call site's inline `defaultValue`
+
+ `fallbackT` looked its key up in the hook's hand-written `defaults` map and, on a miss, rendered the
+ **raw key** to the user — then ran every option, `defaultValue` included, through the interpolation
+ loop as if it were a `{{defaultValue}}` variable. So `t('perm.facet.none', { defaultValue: 'None' })`
+ showed `perm.facet.none` on a host with no `I18nProvider`, which is a supported scenario (standalone
+ embedding and tests are the whole reason this factory exists).
+
+ The lookup order is now `defaults[key]` -> a string `defaultValue` -> the key, matching i18next,
+ which serves the provider path: the defaults map is the pack value's stand-in here, so it keeps the
+ pack's winning position. `defaultValue` is also excluded from the interpolation loop as a reserved
+ name — it selects the string, it does not fill holes in one. Non-string `defaultValue` is ignored.
+ The provider path is untouched.
+
+ Measured over all 26 `createSafeTranslation` hooks in the repo: 27 keys reach a hook whose defaults
+ map lacks them, 21 of those carrying an inline `defaultValue` that used to be dropped (16 keys in
+ `plugin-detail` alone). Those 21 now render their English instead of a raw key on provider-less
+ hosts; the other 6 pass no inline default and still need a map or pack entry.
+
+- 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
+
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
+
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
+
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
+
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
+
+- 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
+
+ The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: _add a formula field holding it_. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
+
+ The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
+
+ All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
+
+- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
+
+ Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
+
+ `ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
+
+ `ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
+
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
+
## 17.4.0
### Patch Changes
diff --git a/packages/i18n/package.json b/packages/i18n/package.json
index 08adde9766..2a5ff5f6de 100644
--- a/packages/i18n/package.json
+++ b/packages/i18n/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/i18n",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/layout/CHANGELOG.md b/packages/layout/CHANGELOG.md
index 60df063ad9..086fc4ef07 100644
--- a/packages/layout/CHANGELOG.md
+++ b/packages/layout/CHANGELOG.md
@@ -1,5 +1,152 @@
# @object-ui/layout
+## 17.5.0
+
+### Minor Changes
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
+
+ Two consequences worth knowing:
+
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/layout/package.json b/packages/layout/package.json
index 220133f1a0..fc1b23d14c 100644
--- a/packages/layout/package.json
+++ b/packages/layout/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/layout",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": [
"./dist/index.js",
diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md
index 9c926a3d15..c64aa4021f 100644
--- a/packages/mobile/CHANGELOG.md
+++ b/packages/mobile/CHANGELOG.md
@@ -1,5 +1,17 @@
# @object-ui/mobile
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [bb68488]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/mobile/package.json b/packages/mobile/package.json
index 5eb4658521..d026ad356c 100644
--- a/packages/mobile/package.json
+++ b/packages/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/mobile",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Mobile optimization for Object UI with responsive components, PWA support, and touch gesture handling.",
diff --git a/packages/permissions/CHANGELOG.md b/packages/permissions/CHANGELOG.md
index 9f255c0f82..5952ad0df0 100644
--- a/packages/permissions/CHANGELOG.md
+++ b/packages/permissions/CHANGELOG.md
@@ -1,5 +1,53 @@
# @object-ui/permissions
+## 17.5.0
+
+### Minor Changes
+
+- 2a40f69: Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
+ branch point rather than taken from their cards, and one card's premise only half held.
+
+ Breaking for anyone who typed against the removed declaration, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/types` and `@object-ui/permissions` no longer export
+ `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
+ (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
+ read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
+ referents were its own definition and the two barrel lines. The wired home is
+ `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
+ what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
+ now records the retirement so the surface is not re-declared. (#4364)
+
+ `PermissionCondition` was proposed for retirement on the same card and is **kept**: its
+ premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
+ branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
+ type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
+ is likewise untouched — `FieldLevelPermission.effect` still reads it.
+
+ No behaviour change, no public surface change:
+
+ - `@object-ui/console` drops `src/utils/metadataConverters.ts` and
+ `src/services/MetadataService.ts`. Both were console-local duplicates of live
+ `@object-ui/app-shell` modules and lost their last importer when the bespoke
+ object-detail widgets were retired (#4365). Both had already drifted behind the live
+ copies they duplicate — the console converter's `referenceTo` chain never read the
+ server's `reference` key, and the console service predates the view cache-invalidation
+ seam (#4373) — which is precisely the imitation trap the card recorded: an author
+ grepping for "the converter" could land on the unexercised copy. The app-shell copies
+ and their tests are untouched. (#4368)
+
+### Patch Changes
+
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [bb68488]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/permissions/package.json b/packages/permissions/package.json
index d41eda9dfa..28befdb598 100644
--- a/packages/permissions/package.json
+++ b/packages/permissions/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/permissions",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "RBAC permission system for Object UI with object/field/row-level access control, permission guards, and hooks.",
diff --git a/packages/plugin-ai/CHANGELOG.md b/packages/plugin-ai/CHANGELOG.md
index d8b946c564..dd87570a4f 100644
--- a/packages/plugin-ai/CHANGELOG.md
+++ b/packages/plugin-ai/CHANGELOG.md
@@ -1,5 +1,60 @@
# @object-ui/plugin-ai
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-ai/package.json b/packages/plugin-ai/package.json
index 0b1c7709ff..33360c5807 100644
--- a/packages/plugin-ai/package.json
+++ b/packages/plugin-ai/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-ai",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
diff --git a/packages/plugin-calendar/CHANGELOG.md b/packages/plugin-calendar/CHANGELOG.md
index 045741752f..dd3d221e4e 100644
--- a/packages/plugin-calendar/CHANGELOG.md
+++ b/packages/plugin-calendar/CHANGELOG.md
@@ -1,5 +1,106 @@
# @object-ui/plugin-calendar
+## 17.5.0
+
+### Patch Changes
+
+- 49b9de6: fix(plugin-calendar): authoring `events` on a `calendar-view` node no longer takes the calendar down
+
+ `calendar-view`'s renderer computed a `CalendarEvent[]` from `schema.data`, passed it
+ as `events={…}`, then spread the remaining props **after** it. `SchemaRenderer`
+ forwards a node's `events` key as a plain prop, so a node authoring `events` — the
+ ordinary SDUI action metadata, legal on any node — landed its `{ onClick: [...] }`
+ object on the `events` array prop: `CalendarView` iterated it and threw
+ `events is not iterable`, and a spec-legal node rendered an error card instead of its
+ calendar.
+
+ The authored key is now destructured out before the spread, so the computed array
+ always wins. This also closes the quiet half of the same collision: an authored
+ `events` **array** never threw — it silently replaced the calendar's contents with
+ itself.
+
+ No capability is removed. Nothing in the renderer layer consumes a node's `events`
+ key (the action path is `properties.action` through `ActionRunner`), and the
+ component's own `onAction` channel is unaffected.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [6d01319]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-calendar/package.json b/packages/plugin-calendar/package.json
index 817c3ba1fd..7878ef433c 100644
--- a/packages/plugin-calendar/package.json
+++ b/packages/plugin-calendar/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-calendar",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Calendar view plugins for Object UI - includes both ObjectQL-integrated and standalone calendar components",
diff --git a/packages/plugin-charts/CHANGELOG.md b/packages/plugin-charts/CHANGELOG.md
index cf02e7f50f..23bb0059ee 100644
--- a/packages/plugin-charts/CHANGELOG.md
+++ b/packages/plugin-charts/CHANGELOG.md
@@ -1,5 +1,121 @@
# @object-ui/plugin-charts
+## 17.5.0
+
+### Patch Changes
+
+- 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
+
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
+
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions...` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
+
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
+
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
+
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
+
+ The label lookup read options as `baseObject.fields[]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
+
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
+
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
+
+- 0b49d60: Analytics: `ObjectChart` consumes the shared label-net helpers instead of a third copy
+
+ objectui#4389 (PR #4404) named two copies of the analytics label-net glue — the dashboard's `DatasetWidget` and plugin-report's dataset block — and retired both into `@object-ui/core` + `@object-ui/react`. There was a THIRD, which that card did not name and its PR deliberately left out of scope: `packages/plugin-charts/src/ObjectChart.tsx` carried its own `translatorFor` closure, its own `buildDimensionLabelMap` loop, and its own base-object-read-then-walk composition. The `translatorFor` copy was logically identical to the two that were deleted, down to the comment explaining the binding.
+
+ `ObjectChart` now calls core's `dimensionOptionTranslator`, `deriveDimensionLabelMaps` and `loadDimensionFieldMeta` directly. Nothing about what a label IS changes — those helpers are the same code the two retired copies were rewritten onto, so the part that was genuinely duplicated three times is now written once.
+
+ Behaviour is unchanged by construction: same two metadata reads in the same order on the dataset path, same one read on the aggregate path, same best-effort fallback (an unresolvable path yields no entry and the raw value survives), same locale-applying memo boundary. `plugin-charts`' 22 test files / 170 assertions pass unchanged and their files are byte-identical to before, which is the acceptance evidence for a pure swap.
+
+ The card's second, optional step — moving the DATASET path's metadata read onto `@object-ui/react`'s `useDatasetDimensionMeta` — was attempted and declined on measurement; the shape blocker is recorded on objectui#4405 and in the PR. The two bug-fix properties the family exists to state (the read rides the host's authenticated `apiFetch`, objectui#4121; the fetched metadata stays locale-free, objectui#4030 / PR #4324) therefore remain stated locally in this file, exactly as before, and are undisturbed by this change.
+
+- bcd3e02: `ObjectChart`'s category option-color / dimension-label probe now rides the host's
+ authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
+ `fetch`.
+
+ Both metadata reads the effect makes — `GET /api/v1/meta/dataset/{dataset}` and
+ `GET /api/v1/meta/object/{object}` — went out on the global `fetch`, so in a hosted
+ console they skipped whatever the host supplies on that channel (Authorization /
+ tenant headers, base-URL rewrite, draft-preview params). A bearer-token session
+ carries its credential in a header rather than a cookie, so `credentials: 'include'`
+ alone left these two reads unauthenticated. The effect is best-effort and swallows
+ every failure, which made the symptom silent: semantic option colors and dataset
+ dimension labels simply never applied, and the chart fell back to the positional
+ theme palette and raw stored values.
+
+ Standalone embeds are unaffected — with no provider (or a provider that supplies no
+ `apiFetch`) the probe still uses the global `fetch`, the same documented fallback
+ `useRecordEditable` and `provider: 'api'` view sources use.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-charts/package.json b/packages/plugin-charts/package.json
index 23a1c46bf0..9ea8c6bb19 100644
--- a/packages/plugin-charts/package.json
+++ b/packages/plugin-charts/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-charts",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Chart components plugin for Object UI, powered by Recharts",
diff --git a/packages/plugin-chatbot/CHANGELOG.md b/packages/plugin-chatbot/CHANGELOG.md
index 29354f0bfa..8555c774c2 100644
--- a/packages/plugin-chatbot/CHANGELOG.md
+++ b/packages/plugin-chatbot/CHANGELOG.md
@@ -1,5 +1,106 @@
# @object-ui/plugin-chatbot
+## 17.5.0
+
+### Minor Changes
+
+- 3256b14: `@object-ui/plugin-chatbot`'s `ChatMessage` is now one type instead of two
+
+ The barrel exported two different `ChatMessage` types: a minimal one it declared itself (`id` / `role` / `content` / `timestamp` / `avatar` / `avatarFallback`) and the shape `` actually renders, re-exported under the alias `ChatbotEnhancedMessage`. The natural name resolved to the narrow one, so an importer reaching for `ChatMessage` silently got the wrong contract — and the compiler could not object, because both shapes existed on purpose and every construction site spreads the extra keys conditionally, which defeats excess-property checking. That is how app-shell's `AiChatPage` ended up unable to read `toolInvocations` off its own function's return value (objectui#4040; re-pointed in PR #4379, but the collision itself was left standing). objectui#4383.
+
+ **Breaking semantics** (declared `minor` per AGENTS.md §版本号策略 — objectui never declares `major` outside an `@objectstack` major sync): `ChatMessage` exported from `@object-ui/plugin-chatbot` now denotes the enhanced shape. In practice this is a widening rather than a removal — every field of the retired shape survives with the same type, and the enhanced shape adds only optional keys (`streaming`, `toolInvocations`, `reasoning`, `sources`, `traceId`, `buildProgress`, `blueprintProgress`, `charts`), so anything that was a valid `ChatMessage` still is, and ` ` keeps accepting the same values. Code that relied on the name meaning _exactly_ the six-key shape (exhaustive `keyof` maps, `Equal`-style assertions) is the case that changes.
+
+ `ChatbotEnhancedMessage` is kept as a `@deprecated` alias of the same type, so importers that spelled the disambiguating name keep compiling; new code should import `ChatMessage`. Pinned at compile time by `packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts`.
+
+- eec2e4f: `useObjectChat` declares the message shape it actually hands back
+
+ The hook typed `messages` — and the `onSend(content, messages)` callback fed from it — as `@object-ui/types`' authoring `ChatMessage`. That was true in local mode only. In API mode the values came out of the runtime mapper and were asserted into place with `as OuiChatMessage[]`, and the authoring contract declares none of what they carry: `buildProgress`, `blueprintProgress`, `charts`, and `pendingActionId` / `draftReview` / `proposedPlan` / `proposedChanges` / `builderHandoff` on every tool invocation. Those keys are the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and the inline charts. They survived only because nothing on the path ever rebuilt a message; anyone writing the obvious thing — reconstruct a message field-by-field from its declared type — deleted all of them, with the compiler agreeing, because the declared type genuinely did not have them.
+
+ The declaration is now the truth, published as `ObjectChatMessage`. The survey behind it found the honest type to be neither of the two `ChatMessage` types on either side, because neither is true of both modes: it stays **wide** where local mode is wide (an authored `'tool'` role and the legacy `'partial-call'` / `'call'` / `'result'` tool states reach this surface unchanged and are folded only at the render seam), **narrow** where both modes are narrow (`timestamp` is `string`, never `Date` — API mode never produces one and local mode absorbs it before emitting), and adds the render-only keys API mode really carries. The `as OuiChatMessage[]` assertion is deleted rather than moved: the mapper's output satisfies the declared type, so the compiler checks that assignment instead of being told to stop looking.
+
+ Nothing about the values changed, and nothing correct breaks. `ObjectChatMessage` is a **subtype** of the authoring `ChatMessage` it replaces, so every consumer that accepted the old declaration still accepts these values — including a host `onSend` callback that types its parameter as `ChatMessage[]`, which keeps type-checking by contravariance. Naming `ObjectChatMessage` is what lets a host _read_ the keys above. The one observable narrowing is deliberate: code that branched on `timestamp instanceof Date` was handling a value this hook cannot emit, and now says so at compile time.
+
+ The seam below it (`chatMessageAdapter.ts`, from objectui#4399) is still necessary and unchanged in behaviour — `'tool'` and the legacy tool states still have to be narrowed for the renderers. What changed is that its pass-through is no longer an act of faith: its input type (`SeamChatMessage`, also exported, alongside `SeamToolInvocation`) names the render-only keys, so the spread preserves them as declared properties the compiler can see, and the pass-through tests type their API-mode fixture directly instead of casting it past the compiler. A cast returning to the hook is now caught by a test rather than by a future outage.
+
+ App-shell carries a comment-only correction on the same family: `AiChatPage` still described `@object-ui/plugin-chatbot` as exporting a second, minimal legacy `ChatMessage` alongside the enhanced one. That collision was retired in objectui#4383 — the barrel publishes one contract and `ChatbotEnhancedMessage` is a deprecated alias of it — so the paragraph was sending readers to look for a hazard that no longer exists.
+
+### Patch Changes
+
+- 37bbc42: Replace the three `messages as any` casts at the `@object-ui/types` ↔
+ `@object-ui/plugin-chatbot` `ChatMessage` boundary with one explicit typed
+ adapter (`toRuntimeMessages` / `authoredToRuntimeMessage`, now exported).
+
+ The authoring contract (`ChatbotSchema['messages']`) and the runtime contract
+ `` renders are both deliberate and deliberately different; the
+ casts erased ALL of that drift rather than the intentional parts, so a future
+ vocabulary move would have surfaced as rendering behaviour instead of a type
+ error. Each narrowing is now named, documented and tested: an authored
+ `role: 'tool'` message is an assistant message (unchanged rendering — the
+ implicit fallthrough is now the recorded decision), a `Date` timestamp becomes
+ its ISO string (one expression, consumed by both the seam and the hook's
+ `normalizeMessages`), and the legacy tool-invocation states
+ `'partial-call'`/`'call'`/`'result'` map to their AI SDK v6 equivalents as the
+ authoring type's own documentation declares — previously they reached the tool
+ chip unrecognised and rendered a status badge with no label.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-chatbot/package.json b/packages/plugin-chatbot/package.json
index 6593730dc0..565dab0898 100644
--- a/packages/plugin-chatbot/package.json
+++ b/packages/plugin-chatbot/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-chatbot",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Chatbot interface plugin for Object UI",
diff --git a/packages/plugin-dashboard/CHANGELOG.md b/packages/plugin-dashboard/CHANGELOG.md
index b5fe89d7d9..b4823ae581 100644
--- a/packages/plugin-dashboard/CHANGELOG.md
+++ b/packages/plugin-dashboard/CHANGELOG.md
@@ -1,5 +1,360 @@
# @object-ui/plugin-dashboard
+## 17.5.0
+
+### Minor Changes
+
+- f244273: `MetricWidgetProps` / `MetricCardProps` declare the DOM pass-through their spread has always accepted
+
+ Both KPI components end their prop list with a `...domProps` spread onto the Shadcn `Card`, and objectui#4357 (PR #4428) kept that spread deliberately — it is their only accessibility pass-through, and removing it would delete the only way a host can put an `id`, a `role` or an `aria-label` on a KPI card. Neither props interface declared any of it. So the type refused what the runtime accepted: a JS consumer, and every SDUI author going through `SchemaRenderer` (untyped at that boundary), got the pass-through, while a TypeScript consumer importing the component directly got `error TS2322` on `id` / `role` / `aria-label` and needed a cast.
+
+ `MetricWidgetProps` now extends `React.HTMLAttributes`, and `MetricCardProps` extends the same minus `title`. That is the repo's measured convention for an exported props interface that spreads onto a host element (`PageHeaderComponentProps`, `ChatbotProps`, `ChatbotEnhancedProps`, `TypingIndicatorProps`, `RefreshIndicatorProps`, `FieldProps`, and shadcn's `BadgeProps`), and the `Omit` carve-out is `ComboboxProps`'s spelling for a name the component's own contract owns.
+
+ Graded `minor` rather than `patch` per the objectui#4403 precedent: two exported interfaces widen. The widening is purely additive for existing callers — every prop that compiled before still compiles, and nothing narrows — so no source change is required to upgrade.
+
+ Semantics worth knowing, because both are contract statements rather than incidental:
+
+ - **`MetricCard.title` stays the heading.** HTML's `title` is a tooltip; this card's `title` is its heading, in the `I18nLabel` vocabulary, destructured out and rendered into `CardTitle`. No `title` attribute has ever reached this element, so the inherited DOM `title` is omitted rather than declared and silently dropped — the "declared but not delivered" failure this repo treats as first-class (objectui#3290, objectui#3222). `MetricWidget` has no such collision (its heading is `label`) and extends the DOM attributes whole.
+ - **`MetricWidget.onClick` stays zero-arg**, narrower than the inherited `MouseEventHandler`, because the same handler is wired to Enter/Space where there is no mouse event to hand over. A zero-arg function is assignable to the inherited signature, so callers already passing `(e) => …` keep compiling.
+
+ Not declared, deliberately: the schema-shaped keys `SchemaRenderer` injects (`schema` / `bind` / `events` / `props` / `ariaLabel` / `ariaDescribedBy` / `dataSource`). None is an HTML attribute name, all seven are destructured out before the spread, and declaring them would re-assert as public contract exactly what PR #4428 stripped from the DOM. They stay in `SchemaHostProps`, intersected in at each component's own signature — accepted so the renderer can inject them, never part of the documented authoring surface.
+
+ Zero runtime change: no component body was touched, and PR #4428's pins pass untouched.
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
+
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
+
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
+
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
+
+- 5900ac5: Analytics surfaces now run resolved select-option labels through the locale bundle — the chart legend and the related list on one page stop disagreeing
+
+ A dashboard widget grouped by a `select` field rendered the option's authored English label while the related list beside it rendered the translation. The decisive evidence in objectui#4030 is the stored value `orion`: the chart read `Orion Engineered Carbons`, a string with no resemblance to the value and matching the object's `label` byte for byte. So the analytics path had already RESOLVED the option label — it simply never ran the result through the i18n bundle before display. (`domestic → Domestic` differs from its value by case alone, which is why the first diagnosis, "the report groups by stored value", was wrong.)
+
+ There is exactly one resolution channel and this change reuses it rather than adding a chart-side dialect: `fieldOptionLabel` from `useObjectLabel`, i.e. `{ns}.fieldOptions...` — the convention `@objectstack/spec` names objectui as the reader of, and the one list, form, kanban and record-picker surfaces already translate select options through. The bundle is applied ONCE, at the output of the label net that landed in objectui#4053/#4263, on the shared option list every consumer reads: chart axis and legend, the table/pivot cells of a dotted dimension, that table's CSV export, per-category colours and the declared category order. `@object-ui/core` gains `localizeFieldOptions` (the pure mirror of `translateOptions`), an optional translator on `buildDimensionLabelMap`, and `resolveDimensionFieldMeta` — the same single relationship walk `resolveDimensionFieldOptions` performs, now keeping the object that OWNS the terminal field, because for `crm_account.industry` the bundle key is `crm_account`, not the dataset's base object.
+
+ Two properties the fix is shaped around. The rows reach this net keyed either way — by stored value when the server did not resolve the dimension, by the English label when it did (ADR-0021) — and the reported screen is the second case, so the map answers to both keys and lands on the same translated display. And identity is untouched: `relabelDimensions` still rewrites display only, so a drilled chart segment clicked as `欧励隆` filters by `orion`, bucket ids and pivot totals keep their raw keys, and an option with no bundle entry (or an `en` console) renders exactly the authored label it renders today.
+
+ The per-locale work moved from the metadata fetch into the render, so switching language now re-labels in place instead of waiting for a refetch.
+
+ Not covered, and unchanged here: a LOCAL select dimension on a table/pivot, whose label the server resolves and whose client-side net is deliberately off (objectui#4263), and a dashboard global filter's own field label, which has no object name in its metadata to key a bundle lookup with — tracked on objectui#4030.
+
+- 3c6e84c: Dashboard `combo` widgets draw as combos on the dataset path — the dataset owns the data, the author owns the presentation
+
+ A widget authoring the spec's own combo shape — `series[].type` plus `series[].yAxis: 'left'|'right'` and two `yAxis` entries — rendered as two bar series on one shared axis. Measured in the DOM: 2 bars, 0 lines, 1 y-axis, where 1 bar, 1 line and 2 axes were authored, so a percentage measure was plotted against a raw count's scale.
+
+ Two halves caused it, and fixing either alone leaves a worse state than before. `CHART_TYPE_MAP` had no `combo` entry, so a `combo` widget fell through its `?? 'bar'` default — bars, whatever the series said. And `chartConfigPresentation` refused to forward `series` / `xAxis` / `yAxis` at all, on the stated grounds that they are derived from the dataset selection, so the per-series mark and the axis binding could never reach the renderer even once the family resolved.
+
+ That belief was half right. The dataset does own the series MEMBERSHIP — which columns become series, which rows, which buckets — and it still does: an authored entry naming a measure the dataset did not select is ignored, and a derived series the author said nothing about keeps the family default. What the dataset never owned is the PRESENTATION carried on those same objects: the per-series mark, its left/right axis binding, label, colour, stack, and the axis definitions' title, format, min, max, step, grid and position. Those are the author's, and they now merge onto the derived bindings by name/key match with the explicit binding winning — one merge function, not a spread per attribute. The split runs through the two binding keys: `ChartSeries.name` and `ChartAxis.field` name a column and stay with the dataset; everything else on the object travels.
+
+ This is objectui#2880's S2 rule, which PR #2883 landed in `ObjectChart` and which the dataset path never carried over. Dropping `ChartAxis.field` on the way through is what makes forwarding the axes safe rather than merely guarded: it is the one key by which an authored axis could have named a series, since the renderer synthesises series from `yAxis[].field` when a chart declares none.
+
+ Two consequences beyond the reported bug. A non-combo widget can now declare one line series and get the combo the renderer already knew how to derive from disagreeing series types. And a `compareTo` overlay inherits its own measure's mark and axis, so the comparison of a bar-on-the-left measure no longer draws as a line on the right the moment the chart becomes a combo.
+
+ Dashboards that never authored `chartConfig.series` or `chartConfig.yAxis` emit exactly what they emitted before.
+
+- ee7a68d: `DatasetWidget`'s option-color / dimension-label probe now rides the host's
+ authenticated fetch (`SchemaRendererContext.apiFetch`) instead of the bare global
+ `fetch`.
+
+ The one metadata read the effect makes — `GET /api/v1/meta/object/{object}` — went
+ out on the global `fetch`, so in a hosted console it skipped whatever the host
+ supplies on that channel (Authorization / tenant headers, base-URL rewrite,
+ draft-preview params). A bearer-token session carries its credential in a header
+ rather than a cookie, so `credentials: 'include'` alone left this read
+ unauthenticated. The effect is best-effort and swallows every failure, which made
+ the symptom silent: a dataset chart's semantic per-category colors and its
+ dimensions' value → label maps simply never applied, and the widget fell back to
+ the positional theme palette and the raw stored values on the axis.
+
+ Standalone embeds are unaffected — with no provider (or a provider that supplies no
+ `apiFetch`) the probe still uses the global `fetch`, the same documented fallback
+ `useRecordEditable` and `provider: 'api'` view sources use.
+
+ This is the `plugin-dashboard` twin of the same fix made to `plugin-charts`'
+ `ObjectChart`.
+
+- 436681e: fix(dashboard): resolve a dotted dimension's labels on table and pivot dataset widgets
+
+ A dataset widget's client-side dimension-label safety net returned early for
+ `table` / `pivot` / metric widgets, so a DOTTED dimension (`crm_account.industry`)
+ rendered the raw stored enum (`education`) there — the same symptom objectui#4053
+ fixed for charts, on the widget types its fix did not reach.
+
+ The early return stays for LOCAL dimensions, which is what made it correct in the
+ first place: on a table the server resolves those labels (ADR-0021), so running
+ the client net for them would be a second resolution of an already-resolved
+ value. It now opens only for dotted paths — the case the server is silent on too —
+ reusing the existing `resolveDimensionFieldOptions` walk unchanged, multi-hop
+ paths included. A table with no dotted dimension resolves nothing and issues no
+ metadata read at all, so those widgets render byte-identically.
+
+ A pivot's marginal totals take the same relabel as its rows, because their bucket
+ ids are re-derived from the dimension values that the headers are built from; the
+ CSV export follows the table's cells for the same reason. Drill-through still
+ filters by the stored value — the relabel preserves row order and count, so the
+ raw rows it indexes stay aligned.
+
+ Metric widgets are unaffected by design: that branch renders one measure value
+ and its header label and puts no dimension value on screen, so it has nothing to
+ resolve.
+
+- 613b167: A dataset dimension on a dotted relationship path now renders its option labels instead of the raw stored enum
+
+ A `DatasetDimension` whose `field` is a relationship path (`crm_account.industry`) got no select-option resolution at all: the chart plotted `education`, `finance`, `manufacturing` — the database column, unresolved — while the **same underlying field** reached as a **local** dimension rendered `Education`, `Finance`, `Manufacturing` beside it on the same dashboard. Nothing errored, so the widget just quietly showed database enum values to end users; on a non-English deployment those are words that appear nowhere else in the UI, since every form and list shows the translated label.
+
+ The label lookup read options as `baseObject.fields[]`, which only ever matches the local spelling. For a dotted path the options live on the **related** object, so the lookup missed and the renderer fell through to the stored value.
+
+ The object-resolution step of that one lookup now walks the path: each segment before the last must be a declared relationship (`lookup` / `master_detail`, target read from `reference` / `reference_to` / `referenceTo` / `reference_to_object`), and the terminal field's options are read off the object that actually owns it. This is the same lookup for both spellings rather than a dotted-path variant beside it — a single-segment path never enters the walk and resolves exactly as before, so the local and joined paths cannot drift apart. Multi-hop paths (`crm_account.owner.department`) resolve too, which is the shape the dataset designer already emits.
+
+ Hops ride the caller's existing `GET /meta/object/:name` channel — the same authenticated read that fetched the base object — so no new fetch layer is introduced, and objects are fetched once per resolution even when several dimensions share a prefix. Every failure stays best-effort: a segment that is not a relationship, a target that cannot be loaded, or a terminal field with no options yields no mapping and the raw value survives, exactly as it does today.
+
+ Applies to both surfaces that carried this lookup: dashboard dataset widgets (`DatasetWidget`) and the chart view's dataset path (`ObjectChart`).
+
+ Scope: this ends at "the label is in hand". Whether that label then passes through the i18n bundle is a separate gap tracked upstream as objectstack#5076.
+
+- bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
+
+ Two consequences worth knowing:
+
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
+
+- 326a70f: Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
+
+ A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
+
+ Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
+
+ Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
+
+ This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
+- 306c101: KPI cards no longer write their own schema onto the DOM — `MetricWidget` and
+ `MetricCard` keep `SchemaRenderer`'s schema-shaped props out of the `...props`
+ spread (objectui#4357).
+
+ Both components are two things at once: an SDUI block reached through
+ `SchemaRenderer`, and a plain React component a host may render directly. The
+ React half wants a `...props` spread on its root so callers can pass `aria-*`,
+ `data-*`, `id`, `role`. The SDUI half means that spread also received the node's
+ own metadata — and React writes unknown lowercase attributes straight to the DOM,
+ stringifying object values. Every KPI card therefore carried
+ `schema="[object Object]"`, and a widget authored with events, a binding or a
+ props container carried `events="[object Object]"`, `bind="data.revenue"` and
+ `props="[object Object]"` beside it.
+
+ Seven props were measured arriving at the call site that are not HTML attribute
+ names — `schema`, `events`, `props`, `bind`, `ariaLabel`, `ariaDescribedBy` (the
+ last two are the camelCase authored forms of ARIA the renderer already emits in
+ their dashed spelling) and `dataSource`. They are destructured out; the spread
+ survives untouched for everything that IS a DOM attribute: `id`, `name`, `role`,
+ `disabled`, `aria-*`, `data-*`, `className`. Nothing else about the render moves
+ — no text, no class, no element.
+
+ `dataSource` is the one that only a live dashboard shows. It is not a schema key
+ (the renderer strips the schema's own `dataSource` binding by name); it is the
+ injected adapter `DashboardRenderer` hands its `SchemaRenderer` call, which
+ arrives through the renderer's trailing props. Every fixture in this package
+ renders without an adapter, so it read `undefined` and wrote nothing — while
+ every deployment that actually loads data put `datasource="[object Object]"` on
+ the card. The pin renders a dashboard with an adapter so the case that only
+ production had is now a test.
+
+ The cost of this was never visible; it was that the defect poisoned the
+ assertion this area attracts. objectui#4163 pins
+ `not.toContain('[object Object]')` on the dashboard grid, and objectui#4032
+ wanted the same pin on the metric path but could not write it: the card carried
+ the attribute before and after any i18n fix, so the container assertion was red
+ for a reason unrelated to labels and the tempting repair was to loosen it. That
+ suite asserted on the card heading instead, with a comment. The workaround is
+ now removed and the container assertion is back.
+
+ The exported `MetricWidgetProps` / `MetricCardProps` interfaces are unchanged —
+ the components' accepted props widen only by the optional, ignored
+ `SchemaHostProps` keys, so no consumer type narrows.
+
+- 45e1949: Numbers render in the user's locale, and a `Field.number` year is no longer `2,026`
+
+ Every numeric field the console rendered went through an `Intl.NumberFormat` built with the locale hardcoded to `en-US` and `useGrouping` never set. Two defects rode in that one construction: a `zh-CN` or `de-DE` console still grouped and pointed decimals the US way, and a four-digit **year** stored as `Field.number({ scale: 0 })` rendered as `2,026` — in every locale, with no field property able to turn it off. Apps had been converting year columns to `Field.text` to escape it, permanently trading numeric comparison, range filters and dataset dimension types for a display detail.
+
+ The construction had been copied into five places — the number cell renderer, the currency cell renderer, the `CurrencyField` widget, the compact `formatNumber` helper, and the dashboard `MetricWidget` — so fixing any one surface never changed the answer. They now share one formatter, `formatDisplayNumber` in `@object-ui/i18n`, which owns the locale and the grouping policy together, plus one locale resolver, `useDisplayLocale`.
+
+ `useDisplayLocale` composes the two locale channels this repo already had rather than adding a third: the tenant's regional default (`useLocalization().locale`, ADR-0053) when an org has configured one, otherwise the active UI language (`useObjectTranslation().language`) so grouping and decimal marks follow a language switch. That second step is what covers the case the report was measured in — a fresh database, where the tenant localization endpoint has no locale to give.
+
+ Grouping is now suppressed when a field declares `scale: 0` and carries no currency, which is what makes years, fiscal periods and other ordinals render plainly. This is an **interim default** with an accepted cost: a large scale-0 _count_ loses its separators too. It holds only until the spec gains an authorable presentation hint, which is being specified separately, contract-first; when that lands it overrides this heuristic.
+
+ Three surfaces deliberately keep their separators, because a zero-decimal display there does not come from a field declaration: the dashboard `MetricWidget` (its decimals are parsed from a numeral.js format pattern, and its own contract calls the separators load-bearing — "`1,930,000` not `1930000`"), the `element:number` aggregate renderer, and every currency path including amounts whose currency code could not be resolved. An **undeclared** `scale` also keeps grouping — absent means "decimals unknown", not "integer".
+
+ `formatCurrency`, `formatCompactCurrency` and `formatNumber` each take a new optional trailing `locale` argument. Existing calls are unaffected; omitting it now follows the runtime default rather than forcing US conventions.
+
+- 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-dashboard/package.json b/packages/plugin-dashboard/package.json
index 4fcf51d219..ba39025152 100644
--- a/packages/plugin-dashboard/package.json
+++ b/packages/plugin-dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-dashboard",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Dashboard plugin for Object UI",
diff --git a/packages/plugin-designer/CHANGELOG.md b/packages/plugin-designer/CHANGELOG.md
index f40784f9eb..eee075cf32 100644
--- a/packages/plugin-designer/CHANGELOG.md
+++ b/packages/plugin-designer/CHANGELOG.md
@@ -1,5 +1,229 @@
# @object-ui/plugin-designer
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- abb0f81: A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
+
+ `@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
+
+ Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
+
+ No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
+
+ The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
+
+- bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
+
+ Two consequences worth knowing:
+
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
+
+ Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [2776b11]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [605b747]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [b42558a]
+- Updated dependencies [d2f6e6b]
+- Updated dependencies [85a3082]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/data-objectstack@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+ - @object-ui/plugin-form@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-designer/package.json b/packages/plugin-designer/package.json
index b251fe3c91..2da3718561 100644
--- a/packages/plugin-designer/package.json
+++ b/packages/plugin-designer/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-designer",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Visual designer plugin for Object UI with page, data model, process, and report designers plus collaborative editing.",
diff --git a/packages/plugin-detail/CHANGELOG.md b/packages/plugin-detail/CHANGELOG.md
index ea8f126c3e..ae71498473 100644
--- a/packages/plugin-detail/CHANGELOG.md
+++ b/packages/plugin-detail/CHANGELOG.md
@@ -1,5 +1,251 @@
# @object-ui/plugin-detail
+## 17.5.0
+
+### Patch Changes
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- 6d01319: Inline edit no longer offers a record picker for a spec-spelled `autonumber` field that carries a `reference_to`
+
+ `TEXTUAL_REF_FALLBACK_TYPES` — the detail page's one definition of "machine-computed" — spelled the auto-number type `auto_number` only. `@objectstack/spec`, the designer and the metadata importer all spell it `autonumber`, and the set is matched by RAW spelling, so it carried half the type.
+
+ The reader that had no gate in front of it is `InlineFieldInput`'s reference fallback, `!!field.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(type)`, on exported public API. A field typed `autonumber` keeps a `reference_to` for relational metadata — which is the entire reason this set exists — so it took the lookup branch and rendered the RECORD PICKER: a searchable list of records offered as replacements for a machine-generated identity. The `auto_number` spelling of the identical field rendered the textual fallback, as intended. Both spellings are now members, matching how `plugin-form` carries both in each of its non-input sets.
+
+ The editability half of the same report (objectui#4219) was already closed from another direction by #4228, whose shared exclusion resolves aliases before matching — a field typed `autonumber` offers no inline affordance in either host. The two gates are a union, so this fix also removes the union's dependence on which spelling the metadata happens to use: previously `autonumber` was held by the exclusion gate alone and `auto_number` by both, and losing either gate would have re-opened a different half of the defect depending on how the field was authored.
+
+ Pins land with it: the reference fallback for `autonumber` (red before this change — the picker really did render), `auto_number` and a real `lookup` as controls in both directions, and set membership asserted directly so the union statement is checked rather than described.
+
+- 63fe8fd: `record:related_list` and the detail synthesizer now declare two shapes they already accepted at runtime.
+
+ `RecordRelatedListRenderer`'s `schema` prop made `objectName` required, which rejected the exact authoring shape the per-element `dataSource` binding exists to support (`{ relationshipField, dataSource: { object, view } }`) — the gate maps the binding onto `objectName` before the body reads it, so the key is supplied, not missing. It is optional on the wrapper's input now, and required everywhere else.
+
+ `ObjectDefLike.fieldGroups` is derived from the spec's authorable field group instead of restating it. The hand-written list had drifted: it omitted `icon` and `description`, both of which the synthesizer passes through to detail section descriptors, so an object definition declaring the group icon the code honours did not type-check against it.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 6314e87: Inline-editing an `address` on the record detail page now edits it as real sub-fields, instead of collapsing it to one text box reading `[Object]` and saving a string over the structured value.
+
+ `InlineFieldInput`'s type switch routed the scalar and relational families to their dedicated widgets; every structured-object type matched nothing and fell through to the terminal raw text input at the end of the component. That fallback stringifies an object value through `coerceToSafeValue`, whose general-object case extracts `name || label || externalId || id || _id` and otherwise returns the literal `[Object]`. A stored address carries none of those keys, so the edit box read `[Object]`.
+
+ The display half was cosmetic; the write half was not. The fallback is a plain input wired to `onChange(v)`, so whatever the user typed was emitted as a **string** that replaced the whole `{ street, city, state, postalCode, country }` object on save — and `[Object]` was what the user saw as the current value they were correcting, which makes typing over it the natural gesture. An ordinary double-click inline edit therefore destroyed the sub-field structure. This is the input path only: objectui#4037 fixed the display registry, and read mode (including the inline-edit read state before editing starts) already rendered a formatted address.
+
+ `location` and `geolocation` are fixed with it. Both store objects too (`{ latitude, longitude }`), both reached the same terminal input, and both produced the identical `[Object]`-then-overwrite pair — one defect in three spellings, not three defects.
+
+ No new editor was written and no consumer-side tolerance was added. All three route to the widgets the create/edit dialog already uses (`AddressField` / `LocationField` / `GeolocationField`, the form's own structured-value editors), so the two entry points cannot diverge on the value shape they write back, and `coerceToSafeValue` is left untouched — the routing is what stops an address from ever reaching it. `autoFocus` follows the numeric branches' convention and lands on each widget's first sub-input (street / the coordinate box / latitude).
+
+ String-valued types are unchanged: `text`, `textarea`, `email`, `phone`, `url`, `color`, `code`, `time`, `qrcode` and the rest keep the terminal text input, where stringification is the identity and nothing is lost.
+
+- 5e2e9fa: A `password` or `secret` field on the record detail page is no longer inline-editable: it renders no pencil / double-click affordance and produces no editor, on both the details body and the highlights strip.
+
+ Both types are **masked on read** — `getCellRenderer` returns a fixed bullet run for either — so the value the row could hand an editor was never the credential. `InlineFieldInput` had no branch for either type, so both reached the terminal raw text input at the end of the component, and the row's payload value was seeded into a `type="text"` box: rendered in clear, selectable and copyable, in a control the user reads as holding their credential. Committing the row then wrote that placeholder back verbatim over the field. For `secret` the overwritten value is an opaque reference into an encrypted store (ADR-0100), so the write destroyed the pointer, not just the display. Nothing in the flow said so; the failure surfaced later, wherever that credential was used.
+
+ The decision already existed one package over. `INLINE_EXCLUDED_FIELD_TYPES` in `@object-ui/fields` excludes both types with exactly this reasoning, and the grid honours it through `isInlineExcludedFieldType()`. The detail hosts gated on readonly / computed / system only and never consulted it, so the detail page reproduced the precise failure the set exists to prevent. Both hosts now consult that same alias-aware contract (`isInlineExcludedDetailFieldType`, a narrow-only union of the authored and the object type, matching the computed gate under objectui#3355) rather than growing a second hand-maintained list — so the rule cannot drift between the grid and the detail page again.
+
+ Consulting the shared set closes the container family on the detail page with it: `object`, `composite`, `record`, `grid`, `repeater` and `vector` rode the same plain-text fallback with an object-shaped value, and are now excluded too. The spec spelling `autonumber` is likewise excluded, where the detail computed gate only knew the `auto_number` spelling. The heavy-editor family (`markdown`, `html`, `richtext`) loses its one-line text box on the detail page — those are authored in the record form, which has the real editors.
+
+ The binary/attachment family is deliberately exempt and keeps its detail editor. It is in the shared set for a grid-cell reason — a cell cannot host an upload dropzone — while `InlineFieldInput` routes `image` / `avatar` / `signature` / `file` (and the `video` / `audio` spellings) to the same upload widgets the record form uses. That exemption is pinned against the routing it claims, so it cannot outlive it.
+
+ Re-authoring a credential is unchanged and still belongs in the record form, which has the widget for it (`PasswordField`).
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- e7663f2: fix(detail): inline edit no longer destroys array values or flattens types on the record page
+
+ `InlineFieldInput`'s type switch ended in a raw text input, and every type it had
+ no branch for landed there: the value was displayed through `coerceToSafeValue`
+ and written back as whatever the user typed — a bare string.
+
+ Two damage classes survived the earlier passes. Array-valued fields (`tags`,
+ `checkboxes`, an options-less multi picklist) were offered for editing as
+ `"a, b"` — `coerceToSafeValue` joins arrays — and saved back as that string, so
+ the array was gone. Type-lossy scalars (`toggle`, `slider`, `progress`,
+ `rating`, `radio`) round-tripped through `String()`, so a boolean column
+ received `"true"`, a numeric one `"42"`, and `radio` accepted any free-typed
+ value its option list never offered.
+
+ Types the switch already routes keep their editors. Everything else that the
+ fields package can edit inline now falls back to `FieldEditWidget` — the same
+ control the form renders, `json` → the code editor included — and only genuinely
+ string-valued types (`text`, `textarea`, `email`, `phone`, `url`) keep the plain
+ input. A drift guard asserts every field type is exactly one of routed /
+ excluded / delegated / benign, so a new type can no longer inherit the
+ value-destroying default in silence.
+
+ `@object-ui/fields`: the four fixed-option widgets no longer clear the stored
+ value when the field declares no `options` at all. An empty offered set had two
+ opposite causes — a list that cascaded to zero (clear) and a list that was never
+ authored (nothing to decide) — and the second deleted the value on mount, which
+ the grid's inline cell editor has always been able to trigger. `FieldEditWidget`
+ also forwards `autoFocus` to the widget it renders.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- 456aac8: `@object-ui/plugin-detail` now declares `react-router-dom` as a peer dependency (`^6.0.0 || ^7.0.0`), the range its three siblings already use.
+
+ It has been importing the router all along — `PermissionFacetLink.tsx` and `record-reference-rail.tsx` both take `Link` and `useParams` from it — while its manifest named it in no field at all. That resolved locally for a reason that does not travel: the workspace root declares `react-router-dom` in its own `devDependencies`, so a `node_modules/react-router-dom` symlink exists at the root of this repository and Node's upward directory walk reaches it from every package directory. A consumer's install has no such root, and this package's rollup config externalises every bare specifier, so the published `dist/index.js` carried an import of a package the manifest never asked for.
+
+ Consumers already installing `@object-ui/app-shell`, `@object-ui/layout` or `@object-ui/plugin-designer` were unaffected — all three declare the same peer — so this closes the case of a consumer that pulls `plugin-detail` on its own.
+
+ A new repository gate, `pnpm check:phantom-deps`, now asserts that every bare specifier a released package imports under `src/` is declared by that package rather than merely resolvable from it, so the next one of these fails on the pull request that introduces it (objectui#4394).
+
+- 7d04b0e: `record:details` stops publishing a `layout` key the spec removed and the renderer never honoured
+
+ `record:details` declared `layout: enum ['auto','custom']` with `defaultValue: 'auto'` and the description "auto uses the object highlightFields; custom uses explicit sections". None of that was ever implemented. The renderer's only `schema.layout` read tested `'inline'` | `'compact'` — two values the schema never permitted — so both legal values fell through the same ternary and the key selected nothing. `auto` and `custom` have behaved identically for as long as both have existed.
+
+ Two directions were wrong with zero diagnostics: `layout: 'auto'` plus explicit `sections` still rendered the sections, and `layout: 'custom'` with no sections silently fell back to the flat body rather than reporting the missing groups. Because the input carried a `defaultValue`, this was not stale documentation — it was the manifest, the generated `sdui-intrinsics.d.ts` and the designer panel actively offering the key. An AI author writing `layout: 'custom'` believed it took effect.
+
+ `@objectstack/spec` 17.0.0 removed the property (objectstack#6946, ADR-0087 D2); `17.0.0-rc.6` is pinned here, so the key is already rejected on parse with a named migration message pointing at `os migrate meta --from 16`. This release completes the objectui half of that retirement: the input declaration is gone, and so is the dead `inline`/`compact` branch — the synthesized layout is now the constant it always resolved to.
+
+ Nothing that worked stops working. The body-source contract is unchanged and is now the only one declared: **`sections` renders the explicit groups; omitting it falls back to the flat body derived from the object's fields.** That is pinned in both directions, plus the empty-array boundary between them, in `recordDetailsBodySource.test.tsx`.
+
+ One gate got sharper on the way through. The parity test's "declares no top-level input the spec does not accept" check read raw `.shape` keys — but an ADR-0087 D2 tombstone stays _in_ the shape as a `z.never()`, so a retired key still answers "is this declared?" with yes. That is precisely why this input survived the rc.6 pin bump with every derived gate green. The check now filters tombstoned members out, so it catches the next D2 retirement instead of waving it through.
+
+- c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
+
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
+
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
+
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
+
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
+
+- 2fea4d2: `detail.showEmptyRelated` renders Russian and Arabic again — the "+N empty" button no longer falls through to English at the counts it takes most often
+
+ This was the repo's only pre-existing i18next plural family, and all ten packs defined exactly two slots: `_one` and `_other`. i18next asks `Intl.PluralRules` for the one suffix a language needs for that number, and when the pack has no such slot it walks `fallbackLng` to `en`. Russian has four plural categories and Arabic six, so `ru` at counts 2-4 (`few`) and 5-20, 25-30, … (`many`), and `ar` at 0, 2, 3-10 and 11-99, resolved nothing locally and rendered the English string. The call site is the collapsed-empties button in the record detail's reference rail, whose count is the number of empty related lists — 2 to 4 are the most common values it ever takes, so a Russian user essentially always read English.
+
+ The fix is a base key (no suffix) beside the two existing slots, in all ten packs. The base key is always in i18next's lookup chain, so every category a pack did not enumerate resolves to it, in that pack's own language — and, unlike adding `_few`/`_many` to `ru` alone, it keeps the ten packs' key sets identical, which full key parity requires. Same shape objectui#3546 slice six established for `perm.facet.*`. Where the base key is genuinely reachable it carries a count-invariant phrasing: `ru` uses the «Существительное: {{count}}» form the pack already writes 22 times, `ar` the «{{count}} مفرد(جمع)» marker it uses throughout. For `en`/`de`/`zh`/`ja`/`ko` the base key cannot be reached at all (their categories are covered by the two existing slots) and repeats `_other` for parity; `fr`/`es`/`pt` reach it only from a million up, where the plural form is already correct. No English copy moves.
+
+ The provider-less path needed the same row for a different reason: `createSafeTranslation`'s fallback resolves `defaults[key]` literally and never appends a plural suffix, so the two suffixed rows in plugin-detail's defaults table were unreachable through it and that path answered with the raw key. It now carries the base key too.
+
+ Parity across packs turned out to be necessary and not sufficient — ten identical key sets were green throughout, because the defect is one level below key names: the slot the language needs is not in the set. So the invariant "a plural family must carry a base key" is now asserted over all ten packs in `all-locales-key-parity.test.ts`, where it is pack-intrinsic and fails at PR time without needing a call site to exist. It went red on all ten packs before this change and names the family that is missing its base.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
+
+ Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
+
+- 35997ce: fix(plugin-detail): synthesize page components in the spec's `properties` carrier so Studio page-create can persist
+
+ Creating a page in Studio never completed. The create path seeds a record
+ page's `regions` from `buildDefaultPageSchema(objectDef)` and PUTs the result,
+ and every node that synthesizer emitted carried its widget props at the TOP
+ level of the component — `{ type: 'page:header', recordChrome: true }`,
+ `{ type: 'page:tabs', items: [...] }`, and the same for `record:highlights`,
+ `record:path`, `record:details`, `record:related_list`, `record:history` and
+ `record:reference_rail`. ADR-0089 D3a closed `PageComponentSchema` with
+ `.strict()`, so those keys are not stripped, they are a parse error
+ (`Unrecognized key(s) on this view/page schema: 'recordChrome', 'actions'`).
+ The server refused the body and no page row was ever stored.
+
+ The props now go where the spec declares them — the node's `properties` bag,
+ which is where `ComponentPropsMap` defines `page:header.recordChrome` and
+ `page:tabs.items` in the first place. Nothing is dropped and nothing changes on
+ screen: a header still defaults to record chrome ON, an author's
+ `recordChrome: false` is still carried (and now actually persists), the tabs
+ keep their items, and `SchemaRenderer` hoists `properties` back onto the node
+ before dispatch, so every renderer receives exactly the props it did before.
+
+ One code path does the wrapping for every node the synthesizer builds, so there
+ is a single answer to "what may go in a page write". Slot overrides are
+ untouched — a node handed in by a caller is still placed verbatim.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [734d186]
+- Updated dependencies [f7c6430]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [78fa331]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-detail/package.json b/packages/plugin-detail/package.json
index 47be391cd9..9bb73c969b 100644
--- a/packages/plugin-detail/package.json
+++ b/packages/plugin-detail/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-detail",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "DetailView plugin for Object UI - comprehensive detail page with sections, tabs, and related lists",
diff --git a/packages/plugin-editor/CHANGELOG.md b/packages/plugin-editor/CHANGELOG.md
index c6b179977f..b81f36864e 100644
--- a/packages/plugin-editor/CHANGELOG.md
+++ b/packages/plugin-editor/CHANGELOG.md
@@ -1,5 +1,49 @@
# @object-ui/plugin-editor
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-editor/package.json b/packages/plugin-editor/package.json
index d986f21aa1..2713b7d92e 100644
--- a/packages/plugin-editor/package.json
+++ b/packages/plugin-editor/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-editor",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Rich text editor plugin for Object UI, powered by Monaco Editor",
diff --git a/packages/plugin-form/CHANGELOG.md b/packages/plugin-form/CHANGELOG.md
index ca50027fd6..bd078334b0 100644
--- a/packages/plugin-form/CHANGELOG.md
+++ b/packages/plugin-form/CHANGELOG.md
@@ -1,5 +1,85 @@
# @object-ui/plugin-form
+## 17.5.0
+
+### Patch Changes
+
+- c32a8a1: `richtext` fields are placed like the long-form fields they are — four layout sets stopped spelling the type three ways the spec rejects
+
+ `@objectstack/spec` spells the WYSIWYG type `richtext`, one word, and **rejects** `rich_text` and `rich-text`: both exist only as typo keys in the spec's own `suggestFieldType` table, so `FieldSchema` refuses a field declared with either. Four sets that place fields by matching the RAW type string carried nothing else — `SKIP_TYPES` in the related list spelled it `rich_text`, both `WIDE_FIELD_TYPES` and `SECONDARY_FIELD_TYPES` spelled it `rich-text` — so each set was inert for the only spelling a producer can emit, and every one of them named the type it was failing to handle.
+
+ For a real `richtext` field that meant: it was auto-derived into a related-list column, it never spanned the full row in a multi-column detail section or form (unlike `markdown` and `html` sitting right beside it in the same sets), and it stayed in the dense primary section of the record page instead of dropping into "More details". All four move together — half of them would have left the detail page and the form disagreeing about the same field, which is worse than the uniform gap.
+
+ The dead spellings are dropped rather than kept alongside the live one: the alias table is the single place aliases belong, and a set that carries both invites the next drift. The pins are derived from the spec's own `FieldType` vocabulary instead of enumerated, so a member that stops being a real type name fails by name — replacing an assertion that was green only because the set contained the string it asked about.
+
+ `markdown` joins `richtext` and `html` in the related list's `SKIP_TYPES`, on a measurement rather than on the assumption that it renders raw. It does not: markdown and richtext both render through `MarkdownCellRenderer`, formatted and sanitized. The reason none of the three works in a table is that the formatted output is block-level — a heading, paragraphs, a list — inside a single-line truncating cell, so a document shows as one clipped heading with the rest invisible. `textarea` stays derived for the same reason read the other way: it renders as plain truncated text, which is a useful column. Author-declared columns are untouched — this set only filters the zero-config auto-derive walk.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/permissions@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-form/package.json b/packages/plugin-form/package.json
index 6613a8b8df..85181131f1 100644
--- a/packages/plugin-form/package.json
+++ b/packages/plugin-form/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-form",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Form plugin for Object UI",
diff --git a/packages/plugin-gantt/CHANGELOG.md b/packages/plugin-gantt/CHANGELOG.md
index 05306688ff..9547a21ee9 100644
--- a/packages/plugin-gantt/CHANGELOG.md
+++ b/packages/plugin-gantt/CHANGELOG.md
@@ -1,5 +1,127 @@
# @object-ui/plugin-gantt
+## 17.5.0
+
+### Patch Changes
+
+- 828549a: The gantt's conflict dialog shows the number of affected tasks again, not a literal `{2}`
+
+ `gantt.conflict.body` was resolved at the render site with a literal string replace on **single** braces — `t('gantt.conflict.body').replace('{count}', String(n))` — while all ten locale packs spell the placeholder the i18next way, `{{count}}`. `"…{{count}}…".replace("{count}", "2")` consumes the inner seven characters and leaves the outer pair behind, so every user on every loaded pack read "自动重新排程 **{2}** 个受影响的任务?". The dialog now interpolates through i18next (`t('gantt.conflict.body', { count })`), the idiom `gantt.delete.body` already used.
+
+ The two sibling keys three lines away in the same file, `gantt.autoScheduleDlg.body` and `.skipped`, were **not** broken — pack and call site both used single braces, and they rendered correctly. They are converted anyway, because that split is the whole mechanism: two write-confirmation dialogs in one component carried two different interpolation idioms, so `conflict.body` drifting to the i18next spelling in the packs (which is the correct spelling, and matches every other placeholder in the bundle) silently broke the render. Leaving the auto-schedule keys on the literal-replace idiom leaves the same trap armed for the next translator. All ten packs and the plugin's bundled English fallback table now agree on `{{count}}` for all three; only the braces moved, no translation was reworded.
+
+ `gantt.quickFilter.resultSummary` stays deliberately single-brace — its `ObjectGantt` call site really does resolve `{shown}`/`{total}` with a literal replace, and that convention is pinned by its own parity test. It is now the only key in the gantt namespace on that idiom, and the comments at both spellings say so.
+
+ Nothing caught this, and each gate was silent for its own reason: the cross-pack parity check compares en against each pack, and all eleven spellings agreed; the en-drift check compares a pack against its own history, and the packs were born matching. Both are **relative** comparisons, and the defect lived in the **absolute** relationship between a pack's spelling and the syntax the call site resolves. The existing render test asserted the dialog body contains `'1'` — which `{1}` satisfies. The new pin asserts the absolute form directly, under a real loaded pack, for every way a placeholder can survive to the screen.
+
+- e1ade8f: An illegal gantt dependency link now says why it was refused, instead of doing nothing
+
+ Dragging a dependency onto a target the gantt refuses — itself, a locked row, a group row, or one that would close a dependency cycle — produced no feedback of any kind: no toast, no dialog, no cursor change, no target outline, not even a console warning. The guard was right and completely invisible, so a user drawing a legitimate-looking dependency got a dead interaction and no way to learn the constraint. The rejection was silent in both places it could have shown: a refused bar never became the drop target, so it got no hover treatment at all, and the release handler only ran its body when a target _had_ been registered, so the drop itself was a no-op.
+
+ Both halves are now wired, and both read the **same** verdict. `canReceiveLink`'s four-branch boolean became `classifyLinkTarget`, which returns which branch refused (or `null`), with the boolean derived from it. The hover affordance and the drop toast are two consumers of that one classification, so the reason a user is shown cannot drift from the reason the link was actually refused — there is no second classifier to disagree. The branch names are the leaves of the new `gantt.link.rejected.*` keys, so a branch added later without a message surfaces as a missing key rather than as a plausible-but-wrong sentence.
+
+ During the drag, a refused bar under the pointer gets `cursor: not-allowed` and a destructive outline; on release it raises a toast naming the reason. Four messages, one per branch, in all ten packs. Both the cursor and the outline are driven from inline `style` rather than utility classes, matching the bar's existing read-only cursor three lines away and for the same reason recorded there: `cursor-not-allowed` and the ring alpha utilities are not emitted in the prebuilt components CSS, so a class would look correct in a DOM test and render nothing in a browser.
+
+ Deliberately unchanged: a host veto through `onBeforeDependencyCreate` stays silent. That rejection carries a reason only the host knows, and the gantt has none to show — surfacing it means exposing a rejection-reason output on the public component, which is a separate contract rather than a rider on this one. The four built-in reasons are the gantt's own policy and are the only ones it can explain.
+
+ One of the four, `group`, has no end-to-end path today: a `type: 'group'` row renders no bar, so the drag can never target it. The message is kept anyway — without it the branch would render a raw key on screen if it ever did fire — and the test pins the reachability fact, so it goes red the day group rows gain a bar. Filed as objectui#4209.
+
+- 0ca6096: A gantt task titled `A$&B` no longer prints `{{title}}` back into its own delete dialog — the two hand-rolled provider-less fallback interpolators are literal, like i18next
+
+ objectui#3418 fixed the shared helper's fallback interpolator: `String.prototype.replace` became `split(needle).join(value)`, because `replace` and `replaceAll` both interpret `$&`, `` $` ``, `$'` and `$$` in the **replacement** string and i18next does not. Two hand-rolled copies of that interpolator never got the fix. Both are deliberate non-users of `createSafeTranslation` — each falls back per key so a host dictionary that covers the common keys but lags on newer ones still resolves what it has — so the shared fix had no path to reach them.
+
+ The reachable one is gantt's. `gantt.delete.body` is `'"{{title}}" will be permanently removed. …'` and its call site interpolates the record's own title, which is user data:
+
+ | task title | rendered before | rendered now |
+ | ---------- | --------------------------------------------------------------------- | ----------------------------------------- |
+ | `A$&B` | `"A{{title}}B" will be permanently removed.` | `"A$&B" will be permanently removed.` |
+ | `` x$`y `` | `"x"y" will be permanently removed.` | `` "x$`y" will be permanently removed. `` |
+ | `p$$q` | `"p$q" will be permanently removed.` | `"p$$q" will be permanently removed.` |
+ | `u$'v` | `"u" will be permanently removed. …v" will be permanently removed. …` | `"u$'v" will be permanently removed.` |
+
+ The first row is the ugly one: `$&` expands to the matched text, so the placeholder itself is printed back to the user inside the record's own name. Gantt's copy also carried the other half of the same defect — a bare string needle substitutes only the **first** occurrence, where i18next substitutes every one — and `split`/`join` fixes both at once.
+
+ The import wizard's copy used a `g`-flagged `RegExp`, which covered the repeated-placeholder half but could not touch the `$`-pattern half: that harm lives in the replacement string, not the needle. Its values are authored metadata — field labels and type names spliced into `grid.import.missingRequiredHint` and `grid.import.legacyReferenceBlocked` — so a label containing `$&` corrupted the hint the same way. Retiring the `RegExp` also retires an unescaped needle, since the placeholder name went into the pattern uninterpolated; that was inert while every placeholder name is a bare identifier, and is now structurally impossible.
+
+ This is the provider-less path only (standalone embedding, unit tests). With an `I18nProvider` mounted, i18next serves these keys and was already literal on both sides — which is exactly why the divergence was invisible. No pack, key or call site changed; the three `{{count}}` gantt keys take numbers and were never affected, and `gantt.quickFilter.resultSummary`'s deliberate single-brace idiom is resolved by its call site rather than this interpolator and is untouched.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [6d01319]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-gantt/package.json b/packages/plugin-gantt/package.json
index f8de33d464..5fd6aee524 100644
--- a/packages/plugin-gantt/package.json
+++ b/packages/plugin-gantt/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-gantt",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Gantt chart plugin for Object UI",
diff --git a/packages/plugin-grid/CHANGELOG.md b/packages/plugin-grid/CHANGELOG.md
index c36c7c5735..5004a259ab 100644
--- a/packages/plugin-grid/CHANGELOG.md
+++ b/packages/plugin-grid/CHANGELOG.md
@@ -1,5 +1,154 @@
# @object-ui/plugin-grid
+## 17.5.0
+
+### Minor Changes
+
+- 24bb2de: grid row menu — the built-in Edit/Delete predicate declarations are derived from the spec-owned authoring type, not hand-restated
+
+ `packages/plugin-grid/src/components/RowActionMenu.tsx` carried its own `BuiltinRowActionPredicates` interface (`{ visibleWhen?: unknown; disabledWhen?: unknown }`) and read it at six declaration sites: both `RowActionMenuProps` predicate props, the shared `isBuiltinRowActionVisible` gate, both `planRowActionMenu` parameters, and the `BuiltinRowActionItem` component. Nothing tied any of them to the type whose values they receive, so a rename at the source would have left every one compiling against a shape that no longer existed — the objectui#3009 hand-copy family, and the mirror of what PR #4423 collapsed in the data-table.
+
+ **Measured true source.** These predicates do NOT flow from `DataTableSchema.rowEditPredicates` / `rowDeletePredicates` — this surface is never handed those keys. `ObjectGrid` resolves the object's `userActions.edit` / `delete` through `resolveRowCrudAffordances`, which returns `CrudAffordances['editPredicates']` / `['deletePredicates']`: the spec-owned `RowCrudPredicates` (ADR-0103, `@objectstack/spec/data`), parsed in exactly one place and re-exported by `@object-ui/core`. Each site now derives from that — per-key `Pick` for the planner (visibility is all it decides), one union alias for the two consumers that serve both built-ins. Measured: with `visibleWhen` renamed at the source, the previous hand-written declarations produce ZERO diagnostics in this package while the derived ones fail to compile at the declarations themselves.
+
+ **Graded `minor` rather than `patch`** because a published type narrows (the objectui#4403 criterion). `RowActionMenuProps.editPredicates` / `deletePredicates` move from `unknown`-valued keys to the spec's `Expression | ExpressionInput` — the authored CEL shorthand or its `{ dialect, source }` envelope — so a consumer passing an `unknown`-typed value, or a bare boolean, stops type-checking. No runtime consumer breaks and no behavior changes; `@object-ui/core` retired the same `unknown` imprecision at its own seam, and this was the last copy of it. (PR #4423's data-table twin stayed `patch` because `DataTableSchema`'s keys were already declared `unknown` — deriving there narrowed nothing.)
+
+ No runtime code was changed, and the package's suite passes unchanged. Alongside it, the "a disabled item still counts toward the menu" rule gains the pin it never had where a user meets it: a row whose only action is `disabledWhen`-gated keeps its "⋮" trigger, and that trigger opens the item, present and `aria-disabled`. The two halves of that rule live in different functions, and each half's own test stayed green while the other regressed. The planner-level case that claimed to pin this is renamed to the verdict it actually decides — the planner never reads `disabledWhen`, so its fixture behaved identically to `{}`.
+
+- 51ac39f: ObjectGrid's host-driven pagination mode is a declared interface instead of twelve `(rest as any)` reads
+
+ `ObjectGridProps` declared twelve members while the component read twelve more out of `...rest`, each through an `as any` cast: `data`, `manualPagination`, `rowCount`, `page`, `pageSize`, `onPageChange`, `onPageSizeChange`, `sort`, `onSortChange`, `search`, `onSearchChange` and `onColumnStateChange`. They are not accidental — together they are the host-driven external-pagination path from framework#2212, where a host has already fetched one window of a larger collection and drives the page/sort/search controls itself, and the component's own comment said so. They were simply declared nowhere, so no call site could be checked against them and no editor could offer them.
+
+ Nothing had caught it because the only untyped caller is `ObjectGridRenderer`, whose `{ schema: any; [key: string]: any }` index signature accepts anything; every typed caller happens to pass only declared props; and the test that exercises the path was compiled by nothing.
+
+ They now live on a named `ObjectGridExternalPaginationProps`, which `ObjectGridProps` extends — a separate interface rather than twelve more members flattened into the authoring surface, so the "advanced host-driven mode" boundary stays visible. The eleven members that already have a counterpart on `DataTableSchema` — the type ObjectGrid forwards them to — are **type-derived** from that declaration (`Partial< Pick< DataTableSchema, … > >`) rather than hand-copied, so the two cannot drift apart; only `onColumnStateChange` is declared explicitly, because the table vocabulary reports per-event `onColumnResize` / `onColumnReorder` rather than the merged `{ order, widths }` layout this reports. `ObjectGridColumnState` is exported for that payload.
+
+ Purely additive for callers: every member is optional, so existing code compiles unchanged, and hosts that were already passing these props now get them checked instead of silently accepted. Runtime behavior is unchanged.
+
+### Patch Changes
+
+- 51ab34e: ObjectGrid's bulk-bar **Clear** now unticks the row checkboxes, instead of only removing the toolbar
+
+ Selecting rows and pressing Clear emptied the bulk-actions bar but left every row checkbox at `data-state="checked"` (the header checkbox stuck at `indeterminate` on a partial pick). The user was stranded on a page of ticked rows with no toolbar left to act on them, and the only way out was a reload or re-selecting and clearing through some other path.
+
+ The selection lives in two places: `selectedRows`, which is the grid's own state and drives the toolbar, and the row checkboxes, which live inside the embedded data-table and only clear when `selectionResetKey` moves. `resetSelection()` writes all three, and the delete / dispatch / dialog-close paths have gone through it since the reset-key mechanism was introduced. Both `BulkActionBar` mount sites, however, hand-wrote their `onClearSelection` as `setSelectedRows([]); setSelectAllMatching(false);` — exactly `resetSelection()` minus the key bump — so Clear updated one source and left the other ticked. Both sites now call `resetSelection()`, so there is one reset for every path that clears a selection rather than three hand-copied ones, and the cross-page "all matching" state drops with it.
+
+- 0ca6096: A gantt task titled `A$&B` no longer prints `{{title}}` back into its own delete dialog — the two hand-rolled provider-less fallback interpolators are literal, like i18next
+
+ objectui#3418 fixed the shared helper's fallback interpolator: `String.prototype.replace` became `split(needle).join(value)`, because `replace` and `replaceAll` both interpret `$&`, `` $` ``, `$'` and `$$` in the **replacement** string and i18next does not. Two hand-rolled copies of that interpolator never got the fix. Both are deliberate non-users of `createSafeTranslation` — each falls back per key so a host dictionary that covers the common keys but lags on newer ones still resolves what it has — so the shared fix had no path to reach them.
+
+ The reachable one is gantt's. `gantt.delete.body` is `'"{{title}}" will be permanently removed. …'` and its call site interpolates the record's own title, which is user data:
+
+ | task title | rendered before | rendered now |
+ | ---------- | --------------------------------------------------------------------- | ----------------------------------------- |
+ | `A$&B` | `"A{{title}}B" will be permanently removed.` | `"A$&B" will be permanently removed.` |
+ | `` x$`y `` | `"x"y" will be permanently removed.` | `` "x$`y" will be permanently removed. `` |
+ | `p$$q` | `"p$q" will be permanently removed.` | `"p$$q" will be permanently removed.` |
+ | `u$'v` | `"u" will be permanently removed. …v" will be permanently removed. …` | `"u$'v" will be permanently removed.` |
+
+ The first row is the ugly one: `$&` expands to the matched text, so the placeholder itself is printed back to the user inside the record's own name. Gantt's copy also carried the other half of the same defect — a bare string needle substitutes only the **first** occurrence, where i18next substitutes every one — and `split`/`join` fixes both at once.
+
+ The import wizard's copy used a `g`-flagged `RegExp`, which covered the repeated-placeholder half but could not touch the `$`-pattern half: that harm lives in the replacement string, not the needle. Its values are authored metadata — field labels and type names spliced into `grid.import.missingRequiredHint` and `grid.import.legacyReferenceBlocked` — so a label containing `$&` corrupted the hint the same way. Retiring the `RegExp` also retires an unescaped needle, since the placeholder name went into the pattern uninterpolated; that was inert while every placeholder name is a bare identifier, and is now structurally impossible.
+
+ This is the provider-less path only (standalone embedding, unit tests). With an `I18nProvider` mounted, i18next serves these keys and was already literal on both sides — which is exactly why the divergence was invisible. No pack, key or call site changed; the three `{{count}}` gantt keys take numbers and were never affected, and `gantt.quickFilter.resultSummary`'s deliberate single-brace idiom is resolved by its call site rather than this interpolator and is untouched.
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 5e514c4: standalone ObjectGrid resolves off-spec `rowHeight` to compact, matching ListView and the spec bridge, instead of silently styling it as medium
+
+ One component answered one question two ways. `ObjectGrid` seeded its density state with `schema.rowHeight ?? 'compact'`, so an ABSENT `rowHeight` landed on `compact` while an OFF-SPEC one skipped every arm of the density ternaries and came out at their terminal `else` — the `medium` styling. That is the absent-vs-off-spec split objectui#4440 removed from `ListView`, and it made a standalone grid the third answer to a question the rest of the system had already settled: `@object-ui/core`'s `rowHeightToDensityMode` abstains for an off-spec value, the `@object-ui/react` spec bridge abstains, and `ListView` defaults the abstention to `compact`. Off-spec now renders exactly like absent, everywhere.
+
+ Only a standalone grid was affected. When `ListView` owns the grid it overwrites the prop with a value derived from `density.mode`, so nothing off-spec survives that hop.
+
+ The narrowing happens at the state boundary, not in the ternaries. `medium` is still a real row height with its own styling arm, and a leaf renderer's terminal `else` is still legitimate styling — what changes is that nothing unrecognized can reach it. Membership is tested against `ROW_HEIGHT_TO_DENSITY_MODE`, so the admitted values keep one definition in the repo and the build fails if the spec grows a sixth row height without teaching the resolver about it. Both entry points go through the resolver: the initial state and the effect that re-syncs when the `rowHeight` prop changes.
+
+ Two off-spec spellings behaved differently before this, which the report of the defect did not distinguish, and the boundary fix covers both:
+
+ - A plain off-spec value (`'garbage'`) was not a key of the toolbar's row-height icon map either. That map is looked up by the same unvalidated state, so `rowHeightIcons[mode]` was `undefined` and rendering ` ` threw `Element type is invalid` — a standalone grid with an off-spec `rowHeight` did not render at all, rather than rendering as `medium`. The toolbar is shown precisely when `schema.rowHeight` is defined, so the crash and the off-spec case coincide exactly.
+ - A prototype member (`'toString'`) WAS reachable through that map's prototype chain, resolving to `Object.prototype.toString` — a function, which React accepts as a component — so it survived to the ternaries and rendered as `medium`, the defect as filed. The resolver uses `hasOwnProperty` rather than `in` for this reason, the same reason `@object-ui/core` does.
+
+ Both are now inert: the state can only ever hold one of the five admitted row heights, so the icon lookup is total and the ternaries never fall through.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/permissions@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-grid/package.json b/packages/plugin-grid/package.json
index 522cb5407e..264bb227c4 100644
--- a/packages/plugin-grid/package.json
+++ b/packages/plugin-grid/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-grid",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Grid plugin for Object UI",
diff --git a/packages/plugin-kanban/CHANGELOG.md b/packages/plugin-kanban/CHANGELOG.md
index 52b809bca4..2151de97bb 100644
--- a/packages/plugin-kanban/CHANGELOG.md
+++ b/packages/plugin-kanban/CHANGELOG.md
@@ -1,5 +1,123 @@
# @object-ui/plugin-kanban
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- 3e19fe7: i18n copy: one ellipsis glyph across the ten packs, `usted` in the es draft-preview empty state, and a pt sentence that stops contracting `de` onto its own hole
+
+ Three locale-copy defects that no gate could see, because all three are _value_ defects on keys whose names, placeholders and key sets were already correct.
+
+ **One ellipsis (objectui#3878).** `en` ended 33 values with three ASCII full stops (`Loading...`, `Ask anything...`) and 110 with the typographic ellipsis `…`, and the nine translation packs had copied `en` value by value — so a user could read both glyphs on one screen: `common.loading` beside `dashboard.loading`, `console.ai.askAnything` beside its own panel's siblings. All ten packs now spell it `…` (U+2026), per the maintainer-authorized consistency pass registered on objectstack#6015. 312 pack values changed: 34 in `en` (the 33 trailing plus the one mid-sentence `collaboration.commentPlaceholder`) and 278 across the nine. Eleven inline `defaultValue` call sites were re-synchronised with the new `en` text, which `scripts/check-i18n-call-site-keys.mjs` requires byte-for-byte.
+
+ The convention is now pinned so the split cannot regrow: `packages/i18n/src/__tests__/ellipsis-glyph-3878.test.ts` fails, by key name, on any value in any of the ten packs that holds three ASCII full stops. It is deliberately wider than "a trailing `...` in `en`", because the census showed the narrow rule would have shipped with two holes in it — `collaboration.commentPlaceholder` puts the ellipsis mid-sentence, and `list.loading` had the packs wrong while `en` was already right, which no `en`-only rule can see.
+
+ Fifteen module-local **no-provider fallback** entries were moved with the packs, across `useCollaborationTranslation`, `useFieldTranslation`, `useDetailTranslation`, `ObjectGrid`, `KanbanImpl`, `data-table` and `ConnectionStatus`. Those maps exist to render when no `LocalizationProvider` is mounted, and each one's own docblock requires it to stay byte-identical to the `en` pack — a requirement objectui#3440 already enforces mechanically for the collaboration map. Leaving them behind would have made the provider-less path disagree with the provider path on ten keys.
+
+ **es `usted` (objectui#3875).** `preview.empty.notReadyDescription` said `Revisa la conversación` — the tú imperative — in a namespace that is otherwise 23:1 usted, and it renders _underneath the usted draft-preview banner at the same moment_, not before or after it. `Revisa` → `Revise`; nothing else in the sentence carries a register. The neighbouring `approvalsInbox` namespace is legitimately tú and was left alone.
+
+ **pt contraction (objectui#3877).** `ConcurrentUpdateDialog` splits `detail.concurrentUpdateDescription` on `{{field}}` and renders a bolded label in the gap, and pt left a bare `de` in front of that gap. When the multi-field conflict branch passes the record label (`este registro`), Portuguese users read `de este registro` — a contraction error every native speaker sees, and one that no spelling of the leaf value could fix (`deste registro` renders `de deste registro`). The pt sentence is rewritten so the hole is preceded by the verb `afeta` instead of any preposition, which closes the whole class rather than trading `de` for an `em` or `a` that contract just as hard. pt only; `en` is unchanged.
+
+ No behavior, no keys added or removed, no placeholder changed.
+
+- 2c8ad7c: A rejected Kanban drag rolls the card back on both data ownerships, not just when the board owns its own records
+
+ Dragging a card into a column the server refuses (`PATCH` 400 `invalid_transition`) left the card sitting in the target column until a manual reload, whenever the board was hosted by a parent that supplies records through the `data` prop — the ListView/console path, which is the one real users meet. The toast fired and the server value was untouched, so the board was showing a move that had not happened.
+
+ `handleCardMove` performed its failure revert only inside `if (!hasExternalData)`. The reasoning recorded next to it was that the parent handles the refresh, and for an accepted move it does — the parent's mutation subscription refetches and the new value propagates. A _rejected_ move changes nothing server-side, so no refetch is ever triggered and nothing un-said the optimistic move.
+
+ The revert is now unconditional, which is also what makes it a single code path rather than two. The card's on-screen position does not live in `ObjectKanban` at all: the board component moves the card inside its own column state before reporting the move upward, and re-syncs that state from its `columns` prop whenever the prop's identity changes — which any re-render of `ObjectKanban` produces, since the renderer re-buckets the records into fresh column arrays. On the internal path the revert corrects the record and re-renders; on the external path it re-renders against the parent's records, which the server never changed, and the re-bucket puts the card back where it started.
+
+ The optimistic write on the way _in_ stays gated on internal data deliberately, and the asymmetry is now pinned by tests: writing it on the external path would re-render against the unchanged parent records and snap an accepted move back before the server had answered. Accepted moves on both paths, and the existing rejection toast, are covered by controls alongside the regression test.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [6d01319]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [63fe8fd]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [6314e87]
+- Updated dependencies [5e2e9fa]
+- Updated dependencies [297534b]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [e076fd5]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [456aac8]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [7d04b0e]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [dad805d]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [35997ce]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/plugin-detail@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-kanban/package.json b/packages/plugin-kanban/package.json
index 10bd7f9f1b..233c47b84c 100644
--- a/packages/plugin-kanban/package.json
+++ b/packages/plugin-kanban/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-kanban",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Kanban board plugin for Object UI, powered by dnd-kit",
diff --git a/packages/plugin-list/CHANGELOG.md b/packages/plugin-list/CHANGELOG.md
index 20faebf550..127211a39f 100644
--- a/packages/plugin-list/CHANGELOG.md
+++ b/packages/plugin-list/CHANGELOG.md
@@ -1,5 +1,221 @@
# @object-ui/plugin-list
+## 17.5.0
+
+### Minor Changes
+
+- fe52a04: `rowHeightToDensityMode` answers only for the five spec row heights — the coerce-to-`comfortable` fallback is gone
+
+ Two surfaces narrow a list view's `rowHeight` onto the renderer's three-step
+ density vocabulary, and since objectui#4352 they answered differently for the
+ same off-spec input: `@object-ui/react`'s spec bridge declined to answer, while
+ `@object-ui/core`'s `rowHeightToDensityMode` rehabilitated anything unknown into
+ `comfortable`. One metadata-driven system, two answers for one input
+ (objectui#4440).
+
+ The strict answer wins, per AGENTS.md #0.1: a renderer-side rehabilitation of
+ off-spec metadata is a second de-facto contract, and one strict contract beats N
+ dialects — a bad `rowHeight` gets fixed at the producer, where the schema already
+ rejects it. The five mappings themselves are untouched (`compact`/`short` →
+ `compact`, `medium` → `comfortable`, `tall`/`extra_tall` → `spacious`), and the
+ table keeps its `Record< RowHeight, … >` typing, so a row height added upstream
+ still fails the build here.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Two things change:
+
+ - **Published type.** `rowHeightToDensityMode` is exported from
+ `@object-ui/core`, and its return widens from `DensityMode` to
+ `DensityMode | undefined`. A host assigning the result straight into a
+ `DensityMode` now has to say what an off-spec row height should mean to it.
+ - **Rendered output, for input the spec already rejects.** `ListView` — the one
+ in-repo caller — used to render an off-spec `rowHeight` one step looser than an
+ ABSENT one (`comfortable`, 40px rows, vs `compact`, 32px). It now renders it
+ exactly like an absent one, `compact`, which is also `ObjectGrid`'s own default.
+ A sweep of this repo, the `objectstack` example apps and one downstream app
+ found zero authored off-spec values, and the legacy `densityMode` alias cannot
+ produce one (`DENSITY_MODE_TO_ROW_HEIGHT` is typed
+ `Record< DensityMode, RowHeight >`).
+
+ Also closed while retiring the branch: the lookup guarded membership with `in`,
+ which walks the prototype chain, so `rowHeight: 'toString'` returned
+ `Object.prototype.toString` — a function — from something typed `DensityMode`. It
+ is an own-property check now.
+
+### Patch Changes
+
+- bb58d1d: i18n: the two search placeholders become pack values, and four values the packs served in English get translated
+
+ **objectui#4375** — `ListView` and `LookupField` built their search placeholder as
+ `t(key) + '...'`, so the ellipsis was a literal concatenated in code: it stayed ASCII
+ in all ten locales on screens where objectui#3878 had converged everything else on
+ U+2026, and no pack could opt out of it (sharpest in `ar`, where a left-to-right run
+ was appended to right-to-left text). Both now read `table.search`, which is already
+ the repo's search-input placeholder key — `data-table`, `RecordPickerDialog` and
+ `PeoplePicker` render it too — and is translated with the right ellipsis in all ten
+ packs. No new keys.
+
+ **objectui#4376** — `list.loading` served the English `Loading records…` in eight of
+ the nine translation packs (`zh` alone had translated it); `designer.undo` and
+ `designer.redo` were English in all nine; `appDesigner.snakeCaseHint` in `ko`, `pt`,
+ `ru` and `ar`. All translated, reusing each pack's own established vocabulary. A new
+ pin (`untranslated-identity-4376.test.ts`) fails on any value byte-identical to `en`
+ inside a non-Latin pack unless the key is on an explicit 22-entry allowlist.
+
+- bb68488: An inline per-locale label now renders its locale's string at the thirteen read sites the `@objectstack/spec` 17.0.0-rc.6 bump exposed
+
+ rc.6 widened `I18nLabel` from `string` to `string | Record`, so an author may write `label: { en: 'Owner', 'zh-CN': '负责人' }` anywhere the spec accepts a display label. PR #4169 repaired eight such sites; these thirteen were invisible to it because the five packages involved build through vite/rolldown, so `turbo run build` never type-checks their sources — only `turbo run type-check` does. All thirteen are now resolved through a shared resolver against a real locale, and `turbo run type-check` is 78/78 with zero errors.
+
+ | package | what an author can now write and see |
+ | ----------------------------- | --------------------------------------------------------------------------------------- |
+ | `@object-ui/layout` | `NavigationArea.label` — the sidebar area switcher's button and its tooltip |
+ | `@object-ui/plugin-list` | `ViewTab.label` — the inline pill row, and the mobile dropdown's trigger and menu items |
+ | `@object-ui/plugin-dashboard` | `DashboardWidget.title` — the widget card heading and its `title` attribute |
+ | `@object-ui/plugin-designer` | `DashboardWidget.title` — the widget card and the preview tile |
+ | `@object-ui/app-shell` | `ActionParam.label` **and** each `ActionParam.options[].label` |
+
+ **Patch, not minor, in every case: no public surface changes meaning.** Every entry above is a read site that previously could only be reached with a value the type system rejected, so no caller's working code changes behaviour. `@object-ui/app-shell` is the only package with an exported-type change and it is purely additive on the authoring side — `RawActionParam.label` and `RawActionParam.options[].label` widen to `I18nLabel` (they accept strictly more), `ResolveActionParamsContext` gains an optional `locale`, and the new `RawActionParamOption` names the authoring shape that was previously spelled with the resolved one. What `resolveActionParams` **emits** is unchanged: `ActionParamDef.label` and its options' labels are still plain `string`s.
+
+ Two consequences worth knowing:
+
+ - **The dashboard designer's title input is deliberately read-only for a map-valued title.** Resolving a per-locale map into a single-line input and writing `e.target.value` back would collapse every other locale on the first keystroke, so the write is guarded and an inline map survives an unrelated edit-and-save round trip untouched — the same conservative branch #4169 took for `DashboardWidgetInspector`. What Studio should actually offer for authoring a per-locale label is objectui#4163 part 2, which is unclaimed and pending design.
+ - **`@object-ui/layout` resolves at the spec's `en` default, not the viewer's language.** That package carries no i18n dependency by design (its whole i18n story is injection), and `AppSchemaRendererProps` exposes no locale to thread. The choice and what would change it are documented at the call site.
+
+- 297534b: Align 43 inline `defaultValue` strings with the `en` pack, and make the call-site gate enforce it (objectui#3810)
+
+ `t(key, { defaultValue: 'English text' })` only renders that text when i18next
+ **misses** the key. Where the key exists in `packages/i18n/src/locales/en.ts` the
+ pack value always wins, so the inline string is dead code — and 43 of those dead
+ strings said something different from the sentence users actually read.
+
+ `scripts/check-i18n-call-site-keys.mjs` (objectui#3530) now compares the two
+ whenever a call site carries a literal `defaultValue` for a key `en` defines, and
+ fails on any byte of difference. It is a hard rule with **no baseline**: the
+ repo-wide census measured 43 sites in 19 files out of 851 literal inline defaults,
+ and all 43 are aligned here, so there is no debt for a ratchet to hold. A
+ `defaultValue` on a key that is _not_ yet in `en` stays legal — that transition
+ runs for months (objectui#3546) and belongs to the existing `missing-key` rule,
+ which keeps reporting it alone.
+
+ Every fix moved the CALL SITE to the pack's wording. `en.ts` is untouched: its
+ values are what users read today, and changing one would oblige the same change in
+ the nine other packs (`scripts/check-i18n-en-drift.mjs`, objectui#3650). Six of the
+ 43 differed only in an ellipsis (`...` against U+2026) — invisible in review, which
+ is how they survived three i18n gates that are each blind to this class by
+ construction.
+
+ The visible effect is confined to hosts that render these components with **no**
+ `I18nProvider` and no initialised i18next instance. There, react-i18next's
+ not-ready `t` returns the `defaultValue`, so the inline string was the rendered
+ one; it now matches what a provider-backed app has always shown. Inside the
+ console — provider mounted — nothing users see changes. The clearest converging
+ examples: the workspaces screen was written as "Organizations" at nine call sites
+ while every user has been reading "Workspaces"; the forgot-password success line
+ was written as "If an account exists, a reset link has been sent." while the pack
+ asserts "We've sent a password reset link to {{email}}."
+
+- f8595a0: A list emptied by the view's own filter says "no records match", instead of inviting you to create your first record
+
+ `ListView`'s empty state distinguishes "filtered to empty" from "truly empty (first run)", but the view's own declared `filter` did not count toward that decision — only the search term, the user-filter conditions and the toolbar's live filter group did. A view that returns nothing _because it is filtered_ therefore rendered the first-run copy over an object full of records.
+
+ That is a small string, and it cost real triage time. In objectui#4155 a stored overlay filter had emptied a list, and the screen said "no data yet / create your first record" — so the report read as data loss or a permission problem, and the investigation went to the data and permission layers rather than to the view layer where the defect was. The same misread is available without any bug at all: a perfectly healthy view declaring `status not_in [archived, deleted]` over an object whose rows are all archived told the user the object was empty.
+
+ The base `filter` now counts as an active query, in both at-rest shapes (an array of conditions, and the Mongo-style object form). No new copy — this only routes to the `list.noMatches` / `list.noMatchesMessage` strings that already exist in all ten locales, so there are no new keys to translate. An author-supplied `emptyState.title` / `emptyState.message` still wins over both branches, unchanged.
+
+- 33c32bf: List sort: the picker stops borrowing the filter whitelist, and a header click is no longer a one-way door out of the view's declared sort
+
+ `filterableFields` was applied to the single field set both toolbar builders read, so a whitelist authored for _filtering_ silently became the _sort_ whitelist too. A view could declare a two-level default sort — `plan_start_date` then `name` — and get a sort panel that offered neither field and rendered both of its rows blank: the declared sort worked on load and could then be neither reproduced nor modified, and there was no way to express "sortable but not offered as a filter condition" short of widening the filter builder as collateral. The whitelist now narrows the filter builder alone, which is the contract it was written for; the sort picker starts from every field the view can name and applies its own sortability rules.
+
+ Those rules are about what the sort can honestly reach, so a second one joins the existing relational exclusion: a `formula` field is withheld. It has no materialised column, so ordering by one is refused by the server outright (objectstack `UNMATERIALIZED_SORT_TYPES`) — and it matters here precisely because the base set widened, since a formula field previously reached the picker only if someone had whitelisted it. The exclusion is `formula` alone and deliberately not the spec's `COMPUTED_VALUE_TYPES`: `summary` and `autonumber` are computed too, each gets a real maintained column, and both order correctly. Either rule keeps its existing escape hatch — a field the current sort already uses stays listed, which for a formula field is the only way to remove the offending row.
+
+ One consequence worth naming: the hint explaining the relational omission used to be gated by the same whitelist. A view whitelisting only `status` showed a near-empty sort picker and no word about why; the withheld relational field now reaches the rule that withholds it, so the explanation appears with it.
+
+ The second half is the way back. One column-header click replaces the whole sort array, so a view shipping a multi-level default lost it for the rest of the session — the declared `sort` behaved as an initial value only, recoverable just by reloading the page. The sort panel gains a **Reset to view default** control that restores the declared array whole: multi-level, in declared order, not merely cleared. It reads the view's declared sort through the same resolver the initial render already uses, so there is one answer to "what did this view declare". It is disabled while the active sort already matches that default, and absent entirely for a view that declares no sort — there is no default to return to, and clearing the sort under that label would be a second, differently-named way to do what removing the rows already does. The header click's own semantics are unchanged: it still replaces the array, it just no longer does so irreversibly.
+
+- 37cd8e4: `list-view` now reads its `dataSource` binding through the shared `ElementDataSourceGate` instead of a private copy of the precedence table
+
+ objectstack#5576 landed the per-element `dataSource` binding on `list-view` by
+ writing the precedence table — binding keys override the component's, a `view` is
+ only a baseline, `filter` AND-combines rather than replaces, an authored-but-empty
+ `columns` counts as unauthored, the row cap lands on `pagination.pageSize`, and
+ `viewType` is taken only when the component declared none — inline in
+ `ListViewBlock`. objectstack#6953 then needed the same table for the other eight
+ object-bound blocks and lifted it into `@object-ui/react`
+ (`useElementDataSourceSchema` / `ElementDataSourceGate`), deliberately not
+ touching `ListViewBlock`: refactoring already-merged code inside a wiring PR
+ would have been an out-of-scope regression surface.
+
+ That left one table with two implementations — `list-view` on the private copy,
+ every other block on the shared one. Nothing was wrong for a user today; the risk
+ is the next person to change the rules changing one side, which is how the spec's
+ "_additional_ filter criteria" becomes two dialects and a per-element filter
+ quietly starts replacing a saved view's instead of narrowing it.
+
+ `ListViewBlock` now contributes only what is genuinely its own — the names of the
+ keys `ListView` reads:
+
+ ```ts
+ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = {
+ columns: true,
+ filter: true,
+ sort: true,
+ limit: "pagination.pageSize",
+ viewType: true,
+ };
+ ```
+
+ and the ~45-line `useMemo` mapping block is deleted, along with the block's
+ hand-rolled error and loading panels (the shared
+ `ElementDataSourceErrorPanel` / `ElementDataSourceLoadingPanel` render with the
+ `list-view` testId prefix, so `list-view-datasource-error` and
+ `list-view-resolving-view` are unchanged down to the byte, and the error heading
+ is passed through as `errorTitle`).
+
+ **No behaviour changes in either direction**, and that is the acceptance
+ criterion rather than a hoped-for outcome: objectstack#5576's entire suite passes
+ untouched, with no assertion edited — had any single case needed adapting, the
+ two implementations would have been proven to disagree, which is a defect to
+ re-grade rather than a refactor detail to absorb. New pins cover the mapping
+ table key by key at the block/gate seam, plus the shared loading panel, which
+ neither implementation had ever asserted.
+
+- e076fd5: Inline-edit toggle reads "Edit fields" without an I18nProvider, matching every locale pack
+
+ `DETAIL_DEFAULT_TRANSLATIONS` said `Edit fields inline` where all ten packs say
+ `Edit fields`, so `InlineEditSaveBar`'s toggle announced two different names for one
+ control — the map's on provider-less hosts (standalone embeds, the preview gallery),
+ the pack's in the console. The pack wins; the map row now mirrors it byte for byte.
+
+ The three ungated defaults maps (`plugin-detail`, `plugin-list`, `plugin-designer`) are
+ now compared key-by-key against the `en` pack by a new gate, generalizing the
+ collaboration-only precedent from objectui#3440. `LIST_DEFAULT_TRANSLATIONS` and
+ `DESIGNER_DEFAULT_TRANSLATIONS` are exported for it, as `DETAIL_DEFAULT_TRANSLATIONS`
+ and `COLLAB_DEFAULT_TRANSLATIONS` already were.
+
+- dad805d: Six i18n keys no longer render as raw key strings on hosts with no `I18nProvider` (objectui#4396)
+
+ `detail.saving`, `list.resetSortToDefault`, `appDesigner.widgetProperties`, `appDesigner.addWidget`, `appDesigner.modeEdit` and `common.delete` were read through `createSafeTranslation` without a row in their hook's defaults table and without an inline `defaultValue` at the call site — the only two fallbacks that path has. On a provider-less host (standalone embedding, the preview gallery, host apps that never mount a provider) `fallbackT` therefore returned the key itself, so users saw `detail.saving` in the inline-edit save button, `list.resetSortToDefault` on the sort popover's reset control, `appDesigner.widgetProperties` as the dashboard inspector heading, `appDesigner.addWidget` as its toolbar label, `appDesigner.modeEdit` as a button's accessible name, and `common.delete` on the designer's destructive confirm.
+
+ Each key now has a row in its consumer hook's defaults table, byte-identical to the `en` pack value. No pack was edited, no key added, no call site changed.
+
+- 7f1cb33: List sort: the relational hint stops recommending a formula field, the one type the server refuses to sort by
+
+ The Sort panel withholds columns that link to another record and explains why, and the last sentence of that explanation named the remedy: _add a formula field holding it_. A formula field is exactly what the platform will not order by. The server keeps `UNMATERIALIZED_SORT_TYPES = new Set(['formula'])` and, since objectstack#6994, a sort naming one is a hard `400 INVALID_SORT` — before that it degraded silently, returning every row with `asc` and `desc` byte-identical. So an author who read the hint, followed it, and built a formula field arrived at a refusal; and since #4243 withheld formula fields from this very picker, at a field the panel does not offer either. Two doors, opposite advice, for one problem.
+
+ The remedy sentence now names a **stored, denormalised field — written when the source changes** — and rules the formula field out in as many words: it is virtual, no column is stored for it, and the server refuses to sort by one. That is deliberately the server's own vocabulary rather than a third phrasing of the same fact: objectstack#6924 and objectstack#6994 settled on one wording across the refusal doors so an author refused twice is not sent two different ways, and this is the UI door of that same set. The first half of the hint — why relation columns are withheld at all — is unchanged.
+
+ All ten locale packs move together, as `check:i18n-drift` requires of any `en` edit. The same sentence also lives in `plugin-list`'s provider-less fallback table, which is what renders when the component is used outside an `I18nProvider`; it is updated to match `en` byte for byte, because a pack-only reword would have left the retired advice on exactly the surface this fixes.
+
+- ff84b05: Stop the report config panel being titled "Title", and the view-settings colour section "Color"
+
+ Two call sites asked for a key whose value was written for a different slot, so the rendered copy was wrong (objectui#4118, surfaced by objectui#3810's census).
+
+ `ReportConfigPanel` used `report.editor.title` for both its heading and the accessible name of its `role="complementary"` landmark. That key is the label of the report's Title _field_ — `report.editor.titlePlaceholder` ('e.g. Pipeline by Quarter') sits directly under it in the pack. So the panel was headed "Title", and a screen reader announced a complementary region named "Title", which says nothing about what the region is. A new `report.editor.panelTitle` ('Edit report' — what the call site's own dead fallback said before objectui#3810 aligned it to the pack) now names the panel, in all ten locale packs.
+
+ `ViewSettingsPopover`'s colour section used `list.color`. On the wide toolbar `ListView` already uses both keys correctly for the two slots of this one feature: the compact `Paintbrush` button is `list.color` ('Color') and the panel it opens is headed `list.rowColor` ('Row Color'). This popover is that same panel on the collapsed/`compactToolbar` surface, so it now takes `list.rowColor` — an existing key, no pack change.
+
+ No `en` value of an existing key changed; `scripts/check-i18n-en-drift.mjs` reports 0 en values changed, 1 key added.
+
## 17.4.0
### Minor Changes
diff --git a/packages/plugin-list/package.json b/packages/plugin-list/package.json
index 14c8043c88..45d05e7551 100644
--- a/packages/plugin-list/package.json
+++ b/packages/plugin-list/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-list",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "ListView plugin for Object UI - unified view component with view type switching",
diff --git a/packages/plugin-map/CHANGELOG.md b/packages/plugin-map/CHANGELOG.md
index 60a9daf511..9784747c9c 100644
--- a/packages/plugin-map/CHANGELOG.md
+++ b/packages/plugin-map/CHANGELOG.md
@@ -1,5 +1,72 @@
# @object-ui/plugin-map
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- b388d0e: `object-map` reads its configuration from the declared `map` input only — `filter` is the query filter, and a map authored with both stopped rendering markers
+
+ `getMapConfig` probed every filter for a `map` key and, on a hit, used it as the MapConfig: `schema.filter.map`, plus a `schema.filter.map.style` half in the style chain. That shape predates the `{ name: 'map', type: 'object' }` input both registrations declare, and it gave `filter` two meanings inside one block — the query filter at `$filter: schema.filter`, and a configuration slot.
+
+ The probe was written as `'map' in schema.filter`, and `in` walks the prototype chain. The ordinary filter is an **array**, and every array inherits `Array.prototype.map` — so the probe matched, handed the component a _function_ as its map configuration, and the spread of a function is `{}`. The declared `schema.map` was never reached (it sat in the `else` branch), so a map authored with both `map` and `filter` — two documented inputs, no legacy shape required — lost `latitudeField` / `longitudeField` / `titleField`, failed `extractCoordinates` on every record, and rendered zero markers under a "N records with missing or invalid coordinates excluded from the map" banner. The only console output was `[ObjectMap] Invalid map configuration:` from the Zod parse of a function.
+
+ This was reachable two ways and both are fixed by the same deletion: an author writing `filter` alongside `map`, and the `dataSource` binding of objectstack#7121, whose merged filter is an `and` node — `['and', [...], [...]]`, still an array, still carrying `Array.prototype.map`.
+
+ Both legacy reads are gone; the map consumes only what it declares. The `map` config, the top-level `locationField` / `latitudeField` branch, and the `style` / `mapStyle` reads are untouched, and `filter` is passed to the query verbatim — a field genuinely named `map` still filters on it, and nothing is stripped from the author's filter.
+
+ A schema still carrying the legacy `filter.map` stash now gets a dev-mode warning naming the shape and pointing at `schema.map`, rather than silently falling back to the default field names. It is deliberately narrow: own properties only (so an inherited `map` method never triggers it) and object-valued only (so `filter: { map: 'x' }` reads as a filter on a field named `map`), and it warns once per distinct stash because `getMapConfig` runs on every render. Production behavior is unchanged beyond the configuration no longer being read.
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-map/package.json b/packages/plugin-map/package.json
index a8cf2102a7..dbaf25e2ce 100644
--- a/packages/plugin-map/package.json
+++ b/packages/plugin-map/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-map",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Map visualization plugin for Object UI",
diff --git a/packages/plugin-markdown/CHANGELOG.md b/packages/plugin-markdown/CHANGELOG.md
index ecec7d908e..9d4aeb3b57 100644
--- a/packages/plugin-markdown/CHANGELOG.md
+++ b/packages/plugin-markdown/CHANGELOG.md
@@ -1,5 +1,49 @@
# @object-ui/plugin-markdown
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-markdown/package.json b/packages/plugin-markdown/package.json
index dd8c3503ac..6c3886c8cd 100644
--- a/packages/plugin-markdown/package.json
+++ b/packages/plugin-markdown/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-markdown",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Markdown rendering plugin for Object UI, powered by react-markdown",
diff --git a/packages/plugin-report/CHANGELOG.md b/packages/plugin-report/CHANGELOG.md
index 5367c1ed84..68c7a4b0cf 100644
--- a/packages/plugin-report/CHANGELOG.md
+++ b/packages/plugin-report/CHANGELOG.md
@@ -1,5 +1,145 @@
# @object-ui/plugin-report
+## 17.5.0
+
+### Patch Changes
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
+
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
+
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
+
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
+
+- 326a70f: Analytics: a LOCAL select dimension on a table / pivot widget — and on a dataset-bound report — now renders its option label through the locale bundle
+
+ A dashboard table grouped by a select field showed `Domestic` on a zh-CN console while the related list on the same screen showed 国内. The value was never untranslated by accident: the server resolves that dimension's display label (ADR-0021) and hands the row over carrying the object's AUTHORED English label. The locale bundle is keyed by the option's stored VALUE (`{ns}.fieldOptions...`), so translating one needs the option LIST — and the table path deliberately loaded no object metadata at all, which is why objectui#4030 / PR #4324 fixed charts and dotted dimensions and left this half open.
+
+ Table, pivot and the dataset report block now take the one metadata read that gives the bundle something to translate against, and feed it to the SAME seam #4324 landed (`resolveDimensionFieldMeta` → `localizeFieldOptions` / `buildDimensionLabelMap` → `relabelDimensions`). No second resolution dialect: the map carries both the stored value and the authored label as keys, and the relabel is value-wise and idempotent, so a value the server already resolved lands on the same display it would have from the raw value. Cells, pivot headers on both axes, the server's marginal totals, the CSV export and a report's embedded chart all read the one map, which is what keeps a subtotal's bucket lookup meeting the header it belongs to.
+
+ Untranslated apps are unchanged by construction: with no bundle entry the display equals the authored label, no key is emitted, and the rows come back by identity. Identity keys stay untranslated — a drilled row or cell still filters records by the values the server sent, and measures still export as bare numbers.
+
+ This deliberately amends the acceptance boundary objectui#4263 landed ("a local-only table issues no metadata read"), which was ruled for label RESOLUTION before the read had a second consumer. The pins that stated it are rewritten in place, in the same change, and say so.
+
+- 49ae9f4: Pivot buckets encode an empty dimension value as JSON `null`, so it no longer collides with a row whose value is literally the placeholder character
+
+ objectstack#5473 / objectstack#5665 replaced the pivot's delimiter-joined ids
+ with `JSON.stringify`, because every delimiter that had been tried — an empty
+ string, a plain space, a control character — assumed the data would not contain
+ it, and each assumption failed on ordinary data. This closes the last place the
+ same assumption survived: the ids were JSON, but the VALUES fed into them were
+ spelled `String(row[d] ?? '∅')`, so an absent dimension value became the
+ ordinary string `"∅"` and shared a bucket with a row whose value literally is
+ that character (U+2205). One bucket, later row overwriting the earlier one — the
+ cell showed a different row's measure, the overwritten row was unreachable, and
+ drill-through followed the same wrong index into the wrong records, all without
+ an error. The trigger requires that character to appear as a dimension value, so
+ this is the assumption being removed rather than a defect users hit today.
+
+ An empty value now encodes as JSON `null`, which `JSON.stringify` renders as a
+ bare `null` that no string can spell. The normalization lives in
+ `@object-ui/core` as `pivotDimensionValue` (absent ⇒ `null`, everything else ⇒
+ its string form) rather than at each call site, because a placeholder spelled by
+ a caller is a placeholder that can collide again — which is exactly how this one
+ survived the previous fix. `pivotBucketId` accepts `Array`
+ accordingly; that is a widening, so existing callers passing `string[]` are
+ unaffected.
+
+ Both renderers' bucket keys move together, which the fix requires: a bucket id
+ and the subtotal map keyed by it are built from the same expression, so changing
+ one alone would split the headers while the subtotal map still merged, landing
+ every column subtotal under the wrong header. In `plugin-dashboard`'s
+ `DatasetWidget` that is the row bucket id, the column bucket id, the cell key,
+ and both the `rowTotalById` and `colTotalById` lookups; in `plugin-report`'s
+ `DatasetReportRenderer` the single `bucketId` helper already feeds all five.
+
+ The dashboard's column bucket id also stops being a bare string and becomes a
+ one-element tuple through the same shared encoder. It was the one id in the
+ family still built by hand, on the reasoning that a single value needs no
+ boundary — true of the boundary, false of everything else the encoder does, and
+ it is why the across axis kept carrying this collision after the row ids were
+ fixed.
+
+ No display change: these placeholders only ever entered ids, never labels. An
+ unset dimension still renders through `formatDimensionValue` exactly as before,
+ and data containing neither an absent value nor that character buckets
+ identically — the ids are opaque lookup keys, never parsed back into a value,
+ never shown, never persisted.
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [e2e6360]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [0f21348]
+- Updated dependencies [d2e2caf]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [3a9021e]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [433ff9f]
+- Updated dependencies [e7663f2]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/fields@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-report/package.json b/packages/plugin-report/package.json
index 10316f63d9..bb617e05a7 100644
--- a/packages/plugin-report/package.json
+++ b/packages/plugin-report/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-report",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
diff --git a/packages/plugin-timeline/CHANGELOG.md b/packages/plugin-timeline/CHANGELOG.md
index c19bd426e0..667bca528f 100644
--- a/packages/plugin-timeline/CHANGELOG.md
+++ b/packages/plugin-timeline/CHANGELOG.md
@@ -1,5 +1,68 @@
# @object-ui/plugin-timeline
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/mobile@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-timeline/package.json b/packages/plugin-timeline/package.json
index ef8d156438..56f7e25414 100644
--- a/packages/plugin-timeline/package.json
+++ b/packages/plugin-timeline/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-timeline",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Timeline component plugin for Object UI",
diff --git a/packages/plugin-tree/CHANGELOG.md b/packages/plugin-tree/CHANGELOG.md
index 14013bbe6a..4faea376dd 100644
--- a/packages/plugin-tree/CHANGELOG.md
+++ b/packages/plugin-tree/CHANGELOG.md
@@ -1,5 +1,67 @@
# @object-ui/plugin-tree
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-tree/package.json b/packages/plugin-tree/package.json
index 62dc8843ab..cda28cd5d4 100644
--- a/packages/plugin-tree/package.json
+++ b/packages/plugin-tree/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-tree",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Tree / tree-grid visualization plugin for Object UI",
diff --git a/packages/plugin-view/CHANGELOG.md b/packages/plugin-view/CHANGELOG.md
index 3e1740baa8..d5a9ee7d0e 100644
--- a/packages/plugin-view/CHANGELOG.md
+++ b/packages/plugin-view/CHANGELOG.md
@@ -1,5 +1,86 @@
# @object-ui/plugin-view
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [f7c6430]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [51ab34e]
+- Updated dependencies [24bb2de]
+- Updated dependencies [0ca6096]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [51ac39f]
+- Updated dependencies [5e514c4]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [c32a8a1]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [f5e1143]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [5bf09fd]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/plugin-grid@17.5.0
+ - @object-ui/plugin-form@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/plugin-view/package.json b/packages/plugin-view/package.json
index e8217ba256..2863057fe7 100644
--- a/packages/plugin-view/package.json
+++ b/packages/plugin-view/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/plugin-view",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Object View plugin for Object UI",
diff --git a/packages/providers/CHANGELOG.md b/packages/providers/CHANGELOG.md
index 925ac91e27..24392eac2c 100644
--- a/packages/providers/CHANGELOG.md
+++ b/packages/providers/CHANGELOG.md
@@ -1,5 +1,17 @@
# @object-ui/providers — Changelog
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [bb68488]
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/providers/package.json b/packages/providers/package.json
index 59a457403a..6cf30e9245 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/providers",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "Reusable context providers for ObjectUI applications",
diff --git a/packages/react-runtime/CHANGELOG.md b/packages/react-runtime/CHANGELOG.md
index 640eee7168..e1e71ce10f 100644
--- a/packages/react-runtime/CHANGELOG.md
+++ b/packages/react-runtime/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/react-runtime
+## 17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/react-runtime/package.json b/packages/react-runtime/package.json
index 48a786aa77..9b7efae0ef 100644
--- a/packages/react-runtime/package.json
+++ b/packages/react-runtime/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/react-runtime",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
index 71df3fc5f3..351e868d57 100644
--- a/packages/react/CHANGELOG.md
+++ b/packages/react/CHANGELOG.md
@@ -1,5 +1,266 @@
# @object-ui/react
+## 17.5.0
+
+### Minor Changes
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- d7f3e30: `bridgeListView` maps the five row heights the spec admits, and only those — the four dead spellings are gone
+
+ `mapDensity` carried a nine-key table: `compact`, `short`, `comfortable`,
+ `spacious`, `small`, `medium`, `large`, `tall`, `extra_tall`. `RowHeightSchema`
+ in `@objectstack/spec` admits five — `short | compact | medium | tall |
+extra_tall` — so `comfortable`, `spacious`, `small` and `large` were unreachable
+ from any spec-valid list view. The bridge's own parameter type said as much
+ (`Partial< ListView >`), and `mapDensity` widened it back to `rowHeight?: string`
+ to let them in. They survived because the fixture asserting three of them
+ compiled against nothing: the package's build tsconfig excludes tests and no
+ other `tsc` read them, so four branches of renderer-side dialect read as live
+ capability (objectui#4352, surfaced by objectui#4040 / PR #4351).
+
+ They are deleted. The parameter takes the spec type honestly, and the table is
+ now `Record< RowHeight, … >`, so a row height added upstream fails the build here
+ instead of arriving with no density. This is AGENTS.md #0.1: a lenient reading
+ for off-spec metadata is a second de-facto contract, and one strict contract
+ beats N dialects — a bad `rowHeight` gets fixed at the producer, where the schema
+ already rejects it.
+
+ **Breaking semantics, deliberately graded `minor`** (this repo never publishes
+ `major` — its major tracks `@objectstack`). Nothing narrows in the published type
+ surface: `mapDensity` is module-local and never appeared in the emitted `.d.ts`,
+ and `bridgeListView`'s declaration is unchanged. What changes is runtime output,
+ and only for input the spec already rejects: a host handing the bridge
+ `rowHeight: 'comfortable'` (or `'spacious'` / `'small'` / `'large'`) used to get
+ `density: 'comfortable'` / `'spacious'` / `'compact'` back, and now gets no
+ `density` key at all, so the renderer's own default applies. A sweep of this repo,
+ the `objectstack` example apps and the console's view metadata found zero authored
+ uses of any of the four; the legacy `densityMode` alias cannot produce one either,
+ since `DENSITY_MODE_TO_ROW_HEIGHT` is typed `Record< DensityMode, RowHeight >` and
+ folds onto `compact` / `medium` / `tall`.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- ceccdcf: Action confirm dialogs and success toasts now honour the bundle's translated
+ `confirmText` / `successMessage`, not just `label` (objectui#4265).
+
+ A TranslationBundle entry for an action carries three keys under one
+ `_actions.` node — `label`, `confirmText`, `successMessage` — and
+ `useObjectLabel()` has always exposed a resolver for each. What had drifted was
+ the call sites: `page:header` (authored record pages), `record:quick_actions`
+ and the related-list row menu resolved the button `label` only and dispatched
+ the authored `confirmText` / `successMessage` untouched. One bundle entry met
+ two fates: the button rendered the translation, the confirm dialog rendered the
+ authored English.
+
+ All action-rendering surfaces now go through one resolver,
+ `useActionTextLocalizer()` (new, exported from `@object-ui/react`), which
+ applies the existing `actionLabel` / `actionConfirm` / `actionSuccess`
+ resolvers over the three keys together. Fallback is unchanged: with no bundle
+ entry — or an entry lacking a key — the authored text renders. A bundle cannot
+ introduce a `confirmText` or `successMessage` the metadata never declared.
+
+- ee26e65: Analytics: the dimension label net's fetch-and-memo glue is written once, not once per surface
+
+ PR #4388 (objectui#4330) put the same React glue on two surfaces — the dashboard's `DatasetWidget` and plugin-report's dataset block. The resolution RULES were never duplicated (both call the same `@object-ui/core` helpers), but the wiring around them was: read the object schema through the host's authenticated `apiFetch`, keep the fetched metadata locale-free in state, derive the label maps in a render memo. Two copies meant two statements of the same two bug fixes, which is a drift surface rather than a defect — nothing a user could hit today, filed as objectui#4389 so it was retired deliberately.
+
+ It is now split along the layer that can actually hold each half. `@object-ui/core` gains the React-free parts — `loadDimensionFieldMeta` (the base-object read composed with the dimension walk), `deriveDimensionLabelMaps` (the locale-applying derivation) and `dimensionOptionTranslator` (binding the bundle resolver to the object that OWNS a terminal field, which for a dotted path is the relationship target). `@object-ui/react` gains `useDatasetDimensionLabels` / `useDatasetDimensionMeta`, the React wiring that cannot live in core, beside the `useViewData` / `useElementDataSource` / `useDiscovery` hooks that already read `SchemaRendererContext` the same way. Both plugins consume it; the dashboard keeps its chart-only per-category colour and category-order derivation layered locally, since a table renders no palette.
+
+ The card originally proposed `@object-ui/core` as the whole glue's home. That home was disproven by measurement and retired in the card's PM RULING #2: `SchemaRendererContext` is defined in `@object-ui/react`, which depends on core, so core importing it back is a cycle — and core is React-free by declaration, by content, and by the topology in AGENTS.md. objectui#3367 had already ruled this direction for the same family (core-canonical logic, react re-exports).
+
+ Behaviour is unchanged by construction: same read count, same best-effort fallback, same memoization boundary. The two bug fixes are now stated once and pinned at the shared hook — the read rides the host's authenticated `apiFetch` (objectui#4121, pinned by asserting that a new channel re-issues the read, i.e. that it really is in the effect's deps), and the fetched metadata stays locale-free (objectui#4030 / PR #4324, pinned by switching language at runtime and asserting the labels flip with no second metadata read). All 39 assertions PR #4388 landed across both surfaces pass unchanged, and their files are byte-identical to before.
+
+- 8f85f8b: The spec bridge abstains on prototype-member `rowHeight` spellings instead of leaking a
+ function into `density`.
+
+ `bridgeListView`'s `mapDensity` indexed a plain object literal with an unchecked key, so
+ the lookup reached `Object.prototype`. The parameter is typed `RowHeight`, but the
+ boundary a host's stored view definition actually crosses is `SpecBridge.transformListView`,
+ whose parameter is `any` — so `rowHeight: 'toString'` came back as `Object.prototype.toString`,
+ a **function**, out of a read whose return type is three strings or nothing. `bridgeListView`
+ then writes the key under `if (density)`, and a function is truthy, so the bad value was not
+ merely returned: it was stored on a `SchemaNode` whose renderer expects
+ `'compact' | 'comfortable' | 'spacious'`. Same for `constructor`, `valueOf`,
+ `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`.
+
+ The lookup is now guarded with `Object.prototype.hasOwnProperty.call(...)` — the same guard
+ `@object-ui/core`'s `rowHeightToDensityMode` grew in objectui#4440, and the repo's existing
+ convention at eight other sites. Both `rowHeight` surfaces now abstain identically on every
+ off-spec **string** spelling, and objectui#4440's agreement pin covers the prototype-member
+ family instead of excluding it (objectui#4442).
+
+ Runtime-only: no public type moved, and no spec-valid `rowHeight` changes its answer.
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [0e67b53]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [734d186]
+- Updated dependencies [f7c6430]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [828549a]
+- Updated dependencies [e1ade8f]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [bb58d1d]
+- Updated dependencies [33c32bf]
+- Updated dependencies [66fb4fa]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [405e808]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [c0f9a4b]
+- Updated dependencies [2459a3e]
+- Updated dependencies [2776b11]
+- Updated dependencies [ac853ce]
+- Updated dependencies [fa51109]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [d46f9b8]
+- Updated dependencies [605b747]
+- Updated dependencies [2fea4d2]
+- Updated dependencies [7f1cb33]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [78fa331]
+- Updated dependencies [b42558a]
+- Updated dependencies [d2f6e6b]
+- Updated dependencies [85a3082]
+- Updated dependencies [ff84b05]
+ - @object-ui/i18n@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+ - @object-ui/data-objectstack@17.5.0
+
## 17.4.0
### Minor Changes
diff --git a/packages/react/package.json b/packages/react/package.json
index 70129ca4c1..4a3b08dc54 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/react",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"license": "MIT",
"description": "React bindings and SchemaRenderer component for Object UI",
diff --git a/packages/runner/CHANGELOG.md b/packages/runner/CHANGELOG.md
index 873ec0eff6..5b7d4e491d 100644
--- a/packages/runner/CHANGELOG.md
+++ b/packages/runner/CHANGELOG.md
@@ -1,5 +1,65 @@
# @object-ui/runner
+## 17.5.0
+
+### Patch Changes
+
+- d0c3b26: Every plain `` now declares its `type`. HTML defaults an untyped button to
+ `type="submit"`, so any of these buttons would submit the form it was composed into
+ instead of running its own handler — a real risk for renderers (`drawer`, `tree-view`,
+ `navigation-overlay`) whose placement inside a form is a JSON metadata decision. 114
+ sites were converted to `type="button"`; no site was a genuine submit button, and the
+ DOM is otherwise unchanged.
+
+ The defect class is now closed mechanically by a new `object-ui/button-has-type` ESLint
+ rule (error), so the next untyped button fails CI at write time rather than being found
+ by a fourth audit round (objectui#4045, closing the objectui#3344 family).
+
+- Updated dependencies [ceccdcf]
+- Updated dependencies [d6e5124]
+- Updated dependencies [debad27]
+- Updated dependencies [dc2aa3e]
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [8f85f8b]
+- Updated dependencies [d0c3b26]
+- Updated dependencies [4dadf0d]
+- Updated dependencies [4b70d28]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [b4d3c22]
+- Updated dependencies [cb13400]
+- Updated dependencies [bc64bfe]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [3e19fe7]
+- Updated dependencies [2c8ad7c]
+- Updated dependencies [d7f3e30]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [45e1949]
+- Updated dependencies [0b49d60]
+- Updated dependencies [bcd3e02]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [bfdf3d4]
+- Updated dependencies [bb68488]
+- Updated dependencies [b1e42d0]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [f5e1143]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+- Updated dependencies [5bf09fd]
+ - @object-ui/react@17.5.0
+ - @object-ui/components@17.5.0
+ - @object-ui/core@17.5.0
+ - @object-ui/plugin-charts@17.5.0
+ - @object-ui/plugin-kanban@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/runner/package.json b/packages/runner/package.json
index c692a4734b..8d77164141 100644
--- a/packages/runner/package.json
+++ b/packages/runner/package.json
@@ -1,7 +1,7 @@
{
"name": "@object-ui/runner",
"private": false,
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Universal Object UI Application Runner",
"type": "module",
"homepage": "https://www.objectui.org/docs/utilities/runner",
diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md
index f908c561c4..af016abf94 100644
--- a/packages/sdui-parser/CHANGELOG.md
+++ b/packages/sdui-parser/CHANGELOG.md
@@ -1,5 +1,7 @@
# @object-ui/sdui-parser
+## 17.5.0
+
## 17.4.0
## 17.3.0
diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json
index f6f51f7259..a2334ac7be 100644
--- a/packages/sdui-parser/package.json
+++ b/packages/sdui-parser/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/sdui-parser",
- "version": "17.4.0",
+ "version": "17.5.0",
"type": "module",
"sideEffects": false,
"license": "MIT",
diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md
index f2c3bf7d45..006585414b 100644
--- a/packages/types/CHANGELOG.md
+++ b/packages/types/CHANGELOG.md
@@ -1,5 +1,239 @@
# @object-ui/types
+## 17.5.0
+
+### Minor Changes
+
+- d9d3463: Retire four zero-consumer declared surfaces (dead-surface sweep batch 3, #4328). Each was
+ measured as declared-but-never-read at the branch point, and each is removed rather than
+ left as an authoring surface whose values nothing acts on.
+
+ Breaking for anyone who typed against the removed declarations, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/core` no longer exports `mergeViewsIntoObjects`. It was a second copy left
+ behind by the move of that step to the provider layer, and it had drifted: it ignored a
+ view container's default `list` and keyed views by the authored bare key instead of the
+ composer's `.` identity. The live implementation — `MetadataProvider`'s, in
+ `@object-ui/app-shell` — is unchanged and remains the only one. (#3775)
+ - `@object-ui/types`' `RoleDefinition` no longer declares `permissions`. A role's grants
+ live in `ObjectPermissionConfig.roles`, keyed by object; that is the only home any
+ consumer reads (`resolveRoles` walks `inherits` and matches on `name`). The removed
+ field was _required_, so five fixtures across three packages had been declaring an empty
+ array for a value nothing would ever look at. Role-attached grants are now a compile
+ error rather than silently ignored data. (#4288)
+ - `@object-ui/react`'s `RecordContextValue` no longer declares `loading` / `error`. Both
+ had zero producers and zero consumers — no host passed them, no `record:*` renderer read
+ them — and only the provider's memo dependency list still named them. Record-level
+ loading and error state stays where it is actually expressed: each renderer's own data
+ source. (#3773)
+
+ No behaviour change, no request-count change:
+
+ - `@object-ui/data-objectstack` drops five `metadataCache.invalidate('views:')`
+ calls across `updateViewConfig` / `createView` / `updateView` / `deleteView`. No read
+ path has ever populated that key — `listViews` fetches directly, uncached — so all five
+ were permanent no-ops. The invalidations of the keys that do have readers
+ (`view::` for `getView`, `view-overrides:` for
+ `listViewOverrides`) are untouched and now pinned. (#3778)
+
+- 2a40f69: Retire two post-retirement dead surfaces (#4364, #4368). Both were measured at this
+ branch point rather than taken from their cards, and one card's premise only half held.
+
+ Breaking for anyone who typed against the removed declaration, marked `minor` per this
+ repository's version-alignment convention (the major tracks `@objectstack`, never an
+ API-break count):
+
+ - `@object-ui/types` and `@object-ui/permissions` no longer export
+ `ObjectLevelPermission`. It declared a second, parallel home for object-scoped grants
+ (`{ object, actions, effect?, conditions? }`) that nothing constructed, accepted or
+ read once `RoleDefinition.permissions` was retired (#4288) — its only remaining
+ referents were its own definition and the two barrel lines. The wired home is
+ `ObjectPermissionConfig.roles`, whose inner grant shape is declared inline; that is
+ what the evaluator reads, and it is unchanged. `ObjectPermissionConfig`'s doc comment
+ now records the retirement so the surface is not re-declared. (#4364)
+
+ `PermissionCondition` was proposed for retirement on the same card and is **kept**: its
+ premise ("only referent is `ObjectLevelPermission.conditions`") did not hold at this
+ branch point. `evaluateCondition` in `@object-ui/permissions` takes it as a parameter
+ type and implements all eleven of its operators under a 26-case suite. `PermissionEffect`
+ is likewise untouched — `FieldLevelPermission.effect` still reads it.
+
+ No behaviour change, no public surface change:
+
+ - `@object-ui/console` drops `src/utils/metadataConverters.ts` and
+ `src/services/MetadataService.ts`. Both were console-local duplicates of live
+ `@object-ui/app-shell` modules and lost their last importer when the bespoke
+ object-detail widgets were retired (#4365). Both had already drifted behind the live
+ copies they duplicate — the console converter's `referenceTo` chain never read the
+ server's `reference` key, and the console service predates the view cache-invalidation
+ seam (#4373) — which is precisely the imitation trap the card recorded: an author
+ grepping for "the converter" could land on the unexercised copy. The app-shell copies
+ and their tests are untouched. (#4368)
+
+- 38ab505: Retire the `global_nav` Studio designer surfaces, and track the `@objectstack` family at `17.0.0-rc.6` (objectstack#7100 / objectstack#6888).
+
+ ## The retirement
+
+ `global_nav` was an `ACTION_LOCATIONS` member no running-app surface ever rendered. The console's ⌘K palette (`app-shell/src/chrome/CommandPalette.tsx`) builds its groups from nav items, objects, dashboards, pages, reports, recent items, record search and theme; it holds no reference to `global_nav`, to `actionRendersAt`, or to any action-metadata source. An action declaring `locations: ['global_nav']` therefore never reached a user.
+
+ The Studio designer previewed it anyway — a mock frame reading `⌘K · Command palette` with the author's button inside it. That is the sharp edge the maintainer's 2026-08-09 ruling on objectstack#6888 named: an authoring tool promising a surface the product does not have teaches authors, and every AI copying this corpus, to declare dead metadata. `@objectstack/spec` `17.0.0-rc.6` retired the member (7 members → 6) with a named rejection message; this release removes the designer surfaces that outlived it.
+
+ - `metadata-admin/previews/ActionPreview.tsx` — the mock command-palette placement frame is gone. The metadata strip above it still ECHOES whatever `locations` the draft declares, deliberately: reporting what a (possibly stale) draft says is honest, whereas the frame CLAIMED the platform renders it.
+ - `metadata-admin/inspectors/ActionDefaultInspector.tsx` — the `global_nav` entry is gone from `LOCATION_LABELS`. That map is typed `Record< ActionLocation, string >`, so the retirement reached it as a compile error rather than as a silently stale dropdown — the mechanism objectui#3017 installed, firing as designed.
+ - `metadata-admin/previews/block-config.ts` — the `record:quick_actions` location dropdown no longer offers it, and both locale tables drop the now-orphaned `…option.location.global_nav` key.
+ - `@object-ui/components`' `action:bar` doc comment is aligned. The component's published enum is `[...ACTION_LOCATIONS]`, so it followed the retirement on its own; only the prose was stale.
+
+ `@object-ui/core`'s `ActionEngine.getActionsForLocation` is **unchanged and still answers a literal string match**. Narrowing it to the six live members would put a second rejection point beside the schema's — the tolerant-consumer shape the strict-contract rule forbids, inverted. Enforcement stays where it belongs: the parameter type is now six-membered so no type-correct caller can spell the retired value, and `ActionLocationSchema` rejects it by name at authoring and publish time.
+
+ ## The dependency move
+
+ All 37 `@objectstack/*` declarations across 30 `package.json` files move from `^17.0.0-rc.5` to `^17.0.0-rc.6`, and `pnpm-lock.yaml` resolves one copy of each family package at rc.6. The siblings move with `spec` because `client` / `formula` / `lint` pin it **exactly** — leaving them behind would keep two copies of the spec in the tree, the split brain objectui#3560 called out.
+
+ Bumping the pin and repairing the fallout cannot be split: at rc.5 the `Record< ActionLocation, string >` above is missing a key, at rc.6 it has an excess one.
+
+ ## Breaking, in FROM → TO form
+
+ - **`@object-ui/types`' `Theme` now binds the spec's `Theme`, not `ThemeInput`.** rc.6 retired every `…Input` alias and moved the bare name onto the `z.input` side (`X` = `z.input`, `XParsed` = `z.infer`). The runtime shape and this package's exported name are unchanged — `Theme` was, and still is, the AUTHORING shape where `mode` is optional. Re-pointing at `ThemeParsed` would have been the silent swap.
+ - **`SpecReport` / `SpecReportChart` re-point to `ReportParsed` / `ReportChartParsed`, and `SpecReportInput` / `SpecReportChartInput` to `Report` / `ReportChart`.** Same rename, same rule: each local alias keeps the SIDE it had at rc.5.
+ - **`@object-ui/types` no longer re-exports `I18nObject`, `LocaleConfig`, `PluralRule`, `DateFormat` or `NumberFormat`** — all five were retired by rc.6. They were dead re-exports here: nothing in this repo imported them from `@object-ui/types` (`@object-ui/i18n`'s formatter vocabulary in `utils/spec-formatters.ts` is locally declared and never bound the spec symbols). `I18nLabel` survives and is unchanged as a name.
+ - **`I18nLabel` itself widened from `string` to `string | Record< string, string >`** — rc.6 folded the retired `I18nObject`'s per-locale map into it and ships `resolveI18nLabel(label, locale)` as the shared resolver. Every read in this repo that lands in a text slot now goes through that resolver, so an inline map renders its locale instead of `[object Object]`. Reads the compiler cannot see are audited separately in objectui#4163.
+ - **`@object-ui/types`' `GlobalFilterSchema` derives via `.safeExtend`, not `.extend`.** rc.6's `GlobalFilterSchema` carries a refinement and zod 4 refuses `.extend()` on a refined object outright, which threw at module load. `.safeExtend` is zod's prescribed replacement and KEEPS the refinement, so the spec's cross-field rule now also runs on this package's dialect — which is the intended behaviour, since the pinned divergences widen individual fields and were never meant to switch off a whole-object rule.
+
+- bb68488: Stop declaring 14 symbols under names `@objectstack/spec` owns at `17.0.0-rc.6`
+ (objectui#4167, objectstack#4115).
+
+ The rc.6 bump published nine names this repo already declared locally, on top of
+ four that predate it — `check:spec-symbols` reported all thirteen at once, and a
+ fourteenth (`GlobalFilterSchema`) appeared during the bump itself. Each was
+ triaged on its own rather than blanket-renamed, because the right answer differs
+ per symbol: five bind to the spec, three are renamed because the spec's
+ same-named export means something else, five arrive by derivation, and one is a
+ declared dialect with a written reason.
+
+ **Breaking for importers of `@object-ui/react`, `@object-ui/app-shell` and
+ `@object-ui/types`** — three exported names changed, because the spec exports the
+ same name for a _different_ thing:
+
+ | package | was | now | what the spec's same-named export actually is |
+ | :-------------------- | :----------------- | :----------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- |
+ | `react` / `app-shell` | `MetadataState` | `MetadataCacheState` | a metadata item's LIFECYCLE state — `'draft' \| 'active' \| 'deprecated' \| 'archived'` (`MetadataStateSchema`, `@objectstack/spec/system`) |
+ | `react` / `app-shell` | `resolveI18nLabel` | `resolveKeyedI18nLabel` | a resolver for the INLINE per-locale map (`{ en: 'Owner', 'zh-CN': '负责人' }`) against a BCP-47 locale |
+ | `types` | `DateRangePreset` | `FilterBuilderDateRangePreset` | the thirteen HISTORICAL dashboard filter-bar presets; this one is the filter-builder set, which adds eight FUTURE windows the dashboard schema rejects |
+
+ `resolveI18nLabel` is the one where the collision had already started costing
+ something. rc.6 widened `I18nLabel` from `string` to
+ `string | Record< string, string >`, so the same authored value now reaches
+ either resolver — and each answers wrongly, silently, for the other's input: the
+ keyed one returns `undefined` for `{ en: 'Owner' }` (no `key`, no
+ `defaultValue`), and the spec's reads `key` / `defaultValue` / `params` as locale
+ tags. The rc.6 bump PR met this and aliased the spec's import as
+ `resolveInlineI18nLabel` in five files, with hand-written comments at two of
+ them. That is a review convention, which is what objectstack#4115 exists to
+ replace with a rule — so `Keyed` is now the counterpart of that `Inline`, and the
+ name says which vocabulary it resolves at every call site.
+
+ **Eleven keep their names and are now imported or derived from the spec** instead
+ of re-declared: `DATE_RANGE_PRESETS`, `NavigationMode`, `AddressValue`,
+ `BreakpointColumnMap`, `BreakpointOrderMap`, `KanbanConfig`, `CalendarConfig`,
+ `GanttConfig`, plus the three renamed above at their new names.
+
+ **Four of the copies were losing information, not just duplicating it.**
+
+ - **`GanttConfig` declared six keys and called itself canonical; rc.6's
+ `GanttConfigSchema` declares seventeen.** The eleven it never mentioned —
+ `parentField`, `typeField`, `baselineStartField`, `baselineEndField`,
+ `groupByField`, `resourceView`, `assigneeField`, `effortField`, `capacity`,
+ `quickFilters`, `autoZoomToFilter` — are all read by
+ `plugin-gantt/src/ObjectGantt.tsx`, through a local `GanttConfigEx`
+ intersection that existed only because this type did not carry them. It now
+ derives from the spec, with `timeSegments` (shift segmentation) as the one
+ genuinely local extension; the schema is `$loose` upstream, so that key is
+ legal metadata rather than a second dialect.
+ - **`GanttConfig.tooltipFields` carried the comment "not part of the upstream
+ GanttConfigSchema".** It is, as of rc.6, so the key now arrives from the spec.
+ - **`AddressValue` declared five of the spec's seven parts** — `countryCode` and
+ `formatted` were missing, under a comment already claiming to be "the part
+ names of `AddressSchema`". The widget still renders five inputs; binding the
+ type stops it from asserting the platform cannot store the other two, and makes
+ the `{ ...address }` write-through say so.
+ - **`DATE_RANGE_PRESETS` was `Object.keys(PRESET_RANGES)`,** a third copy of a
+ vocabulary the spec extracted in objectstack#4614 precisely to collapse — its
+ own doc comment names this module as one of the three. It is now the spec's
+ array by reference, and the local date-macro bounds table is pinned complete
+ against it with `satisfies`, so a preset the schema gains without bounds here
+ is a compile error rather than a filter that validates clean and then selects
+ nothing.
+
+ `NavigationMode` was one hop from the spec already (`NavigationConfig['mode']`);
+ it is bound directly, with a both-directions type pin that it stays the same type
+ as the config's own `mode`. `KanbanConfig` / `CalendarConfig` /
+ `BreakpointColumnMap` / `BreakpointOrderMap` were exact hand copies of `$strict`
+ schemas and are now re-exports — "still exact" is the argument for binding them,
+ since a copy with nothing to protect can only drift.
+
+ `GlobalFilterSchema` is the one ALLOW entry. It is the same spread-composition
+ dialect as `SelectOptionSchema` next to it, and it collided only because rc.6's
+ new refinement forced `.extend()` to be respelled as a `.shape` spread — which
+ moved a derivation the guard could see into an object literal it deliberately
+ does not descend into. The dialect is unchanged and its three divergences are
+ pinned; which side moves on the refinement itself is objectui#4165.
+
+ `@objectstack/spec` moves from `devDependencies` to `dependencies` in
+ `@object-ui/layout`: its public type surface now references the spec.
+
+### Patch Changes
+
+- abb0f81: A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window
+
+ `@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message.
+
+ Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated.
+
+ No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting.
+
+ The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here.
+
+- 7e4f0e5: fix(dashboard,i18n): KPI cards and dashboard filters resolve authored labels instead of dropping them (#4032)
+
+ A `type: 'metric'` dashboard widget rendered raw English while every other widget
+ type on the same dashboard rendered the translation, and dashboard filter chips
+ rendered `[object Object]` or the raw stored value. Both come from the same
+ cause: authored labels reaching a render site that could not read the
+ vocabulary `@objectstack/spec` actually admits.
+
+ - **KPI cards rejoin the widget translation channel.** The self-contained
+ `metric` branch built its own label from the raw `widget.title`, so the
+ `{ns}.dashboards.{dash}.widgets.{id}.title` value the renderer had already
+ resolved was computed and thrown away. It now reads that channel like every
+ other widget header.
+ - **The three private `resolveLabel` copies** (`DashboardRenderer`,
+ `MetricWidget`, `MetricCard`) are gone. Each read the retired
+ `{ key, defaultValue }` key-reference form and ended `defaultValue || key`, so
+ handed the inline per-locale map the spec admits today they returned nothing —
+ a KPI card with a map title rendered the literal string `metric`. All three
+ now use `pickLocalized`, the resolver already used for this vocabulary
+ elsewhere in the package.
+ - **Dashboard filter labels and static option labels resolve per locale.**
+ `DashboardFilterDef.label` widens to `string | I18nLabel`, the filter bar
+ resolves before rendering (fixing `[object Object]: All` in the trigger, and
+ in `aria-label` / `placeholder`), and the `def.label || def.name` gate now
+ tests the RESOLVED string — an object is always truthy, so it never reached
+ the fallback before.
+ - **Option labels are no longer discarded.** `normalizeFilterOptions` coerced a
+ map label to the raw stored value in every locale, English included, so
+ `{ value: 'domestic', label: { en: 'Domestic', … } }` displayed as `domestic`.
+ The pair shape is still normalized; the label vocabulary is preserved for the
+ render side to resolve.
+ - **`DashboardComponentSchema.globalFilters` is bound to the spec's
+ `GlobalFilter`** instead of restated by hand. The restatement was both too
+ narrow (`label?: string`, which is what made these read sites invisible to
+ `tsc`) and too wide (it declared a bare-string option shorthand the spec
+ rejects at publish).
+
+ Plain-string labels are unaffected and render byte-identically.
+
## 17.4.0
### Minor Changes
diff --git a/packages/types/package.json b/packages/types/package.json
index ebf2a9058a..995ad699eb 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -1,6 +1,6 @@
{
"name": "@object-ui/types",
- "version": "17.4.0",
+ "version": "17.5.0",
"description": "Pure TypeScript type definitions for Object UI - The Protocol Layer",
"type": "module",
"sideEffects": false,
diff --git a/packages/vscode-extension/CHANGELOG.md b/packages/vscode-extension/CHANGELOG.md
index d0100c86cd..d95b11a34c 100644
--- a/packages/vscode-extension/CHANGELOG.md
+++ b/packages/vscode-extension/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## 17.5.0
+
+### Patch Changes
+
+- Updated dependencies [ee66e2e]
+- Updated dependencies [ee26e65]
+- Updated dependencies [5900ac5]
+- Updated dependencies [e901131]
+- Updated dependencies [d9d3463]
+- Updated dependencies [2a40f69]
+- Updated dependencies [613b167]
+- Updated dependencies [abb0f81]
+- Updated dependencies [38ab505]
+- Updated dependencies [7e4f0e5]
+- Updated dependencies [49ae9f4]
+- Updated dependencies [2459a3e]
+- Updated dependencies [d6aa172]
+- Updated dependencies [fe52a04]
+- Updated dependencies [bb68488]
+- Updated dependencies [9461dd3]
+ - @object-ui/core@17.5.0
+ - @object-ui/types@17.5.0
+
## 17.4.0
### Patch Changes
diff --git a/packages/vscode-extension/package.json b/packages/vscode-extension/package.json
index bb07ebed0d..410cb5c565 100644
--- a/packages/vscode-extension/package.json
+++ b/packages/vscode-extension/package.json
@@ -2,7 +2,7 @@
"name": "object-ui",
"displayName": "Object UI",
"description": "VSCode extension for Object UI - Schema-driven UI development with IntelliSense, validation, and live preview",
- "version": "17.4.0",
+ "version": "17.5.0",
"publisher": "objectui",
"private": true,
"icon": "icon.svg",