From aabc527cb8ad5a93e3972bd3b4d75de94606301c Mon Sep 17 00:00:00 2001 From: os-sam Date: Sat, 5 Sep 2026 02:17:33 +0000 Subject: [PATCH 01/13] chore(deps): resolve @objectstack/spec at 17.3.0 in the lockfile Moves the `@objectstack/spec` resolution in `pnpm-lock.yaml` from 17.2.0 to 17.3.0. Every one of the 30 manifests that declare the dependency already carried a range admitting it (`^17.0.0` / `^17.1.0` / `^17.2.0`), so the declared specifiers are untouched and no published dependency declaration changes by a byte; `check-changeset-presence` confirms the range independently ("0 of them a manifest whose published contract moved"). The bump is committed on its own because the reconciliation it forces is not mechanical: 17.3.0 reddens 14 test files / 36 assertions across six packages, and at least four of those reds are public-contract decisions rather than pin updates. The PR body carries the full measured red set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/spec-17-3-0-lockfile-bump.md | 8 ++ pnpm-lock.yaml | 119 +++++++++++++++--------- 2 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 .changeset/spec-17-3-0-lockfile-bump.md diff --git a/.changeset/spec-17-3-0-lockfile-bump.md b/.changeset/spec-17-3-0-lockfile-bump.md new file mode 100644 index 000000000..167ba120b --- /dev/null +++ b/.changeset/spec-17-3-0-lockfile-bump.md @@ -0,0 +1,8 @@ +--- +--- + +Dev-time only: move the `@objectstack/spec` resolution in `pnpm-lock.yaml` from +17.2.0 to 17.3.0. No published package changes — every manifest's declared range +(`^17.0.0` / `^17.1.0` / `^17.2.0`) already admitted 17.3.0, so no floor moved and +no package's dependency declaration differs by a byte. Empty frontmatter is the +deliberate "no release" declaration for a change that publishes nothing. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df8fbcd77..effb3afdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: link:packages/test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -68,7 +68,7 @@ importers: version: 4.1.10(vitest@4.1.10) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) axe-core: specifier: ^4.13.0 version: 4.13.0 @@ -285,7 +285,7 @@ importers: version: 17.2.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -420,7 +420,7 @@ importers: version: link:../../packages/types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) fumadocs-core: specifier: 16.15.4 version: 16.15.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.8(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.31.0(react@19.2.8))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(zod@4.4.3) @@ -784,7 +784,7 @@ importers: version: 17.2.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@sentry/react': specifier: ^10.70.0 version: 10.70.0(react@19.2.8) @@ -878,7 +878,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) better-auth: specifier: ^1.6.28 version: 1.6.28(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(mongodb@7.2.0(socks@2.8.9))(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.62.1)(@types/node@26.2.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@4.1.10) @@ -918,7 +918,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) chalk: specifier: ^6.0.0 version: 6.0.0 @@ -955,7 +955,7 @@ importers: version: 26.2.0 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -974,7 +974,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1010,7 +1010,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1197,7 +1197,7 @@ importers: version: 17.2.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1241,7 +1241,7 @@ importers: version: 2.4.9 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1262,14 +1262,14 @@ importers: version: 17.2.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) devDependencies: '@object-ui/test-support': specifier: workspace:* version: link:../test-support tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -1299,7 +1299,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1406,7 +1406,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1453,7 +1453,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1634,7 +1634,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -1676,7 +1676,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@radix-ui/react-slot': specifier: ^1.3.3 version: 1.3.3(@types/react@19.2.18)(react@19.2.8) @@ -1804,7 +1804,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react-grid-layout': specifier: ^2.1.0 version: 2.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1913,7 +1913,7 @@ importers: version: link:../i18n '@objectstack/spec': specifier: ^17.1.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2038,7 +2038,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2099,7 +2099,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2160,7 +2160,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tanstack/react-virtual': specifier: ^3.14.9 version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2331,7 +2331,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/react': specifier: 19.2.18 version: 19.2.18 @@ -2370,7 +2370,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2526,7 +2526,7 @@ importers: version: link:../test-support '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -2571,7 +2571,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2629,7 +2629,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -2693,7 +2693,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2758,7 +2758,7 @@ importers: version: link:../types '@objectstack/spec': specifier: ^17.1.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) react: specifier: 19.2.8 version: 19.2.8 @@ -2835,7 +2835,7 @@ importers: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2874,13 +2874,13 @@ importers: version: link:../react '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) packages/test-support: devDependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2892,7 +2892,7 @@ importers: dependencies: '@objectstack/spec': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) zod: specifier: ^4.4.3 version: 4.4.3 @@ -2924,7 +2924,7 @@ importers: version: 3.9.2 tsup: specifier: ^8.5.1 - version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + version: 8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -4279,6 +4279,15 @@ packages: ai: optional: true + '@objectstack/spec@17.3.0': + resolution: {integrity: sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==} + engines: {node: '>=22.0.0'} + peerDependencies: + ai: ^7.0.0 + peerDependenciesMeta: + ai: + optional: true + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -9689,6 +9698,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} @@ -12747,6 +12760,13 @@ snapshots: optionalDependencies: ai: 7.0.65(zod@4.4.3) + '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': + dependencies: + pg-connection-string: 2.14.0 + zod: 4.4.3 + optionalDependencies: + ai: 7.0.65(zod@4.4.3) + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -14966,6 +14986,15 @@ snapshots: postcss: 8.5.26 postcss-value-parser: 4.2.0 + autoprefixer@10.5.4(postcss@8.5.28): + dependencies: + browserslist: 4.28.6 + caniuse-lite: 1.0.30001806 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-value-parser: 4.2.0 + axe-core@4.13.0: {} axios@1.18.1: @@ -18463,12 +18492,12 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.26 + postcss: 8.5.28 tsx: 4.23.12 yaml: 2.9.0 @@ -18496,6 +18525,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + potpack@2.1.0: {} powershell-utils@0.1.0: {} @@ -19799,7 +19834,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0): + tsup@8.5.1(@microsoft/api-extractor@7.58.2(@types/node@26.2.0))(@swc/core@1.15.33(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -19810,7 +19845,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.12)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -19821,7 +19856,7 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.58.2(@types/node@26.2.0) '@swc/core': 1.15.33(@swc/helpers@0.5.23) - postcss: 8.5.26 + postcss: 8.5.28 typescript: 6.0.3 transitivePeerDependencies: - jiti @@ -20185,7 +20220,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: From 952d2ec7e4e0cf4b042eec30c281756186854d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 10:47:29 +0000 Subject: [PATCH 02/13] fix(app-shell,scripts): reconcile the declared-symbol surface with @objectstack/spec 17.3.0 (objectui#7122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three repairs, each prescribed by the thing that failed. 1. `SchemaDiffEntryKind` gained `unreachable` in 17.3.0, so app-shell's deliberately-total `DIFF_LABEL` map stopped compiling — TS2741, the single error behind the `Build packages: failure` CI reported on this branch. The map is total on purpose so an upstream addition fails the build instead of rendering a blank cell; that mechanism worked. Labelled "Not checked — remote unreachable", following the spec's own ruling that this kind asserts NOTHING about the remote schema and must never be surfaced as "schema changed". 2. 17.3.0 began exporting `FlowNodeLike` from `@objectstack/spec/system`, so app-shell's two same-named local declarations became shadows. Measured rather than assumed: the spec's `id?` is OPTIONAL where the inspector's is REQUIRED, and assignability runs one way only, so importing the spec export would silently widen away the guarantee `locateFlowNode`'s callers rest on. Genuine dialects ⇒ renamed to `InspectorFlowNode` and `ScopeFlowNode`, with the tripwire rows the guard asks for in `page-nav-misc-spec-parity.test.ts`. 3. The `BreakpointName` / `BreakpointColumnMap` ALLOW entries were written self-expiring: they covered the interval while objectstack#11027's retirement was merged but unreleased, and their own text said to delete them on the pin bump and move the names to the absence pin. 17.3.0 published the retirement, ratchet 3 failed them as excusing nothing, so both are deleted and both names are pinned absent instead. `node scripts/check-spec-symbol-derivation.mjs` — exit 0 (was 1 with three findings): 1345 files scanned, 18 declared dialects. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../ActionParam.one-authority.test.ts | 2 +- .../external/ValidationPanel.tsx | 6 +++ .../FlowNodeInspector.specKeys.test.tsx | 12 ++--- .../inspectors/FlowNodeInspector.tsx | 6 +-- .../inspectors/flow-nested-selection.ts | 39 ++++++++++---- .../metadata-admin/inspectors/flow-scope.ts | 18 +++++-- .../previews/flow-canvas-layout.ts | 2 +- .../flow-node-producers.label.test.tsx | 4 +- .../page-nav-misc-spec-parity.test.ts | 44 ++++++++++++++++ scripts/check-spec-symbol-derivation.mjs | 52 +++++-------------- 10 files changed, 119 insertions(+), 66 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/ActionParam.one-authority.test.ts b/packages/app-shell/src/views/metadata-admin/ActionParam.one-authority.test.ts index cbdab5932..1f81f9bb6 100644 --- a/packages/app-shell/src/views/metadata-admin/ActionParam.one-authority.test.ts +++ b/packages/app-shell/src/views/metadata-admin/ActionParam.one-authority.test.ts @@ -28,7 +28,7 @@ * INSTRUMENT against declaration 2. `[k: string]: unknown` makes every string * a member, so `keyof` on that type is `string` and a key-set comparison * cannot fail whatever the file does. That is the same trap - * `FlowNodeInspector.specKeys.test.tsx` records for `FlowNodeLike`. + * `FlowNodeInspector.specKeys.test.tsx` records for `InspectorFlowNode`. * * Here the index signature GOES AWAY in the convergence rather than being * worked around, so the key-set half becomes live — but only against the diff --git a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx index 3358e0ba1..7a8705201 100644 --- a/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/external/ValidationPanel.tsx @@ -39,6 +39,11 @@ type RunState = 'idle' | 'running' | 'done' | 'error' | 'unavailable'; * the union, and therefore silently unlabelled here (objectstack#4115). * `default_mismatch` (spec 17.0.0-rc.2) is the mechanism working as intended — * it arrived with the pin bump and failed this build until labelled. + * `unreachable` (spec 17.3.0) arrived the same way. It is the one kind that + * asserts NOTHING about the remote schema — introspection never completed, so + * the comparison never ran — and the spec's own ruling is that consumers must + * surface it as "cannot check", never as "schema changed": labelling it like a + * mismatch tells an operator to repair a schema nobody has read. */ const DIFF_LABEL: Record = { missing_table: 'Missing table', @@ -50,6 +55,7 @@ const DIFF_LABEL: Record = { index_mismatch: 'Index mismatch', unmapped_index: 'Unmapped index', default_mismatch: 'Column default mismatch', + unreachable: 'Not checked — remote unreachable', }; export function ValidationPanel({ datasource }: ValidationPanelProps) { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx index 7afa48a7a..20baf33f0 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.specKeys.test.tsx @@ -22,7 +22,7 @@ * Dropping `description?` from a local `interface` proves nothing on its own, * for two independent reasons measured on this card: * - * 1. The node the inspector edits is typed `FlowNodeLike` (the exported shape + * 1. The node the inspector edits is typed `InspectorFlowNode` (the exported shape * `locateFlowNode` returns), NOT the inspector's own module-local * declaration — so narrowing only the local copy changes no read. * 2. Both shapes carry a deliberately load-bearing `[k: string]: unknown` @@ -35,7 +35,7 @@ * * The two assertions below survive both traps: * - * - **Compile time**: the DECLARED members of `FlowNodeLike` — index signature + * - **Compile time**: the DECLARED members of `InspectorFlowNode` — index signature * stripped — must be a subset of the spec's own `FlowNode` keys. That closes * the whole class rather than the one key: any future member added to the * read type that the contract refuses turns this red. @@ -55,7 +55,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import * as Automation from '@objectstack/spec/automation'; import type { FlowNode as SpecFlowNode } from '@objectstack/spec/automation'; -import type { FlowNodeLike } from './flow-nested-selection'; +import type { InspectorFlowNode } from './flow-nested-selection'; // Same stubs the sibling suite uses: the engine config-schema hook is empty so // the hardcoded field groups render, and the field catalog resolves without a @@ -129,7 +129,7 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? /** * The DECLARED members of a type — its index signature removed. * - * `keyof FlowNodeLike` is `string | number` while the index signature is there, + * `keyof InspectorFlowNode` is `string | number` while the index signature is there, * which is why the naive key comparison cannot see this defect at all. */ type Declared = { @@ -138,14 +138,14 @@ type Declared = { describe('the node read type declares no key FlowNodeSchema refuses (#6287)', () => { it('is pinned at compile time', () => { - type DeclaredNodeKeys = keyof Declared; + type DeclaredNodeKeys = keyof Declared; type SpecNodeKeys = keyof SpecFlowNode; // Guard against a degenerate probe: were either side `any`, or the // index-signature strip to leave nothing behind, every assertion below // would pass while measuring nothing. type _SpecNotAny = Assert, false>>; - type _LocalNotAny = Assert, false>>; + type _LocalNotAny = Assert, false>>; type _StripLeftKeys = Assert, false>>; type _SpecHasKeys = Assert>; // …and that the strip really removed the index signature: `description` diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx index 975b4673a..946c34ad6 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx @@ -55,7 +55,7 @@ import { useActionConfigSchemas } from '../previews/useFlowNodePalette.js'; import { FlowNodeConfigField } from './FlowNodeConfigField.js'; import { useFlowScope } from './useFlowScope.js'; import { nodeOutputRefs, type ScopeRef } from './flow-scope.js'; -import { NESTED_NODE_KIND, parseNestedNodeId, locateFlowNode, type FlowNodeLike } from './flow-nested-selection.js'; +import { NESTED_NODE_KIND, parseNestedNodeId, locateFlowNode, type InspectorFlowNode } from './flow-nested-selection.js'; import type { FlowDesignerEdge } from '../previews/flow-canvas-layout.js'; import { ScreenPreview } from '../previews/ScreenPreview.js'; @@ -69,7 +69,7 @@ import { ScreenPreview } from '../previews/ScreenPreview.js'; * - the node copy declared `description?: string`, a key `FlowNodeSchema` * refuses by name (`.strict()`, objectstack#4001) — and the copy was not even * the type the panel reads through, since `locateFlowNode` returns - * `FlowNodeLike`. Narrowing the copy alone would have changed nothing. + * `InspectorFlowNode`. Narrowing the copy alone would have changed nothing. * - the edge copy still spelled `condition?: unknown`, months after * `FlowEdgeInspector`'s twin was narrowed to the spec's `ExpressionInput` * because the loose spelling described an envelope the server rejects — the @@ -84,7 +84,7 @@ import { ScreenPreview } from '../previews/ScreenPreview.js'; * spec, so this panel now inherits that pin instead of needing its own copy of * it. */ -type FlowNode = FlowNodeLike; +type FlowNode = InspectorFlowNode; type FlowEdge = FlowDesignerEdge; /** diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts index 9b19f4112..b2fef4fa2 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-nested-selection.ts @@ -121,8 +121,27 @@ export function regionLabelOf(regionKey: string, container?: { config?: unknown * field for it (objectui#6287). `FlowNodeInspector.specKeys.test.tsx` pins the * declared members (index signature stripped) as a subset of the spec's own * node keys, so the next addition of that kind fails to compile. + * + * ## Why the name is `InspectorFlowNode` and not `FlowNodeLike` + * + * `@objectstack/spec/system` began exporting its OWN `FlowNodeLike` in spec + * 17.3.0 (the minimal node shape `translateFlow` consumes), so the local name + * became a shadow of a live spec export — read by the next agent as the spec's + * own definition, which is how objectstack#2901 was filed with a backwards + * premise. `check-spec-symbol-derivation.mjs` caught it at the pin bump + * (objectui#7122) and offers four remedies; the difference here is REAL, so + * this is remedy 3, a declared local dialect. + * + * The difference, measured rather than asserted: the spec's `FlowNodeLike` + * declares `id?: string` OPTIONAL, this one declares `id: string` REQUIRED. + * Assignability runs one way only — this type is usable where the spec's is + * expected, the spec's is NOT usable where this one is. Importing the spec + * export in place of this declaration would therefore be a silent WIDENING + * that drops the `id` guarantee `NodeLocation` and every `locateFlowNode` + * caller are built on. The tripwire lives in `@object-ui/types`' + * `page-nav-misc-spec-parity.test.ts`, with the other renamed dialects. */ -export interface FlowNodeLike { +export interface InspectorFlowNode { id: string; type?: string; label?: string; @@ -136,7 +155,7 @@ export interface FlowNodeLike { */ export interface NodeLocation { /** The resolved node (a member of draft.nodes, or of a region sub-graph). */ - node: FlowNodeLike; + node: InspectorFlowNode; /** True when the node lives inside a container region (not draft.nodes). */ nested: boolean; /** @@ -145,7 +164,7 @@ export interface NodeLocation { */ scopeAnchorId: string; /** The enclosing container node — only when nested. */ - container?: FlowNodeLike; + container?: InspectorFlowNode; /** Human region label for the inspector breadcrumb — only when nested. */ regionLabel?: string; /** @@ -157,21 +176,21 @@ export interface NodeLocation { write: (next: Record | null) => Record | null; } -function asNodeArray(v: unknown): FlowNodeLike[] { - return Array.isArray(v) ? (v as FlowNodeLike[]) : []; +function asNodeArray(v: unknown): InspectorFlowNode[] { + return Array.isArray(v) ? (v as InspectorFlowNode[]) : []; } -function configOf(node: FlowNodeLike): Record { +function configOf(node: InspectorFlowNode): Record { const c = node.config; return c && typeof c === 'object' && !Array.isArray(c) ? (c as Record) : {}; } /** A region object (`{ nodes, edges, name? }`) with a usable `nodes` array, or null. */ -function asRegion(v: unknown): (Record & { nodes: FlowNodeLike[] }) | null { +function asRegion(v: unknown): (Record & { nodes: InspectorFlowNode[] }) | null { if (!v || typeof v !== 'object' || Array.isArray(v)) return null; const r = v as Record; if (!Array.isArray(r.nodes)) return null; - return r as Record & { nodes: FlowNodeLike[] }; + return r as Record & { nodes: InspectorFlowNode[] }; } /** Resolve a region object out of a container's config by its structured path. */ @@ -189,9 +208,9 @@ function regionFromConfig(cfg: Record, rp: RegionConfigPath) { * (`region.edges`, `branch.name`). */ function writeNestedNode( - nodes: FlowNodeLike[], + nodes: InspectorFlowNode[], containerIdx: number, - container: FlowNodeLike, + container: InspectorFlowNode, rp: RegionConfigPath, nodeIdx: number, next: Record | null, diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts index 6581cb741..8e6b6682d 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts @@ -73,7 +73,19 @@ export interface FlowScope { trigger?: TriggerScope; } -interface FlowNodeLike { +/** + * The scope walker's read shape for a node it has NOT yet validated — every + * member `unknown` on purpose, because this runs over raw stored metadata and + * narrows each value at its use site. + * + * Named `ScopeFlowNode` rather than `FlowNodeLike` because spec 17.3.0 began + * exporting a `FlowNodeLike` of its own (objectui#7122): a local declaration + * under a live spec export's name reads as the spec's definition to the next + * agent. This one is a genuine dialect, not a copy — the spec's members are + * typed (`id?: string`), these are deliberately untyped, which is the whole + * point of a pre-validation probe. Tripwire: `page-nav-misc-spec-parity.test.ts`. + */ +interface ScopeFlowNode { id?: unknown; type?: unknown; label?: unknown; @@ -154,7 +166,7 @@ export function flowAncestors(nodeId: string, edges: FlowEdgeLike[]): Set, nodeId: string | undefined): FlowScope { - const nodes = asArray(draft.nodes).map(asRecord) as FlowNodeLike[]; + const nodes = asArray(draft.nodes).map(asRecord) as ScopeFlowNode[]; const edges = asArray(draft.edges) as FlowEdgeLike[]; const refs: ScopeRef[] = []; diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts index 13a0b6287..4c4127697 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-canvas-layout.ts @@ -93,7 +93,7 @@ export interface FlowDesignerNode { * * ⛔ Making it required here catches nothing. Measured on `origin/main` in * #6287: `tsc` exit 0, ZERO errors, because every node reaches the reader - * types through `as FlowNodeLike[]` / `as FlowDesignerNode[]` casts out of + * types through `as InspectorFlowNode[]` / `as FlowDesignerNode[]` casts out of * `Record`, and **a cast bypasses a required member**. It is * also mildly harmful — `node.label ?? ''` and `node.label || node.id` are * guards the OPTIONAL type forces, while a required `label` would let diff --git a/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx index 58a3f73c8..b57adbb61 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/flow-node-producers.label.test.tsx @@ -16,7 +16,7 @@ * ⛔ The reader-side type is NOT the enforcement point, and this is measured, * not asserted. objectui#6287 made `label` required on the reader type and got * `tsc` exit 0 with **zero errors**: every node reaches that type through - * `as FlowNodeLike[]` casts out of `Record`, and **a cast + * `as InspectorFlowNode[]` casts out of `Record`, and **a cast * bypasses a required member**. It is also mildly harmful — `node.label ?? ''` * and `node.label || node.id` are guards the OPTIONAL type currently forces, * while a required `label` would let `node.label.trim()` compile against a @@ -279,7 +279,7 @@ function collectSites(file: string, src: string): Site[] { const sites: Site[] = []; // (a) a literal declared AS a flow node - for (const m of src.matchAll(/:\s*(?:FlowDesignerNode|FlowNodeLike|FlowNode)\s*=\s*\{/g)) { + for (const m of src.matchAll(/:\s*(?:FlowDesignerNode|InspectorFlowNode|FlowNodeLike|FlowNode)\s*=\s*\{/g)) { const open = src.indexOf('{', m.index!); const close = matchBracket(src, open, '{', '}'); if (close < 0) continue; diff --git a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts index 2f24edc3c..9afcec892 100644 --- a/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts +++ b/packages/types/src/__tests__/page-nav-misc-spec-parity.test.ts @@ -614,6 +614,12 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 ['FileMetadata', 'UploadedFileMetadata'], ['PageRegion', 'PageNodeRegion'], ['PageRegionSchema', 'PageNodeRegionSchema'], + // `FlowNodeLike` became a spec export in 17.3.0 (objectui#7122), so + // `@object-ui/app-shell`'s two same-named local declarations became + // shadows and were renamed to `InspectorFlowNode` (the inspector's read + // type, `id` REQUIRED where the spec's is optional) and `ScopeFlowNode` + // (the scope walker's pre-validation probe, every member `unknown`). + ['FlowNodeLike', 'InspectorFlowNode'], // `['ResponsiveConfig', 'MobileResponsiveConfig']` left this list when the // local dialect was RETIRED (objectui#7519) — see the header. // `WidgetManifest` / `WidgetSource` moved OUT of this list on the @@ -716,6 +722,42 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 }, ); + /** + * THE TRIPWIRE'S FOURTH FIRING, on the `@objectstack/spec` 17.3.0 bump + * (objectui#7122). 17.3.0 published objectstack#11027's retirement of the + * whole `ui/responsive` vocabulary, which the objectui#7580 ruling had + * already localized here on the merged-but-unreleased upstream change. + * + * For the length of that one pin interval the local declarations and live + * spec exports shared a name, so both carried a SELF-EXPIRING ALLOW entry in + * `scripts/check-spec-symbol-derivation.mjs` whose own text set the + * expiry: "⛔ Delete this entry on the pin bump — ratchet 3 will force it — + * and move the name to the absence pin in page-nav-misc-spec-parity.test.ts". + * The bump did exactly that; these rows are where the names landed. + * + * `BreakpointName` is `@object-ui/types`'; `BreakpointColumnMap` is + * `@object-ui/layout`'s. Both are kept because `responsive-grid` is a + * REGISTERED SDUI component whose authorable `columns` reaches + * `resolveColumnClasses` on the render path — the tombstone's own stated + * return condition, met on the renderer side. The exports are live in this + * workspace, so a spec re-publish is a live collision, not a latent one. + */ + it.each([['BreakpointName'], ['BreakpointColumnMap']])( + 'the spec no longer owns `%s`, re-homed under the objectui#7580 ruling', + (localized) => { + expect( + names, + `spec owns '${localized}' again — the objectstack#11027 ui/responsive ` + + `retirement has been undone upstream while this workspace exports that exact ` + + `name (@object-ui/types' responsive union / @object-ui/layout's column map, ` + + `objectui#7580). This is a live collision: re-triage against objectstack#4115 ` + + `— derive from the spec again, or arbitrate the name. ⛔ Do not answer it by ` + + `re-adding an ALLOW entry: the deleted ones were an INTERVAL waiver, not a ` + + `deliberate-divergence waiver.`, + ).not.toContain(localized); + }, + ); + /** * `OfflineConfig` is the one the spec vacated that objectui did NOT reclaim, * and the reason is worth pinning rather than remembering: **the spec was @@ -760,6 +802,8 @@ describe('renamed local dialects do not collide with a spec export (objectui#307 // `MobileResponsiveConfig` retired with its type (objectui#7519). ['RuntimeWidgetManifest', 'SDUI component manifest, not the field-widget plugin'], ['RuntimeWidgetSource', 'objectui module/inline/registry loader union'], + ['InspectorFlowNode', "app-shell's flow-inspector read type, `id` REQUIRED"], + ['ScopeFlowNode', "app-shell's pre-validation scope probe, members `unknown`"], ])('the spec does not own `%s` (%s)', (name) => { expect(names).not.toContain(name); }); diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 9caf395e4..660d29cc7 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -670,46 +670,18 @@ const ALLOW = { "case this map exists to make someone write a reason for.", issue: 4115, }, - // ── Re-homed layout vocabulary, objectui#7580 (ruling 2026-09-04, option A) ── - // Both entries below are SELF-EXPIRING, and deliberately so. They are the same - // shape as the three theme document types noted at the end of this map: a - // maintainer ruling localized a vocabulary whose upstream retirement is MERGED - // but not yet RELEASED, so for the length of one pin interval the local - // declaration and a live spec export share a name. Ratchet 3 fails an ALLOW - // entry that excuses nothing, so the pin bump that lands objectstack#11027 - // cannot leave either entry behind — it must delete them and pin the vacancy - // where it can execute, exactly as objectui#5668 did for the theme trio. - // - // ⛔ Neither is a deliberate-divergence waiver, and neither may be renewed on - // that reading: the local declarations are the retired spec members VERBATIM. - // The reason they are here is the interval, not a difference. - "@object-ui/types:BreakpointName": { - reason: - "Re-homed from `@objectstack/spec/ui` by the objectui#7580 ruling, NOT a fork: " + - "objectstack#11027 retired the whole `ui/responsive` vocabulary upstream " + - "(tombstone + `RETIRED_DEFS_BY_MAJOR[18]`), and this repo keeps the union because " + - "`responsive-grid` is a REGISTERED SDUI component whose authorable `columns` reaches " + - "`resolveColumnClasses` on the render path — the tombstone's own stated return " + - "condition, met on the renderer side. The collision is an INTERVAL, not a divergence: " + - "the pin is still 17.2.0, which pre-dates the retirement. Members are the retired " + - "enum's verbatim (`xs`…`2xl`), width-pinned by " + - "packages/types/src/__tests__/spec-derived-unions.test.ts. ⛔ Delete this entry on the " + - "pin bump — ratchet 3 will force it — and move the name to the absence pin in " + - "page-nav-misc-spec-parity.test.ts, the disposition objectui#5716/#5668 used for the " + - "theme trio.", - issue: 7580, - }, - "@object-ui/layout:BreakpointColumnMap": { - reason: - "The `BreakpointName` entry above, one package over and for the same interval: the " + - "authorable `columns` input of the registered `responsive-grid` component. Declared " + - "verbatim from the retired `BreakpointColumnMapSchema` — six optional column counts " + - "keyed `xs`…`2xl`, and no index signature, because that schema was `$strict`. Its " + - "twin `BreakpointOrderMap` was NOT re-homed (ruling item 3): it had no read point in " + - "the package and existed only because the retired `ResponsiveConfigSchema` paired the " + - "two. ⛔ Delete this entry on the pin bump; ratchet 3 will force it.", - issue: 7580, - }, + // ── Re-homed layout vocabulary, objectui#7580 — ENTRIES RETIRED, spec 17.3.0 ── + // `@object-ui/types:BreakpointName` and `@object-ui/layout:BreakpointColumnMap` + // lived here for exactly the interval their own text described: a maintainer + // ruling localized the `ui/responsive` vocabulary while objectstack#11027's + // upstream retirement was MERGED but not yet RELEASED, so the local + // declarations and live spec exports shared a name. 17.3.0 published the + // retirement, the collisions ended, ratchet 3 failed both entries as excusing + // nothing, and the pin bump deleted them — the disposition the entries + // themselves prescribed. The vacancy is pinned where it can execute: + // page-nav-misc-spec-parity.test.ts asserts both names ABSENT from the spec + // export set, so an upstream re-publish is a loud collision rather than an + // exemption a future fork inherits under the same name. // The three theme document types (`Theme`, `ThemeMode`, `ColorPalette`, // objectui#5716 ruling, option A — localize) carried ALLOW entries here from // the localization until the `@objectstack/spec` 17.2.0 refresh From 59ea811d28a787495b27574058811b10d8d747b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:11:56 +0000 Subject: [PATCH 03/13] test(types,plugin-form,auth): re-point the citation-stripped pins at the prescription (objectui#7122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` 17.3.0 stripped the `#NNNN` issue-number citations from its refusal messages while keeping the prescriptive half intact. Sixteen assertions were pinned on the stripped half and went red; each is re-pointed at what the refusal still carries, which is the half the pins' own comments name as the point — "the prescription is the half that makes the refusal actionable for an author; asserting only `success === false` would stay green if it were reduced to 'Invalid input'". - `submitRedirect.test.ts` (13): `#7496` -> `ruled 2026-08-11`. The refusal still cites its governing ruling, now by date rather than by issue number; the key-name and non-genericity assertions are untouched. - `export-options-spec-parity.test.ts`: `/8010|1301/` -> the surviving format list and the `os migrate meta` command, i.e. the actionable repair itself. - `report-chart-query-spec-parity.test.ts`: `'#4876, ADR-0049 D2'` -> the ADR reference, which survived, plus the `Delete the key.` prescription. Separately, the `PreviewModeConfig` doc-provenance ratchet FIRED and is retired exactly as its own docblock prescribed ("correct the README line … and then delete this guard, which has no reason to outlive the sentence it protects"). ⚠️ The docblock expected that at spec major 18 — objectstack#11846 registered the retirement in `RETIRED_DEFS_BY_MAJOR[18]` — and it arrived in a MINOR instead, one of four public type exports 17.3.0 removed with no major signal. The capability is untouched: `previewMode` is host-supplied and stays. Verified: `pnpm exec vitest run` over the five affected files — 5 passed, 150 tests passed, exit 0. The edited gate script's own pin suites (`check-spec-symbol-derivation.test.ts`, `check-doc-component-types.test.ts`) — 2 passed, 108 tests, exit 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- packages/auth/README.md | 2 +- .../src/__tests__/auth-spec-parity.test.ts | 55 +++++++------------ .../plugin-form/src/submitRedirect.test.ts | 9 ++- .../export-options-spec-parity.test.ts | 7 ++- .../report-chart-query-spec-parity.test.ts | 6 +- 5 files changed, 39 insertions(+), 40 deletions(-) diff --git a/packages/auth/README.md b/packages/auth/README.md index d1258d09f..b44f7d9a7 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -325,7 +325,7 @@ not a gap. Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens. -This feature aligns with the `PreviewModeConfig` from `@objectstack/spec/kernel` ([spec PR #676](https://github.com/objectstack-ai/spec/pull/676)). +This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846); it left the published spec in `@objectstack/spec` 17.3.0, together with the `RuntimeMode` value `'preview'`. The `previewMode` prop below is unaffected — it is the host's to supply, and always was. ### Usage diff --git a/packages/auth/src/__tests__/auth-spec-parity.test.ts b/packages/auth/src/__tests__/auth-spec-parity.test.ts index d6f791bfd..e6d005a0a 100644 --- a/packages/auth/src/__tests__/auth-spec-parity.test.ts +++ b/packages/auth/src/__tests__/auth-spec-parity.test.ts @@ -274,43 +274,26 @@ describe('the AuthProvider exemption rests on the spec symbol being an id enum', }); /** - * `PreviewModeConfig` — the doc-provenance ratchet (objectui#6748). + * `PreviewModeConfig` — the doc-provenance ratchet (objectui#6748) FIRED and is + * retired here, which is the disposition its own docblock prescribed. * - * `packages/auth/README.md:328` tells readers that this package's preview-mode - * prop "aligns with the `PreviewModeConfig` from `@objectstack/spec/kernel`". - * That sentence is TRUE on the spec this package resolves today — 17.2.0 still - * exports `PreviewModeConfig`, `PreviewModeConfigParsed` and - * `PreviewModeConfigSchema` from its `./kernel` barrel — which is why this - * assertion ships green rather than skipped. + * The ratchet guarded one sentence: `packages/auth/README.md` claimed this + * package's preview-mode prop "aligns with the `PreviewModeConfig` from + * `@objectstack/spec/kernel`". Its instruction on going red was exact — + * "correct `packages/auth/README.md` … and then delete this guard, which has no + * reason to outlive the sentence it protects". * - * Upstream has already retired the symbol in SOURCE: objectstack#11846, landed - * as objectstack PR #12718 on 2026-08-28, removed the whole `PreviewModeConfig` - * block together with the `RuntimeMode` value `'preview'`, and registered it as - * `kernel/PreviewModeConfig` in `RETIRED_DEFS_BY_MAJOR[18]`. So it leaves the - * PUBLISHED set at spec major 18 — not during 17.x. That is why the README line - * was deliberately NOT rewritten when this was found: editing it now would make - * it wrong in the opposite direction for the rest of 17.x, telling readers there - * is no upstream anchor while the import still resolves. + * It went red on the `@objectstack/spec` 17.3.0 bump (objectui#7122): the + * symbol left the PUBLISHED set there. ⚠️ Worth recording rather than + * smoothing over — the docblock expected that to happen at major 18, because + * objectstack#11846 registered the retirement in `RETIRED_DEFS_BY_MAJOR[18]`. + * It arrived in a MINOR instead, one of four public type exports 17.3.0 + * removed without a major-version signal. * - * Nothing else in this repo would notice the moment that flips. This is the - * signal. When this package bumps to a spec without the symbol, THIS TEST GOES - * RED, and the fix is to correct `packages/auth/README.md:328` — drop the - * alignment claim, or repoint it at whatever succeeds it — and then delete this - * guard, which has no reason to outlive the sentence it protects. - * - * The capability itself is NOT in question. `AuthProvider`'s `previewMode` prop - * is host-supplied and stays (objectui#6654's ruling leaves it intact); only the - * provenance sentence is at stake here. + * The README sentence is corrected and the guard is gone. The capability is + * untouched: `AuthProvider`'s `previewMode` prop is host-supplied and stays + * (objectui#6654). No absence pin replaces this one — the symbol was never + * declared in this package, so there is no local name for the spec to collide + * with; the names this repo DOES own are pinned in + * `page-nav-misc-spec-parity.test.ts`. */ -describe('the README preview-mode provenance claim still has an upstream anchor', () => { - it('the spec still exports `PreviewModeConfig`', () => { - expect( - SPEC_NAMES.has('PreviewModeConfig'), - '@objectstack/spec no longer exports `PreviewModeConfig`, so ' + - '`packages/auth/README.md:328` now points readers at a symbol that does not ' + - 'exist — it was retired for major 18 (objectstack#11846, PR #12718). Correct ' + - 'that line, then delete this guard. Do NOT remove the `previewMode` prop: the ' + - 'capability is host-supplied and unaffected (objectui#6654, objectui#6748).', - ).toBe(true); - }); -}); diff --git a/packages/plugin-form/src/submitRedirect.test.ts b/packages/plugin-form/src/submitRedirect.test.ts index 70ed1eb74..8326ca3c2 100644 --- a/packages/plugin-form/src/submitRedirect.test.ts +++ b/packages/plugin-form/src/submitRedirect.test.ts @@ -131,8 +131,15 @@ describe('the shape verdict is the contract’s, for every family', () => { // submitter reads on screen is the one the authoring door would have said. // An empty or generic message would be a silent drop wearing an error's // clothes — which is defect 2 with extra steps. + // + // The citation is asserted in the spelling the refusal actually carries. + // `@objectstack/spec` 17.3.0 stripped the `#NNNN` issue numbers from these + // messages while keeping the prescriptive half intact, so `#7496` stopped + // appearing and the ruling is now referenced by DATE. Re-pointed rather + // than dropped: the property this line exists for is that the sentence + // carries its governing ruling, not that it spells it as an issue number. expect(verdict.refusal).toMatch(/`(submitBehavior\.)?url`/); - expect(verdict.refusal).toContain('#7496'); + expect(verdict.refusal).toContain('ruled 2026-08-11'); expect(verdict.refusal.length).toBeGreaterThan(40); }); diff --git a/packages/types/src/__tests__/export-options-spec-parity.test.ts b/packages/types/src/__tests__/export-options-spec-parity.test.ts index 329384b24..c670d2ee8 100644 --- a/packages/types/src/__tests__/export-options-spec-parity.test.ts +++ b/packages/types/src/__tests__/export-options-spec-parity.test.ts @@ -152,7 +152,12 @@ describe('exportOptions ↔ installed @objectstack/spec (objectui#4535)', () => // reduced to "Invalid input". const messages = (refused.error?.issues ?? []).map((i) => i.message).join('\n'); expect(messages).toMatch(/pdf/); - expect(messages).toMatch(/8010|1301/); + // Was `/8010|1301/` — the issue numbers. 17.3.0 stripped those citations + // and kept the prescriptive half, so the assertion moves to the half this + // test's own comment above calls the point: the actionable repair (which + // values survive) and the migration command that lists the edits. + expect(messages).toMatch(/'csv', 'xlsx' and 'json'/); + expect(messages).toMatch(/os migrate meta/); }); it('lifts a bare format array at PARSE — which is why the renderer still needs its own tolerance', () => { diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index 3710dce9d..d4fac3ac5 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -457,7 +457,11 @@ describe('DashboardWidgetSchema derives from the spec', () => { expect(issue, '`responsive` must be reported by name').toBeDefined(); // The tombstone names its own retirement, not #5010's — if this ever reads // `#5010` the key was folded into the wrong retirement upstream. - expect(issue?.message).toContain('#4876, ADR-0049 D2'); + // Was `'#4876, ADR-0049 D2'`. 17.3.0 stripped the `#NNNN` half of that + // citation and kept the ADR reference and the prescription, so the + // assertion keeps the durable half plus the repair the author acts on. + expect(issue?.message).toContain('ADR-0049 D2'); + expect(issue?.message).toContain('Delete the key.'); // And it must point at the surviving home for breakpoint behaviour rather // than just saying "removed". expect(issue?.message).toContain('page.components[].responsive'); From dcd0dfa1c540c33585adc618c39650d2b7390725 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:13:13 +0000 Subject: [PATCH 04/13] chore(changeset): declare the app-shell/auth reconciliation the 17.3.0 bump forced (objectui#7122) The branch previously declared 'no release' with empty frontmatter, which was true while its only diff was the lockfile. It no longer is: `@object-ui/app-shell` needed a source fix to compile against 17.3.0 at all, and `@object-ui/auth`'s README carried a provenance claim 17.3.0 falsified. Both are declared as patches; the lockfile-only changeset keeps its own empty-frontmatter declaration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7122-spec-17-3-0-reconciliation.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/7122-spec-17-3-0-reconciliation.md diff --git a/.changeset/7122-spec-17-3-0-reconciliation.md b/.changeset/7122-spec-17-3-0-reconciliation.md new file mode 100644 index 000000000..d09810427 --- /dev/null +++ b/.changeset/7122-spec-17-3-0-reconciliation.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/auth': patch +--- + +Reconcile `@object-ui/app-shell` with `@objectstack/spec` 17.3.0 (objectui#7122). + +`SchemaDiffEntryKind` gained an `unreachable` member in 17.3.0, and the external +datasource validation panel labels those kinds through a map that is TOTAL over +the union on purpose — so an upstream addition fails the build rather than +rendering a blank cell. That mechanism fired: the package did not compile against +17.3.0 until the kind was labelled. It now reads "Not checked — remote +unreachable", following the spec's own ruling that this kind asserts nothing +about the remote schema and must never be surfaced as "schema changed": it means +introspection could not complete, which is often transient, and labelling it like +a mismatch would tell an operator to repair a schema nobody has read. + +Two internal flow-inspector types were renamed (`FlowNodeLike` → +`InspectorFlowNode` / `ScopeFlowNode`) because 17.3.0 began exporting its own +`FlowNodeLike`; neither name is in this package's published entry, so no consumer +import changes. + +`@object-ui/auth`: a README sentence claiming the preview-mode prop aligns with +the spec's `PreviewModeConfig` is corrected — 17.3.0 removed that symbol. The +`previewMode` prop itself is host-supplied, unchanged, and unaffected. From ed370059357eb53f0524beab566179f3d19879ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:15:06 +0000 Subject: [PATCH 05/13] fix(core): re-derive the spec action-key inventory for 17.3.0's `operation` / `patch` (objectui#7122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SPEC_ACTION_KEYS` declares itself as "every property `@objectstack/spec`'s `ActionSchema` declares", and `actionKeys.pin.test.ts` walks the schema and fails the day that drifts. 17.3.0 added `operation` and `patch` — the declarative single-record field write mirroring a list view's `bulkActionDefs` — so the inventory was stating something false. Restating the spec, NOT adopting a feature, exactly as the `description` entry above it records for the same situation: `ActionDef` still does not declare either key, no runner branch reads them, and no action surface forwards them. That omission is deliberate under the maintainer's 2026-09-05 ruling on this bump — record them as justified omissions now, forward only a key a runtime actually reads once its semantics are read from upstream, no speculative forwarding. ⚠️ Deliberately checked rather than assumed: the inventory feeds `KNOWN_ACTION_KEYS`, which `classifyActionKeys` consults — and that function only builds `unknown` / `retired` lists for a dev-mode console warning. It rejects nothing and drops nothing, so widening it does not move what any published `@object-ui/*` package accepts or refuses. Clause-② stays `no`. Verified: `pnpm exec vitest run packages/core/src/actions/` — 24 files, 427 tests, exit 0. `node scripts/check-action-forward-parity.mjs` — exit 0, still 19 justified omissions / 7 known gaps, so no new gap was opened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- packages/core/src/actions/actionKeys.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/core/src/actions/actionKeys.ts b/packages/core/src/actions/actionKeys.ts index c61c239ad..77dcab75a 100644 --- a/packages/core/src/actions/actionKeys.ts +++ b/packages/core/src/actions/actionKeys.ts @@ -251,8 +251,26 @@ export const SPEC_ACTION_KEYS = [ 'onSuccess', 'openIn', 'opensInNewTab', + // Added to `ActionSchema` in @objectstack/spec 17.3.0 (objectui#7122), the + // declarative single-record field write that mirrors a list view's + // `bulkActionDefs`: `operation: 'update'` applies `patch` (merged UNDER the + // collected `params`) to the current record on the data plane AS THE CALLER. + // + // Listed here for the same reason `description` above is, and with the same + // limits: this array's contract is "every property the spec's `ActionSchema` + // declares", so listing them RESTATES the spec rather than adopting a + // feature. `ActionDef` does NOT declare either key and is not changed, no + // runner branch reads them, and no action surface forwards them — that is a + // deliberate omission under the maintainer's 2026-09-05 ruling on this bump + // ("record both as justified omissions now; forward only the key a runtime + // actually reads, once its semantics are read from upstream — no speculative + // forwarding"). What these two entries buy is the one thing the inventory is + // consulted for: an action carrying them is no longer reported as having + // unknown keys, which is a dev-mode console warning and nothing else. + 'operation', 'order', 'params', + 'patch', 'recordIdField', 'recordIdParam', 'refreshAfter', From 36baa6d5776eb14dbd3202a739d17dc62a41587f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 11:20:23 +0000 Subject: [PATCH 06/13] docs(auth): state the preview-mode provenance without a version literal (objectui#7122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/__tests__/doc-version-claims.test.ts` is a ratchet over version literals on doc surfaces, and my first correction of this sentence introduced one. Its prescription is explicit — prefer DELETING the literal and pointing at the truth instead, because that is the only spelling that cannot go stale (objectui#3645 froze a spec range across 36 READMEs for thirteen majors exactly this way). The sentence now names the upstream retirement and the fact that the resolved spec no longer exports the symbol, with no version to go stale. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- packages/auth/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth/README.md b/packages/auth/README.md index b44f7d9a7..28596599a 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -325,7 +325,7 @@ not a gap. Preview mode allows visitors (e.g. marketplace customers) to explore the platform without registering or logging in. The `AuthProvider` auto-authenticates with a simulated user identity and bypasses login/registration screens. -This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846); it left the published spec in `@objectstack/spec` 17.3.0, together with the `RuntimeMode` value `'preview'`. The `previewMode` prop below is unaffected — it is the host's to supply, and always was. +This capability is host-supplied and has no `@objectstack/spec` anchor. It aligned with `PreviewModeConfig` from `@objectstack/spec/kernel` until that symbol was retired upstream (objectstack#11846), which removed it together with the `RuntimeMode` value `'preview'`; the spec this package resolves no longer exports it. The `previewMode` prop below is unaffected — it is the host's to supply, and always was. ### Usage From 1546e59c1a6ea8417a5a08357ec0f81e5a3b4224 Mon Sep 17 00:00:00 2001 From: os-sam Date: Sat, 5 Sep 2026 13:36:12 +0000 Subject: [PATCH 07/13] chore(deps): resolve the @objectstack/* family at 17.3.0 so the spec dedupes `@objectstack/client`, `core`, `formula` and `lint` at 17.2.0 each pin `@objectstack/spec` EXACTLY at 17.2.0. Resolving the spec alone to 17.3.0 therefore stopped satisfying them, and the console shipped TWO copies of the spec: `vendor-objectstack` went 926.1 -> 1877.6 KB gzip and the eager closure went 3186.1 -> 4137.7 KB against a 3191.4 KB budget, with no chunk entering or leaving the closure. Moving the family in the lockfile collapses the duplicate. Every declared range already admitted 17.3.0 (`^17.0.0` on all four), so no manifest's published contract moves by a byte -- `check-changeset-presence` independently reports "0 of them a manifest whose published contract moved". The lockfile was resolved by raising the four ranges temporarily, resolving, then restoring the manifests and re-installing, so `pnpm update`'s two side effects are absent: no declared range moved, and `zod` stays at 4.4.3 rather than splitting the `@objectstack/*` peer context into two variants (which would have re-introduced the very duplication this commit removes). Measured after: eager closure 3466.1 KB, `vendor-objectstack` 1206.1 KB. The duplicate IS gone -- markers unique to the 17.2.0 artifact fall from 92.3% to 9.2% presence in the chunk, and `defaultValue` occurrences fall 413 -> 258 against a 203 baseline. What remains is real growth, not duplication: the spec's own browser dist is +292.2 KB gzip at 17.3.0. `check:eager-closure` therefore stays RED by 274.7 KB, which no repair inside this repo can answer. The family bump forces exactly one source reconciliation, and no new test reds. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7122-objectstack-family-17-3-0.md | 22 +++++ packages/data-objectstack/src/index.ts | 22 ++++- pnpm-lock.yaml | 91 +++++++++----------- 3 files changed, 83 insertions(+), 52 deletions(-) create mode 100644 .changeset/7122-objectstack-family-17-3-0.md diff --git a/.changeset/7122-objectstack-family-17-3-0.md b/.changeset/7122-objectstack-family-17-3-0.md new file mode 100644 index 000000000..7fe38e7a4 --- /dev/null +++ b/.changeset/7122-objectstack-family-17-3-0.md @@ -0,0 +1,22 @@ +--- +'@object-ui/data-objectstack': patch +--- + +Reconcile `@object-ui/data-objectstack` with the `@objectstack/*` family at +17.3.0 (objectui#7122). + +`@objectstack/client`, `core`, `formula` and `lint` each pin `@objectstack/spec` +EXACTLY, so resolving the spec alone to 17.3.0 left the console bundling TWO +copies of it. Moving the family with it in `pnpm-lock.yaml` collapses the +duplicate; every declared range already admitted 17.3.0, so no manifest moved. + +The one source change the family bump forces is a type reconciliation, not a +behaviour change. `client.analytics.query` resolved to `Promise` at 17.2.0 +and resolves to `Promise` at 17.3.0, so the pre-envelope +branches of `aggregate`'s row-shape fallback stopped type-checking. Those +branches are read through a widened alias rather than deleted: the client's own +docblock records the runtime change behind the narrower type ("BREAKING since +objectstack#13079 — read `result.rows`, not `result.data.rows`"), and deleting +them is a runtime compatibility decision about servers older than that, not a +type repair. The alias restores exactly the compile-time latitude 17.2.0 gave +the same expression and changes no runtime byte of it. diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index bad31df43..ce30b7e3b 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -5003,7 +5003,27 @@ export class ObjectStackAdapter implements DataSource { : params.filter; } - const data = await this.client.analytics.query(payload); + const contractResult = await this.client.analytics.query(payload); + + // `client.analytics.query` resolved to `Promise` at + // `@objectstack/client` 17.2.0 and resolves to `Promise` + // at 17.3.0, so the pre-envelope branches below stopped type-checking the + // moment the family moved. The client's own docblock states the runtime + // change that produced the narrower type: "BREAKING since #13079 - read + // `result.rows`, not `result.data.rows`; the method used to resolve to the + // whole envelope." + // + // Those branches are READ THROUGH a widened alias here rather than + // deleted, and the distinction is deliberate: deleting them is a runtime + // compatibility decision about servers older than #13079, NOT a type + // repair, and it belongs to whoever owns that decision. This alias + // restores exactly the compile-time latitude 17.2.0's `Promise` gave + // the same expression and changes no runtime byte of it. When the + // compatibility question is ruled, the branches go and the alias goes + // with them - it exists only to keep a decision from being made by a + // build error. + const data = contractResult as AnalyticsResult & + Partial>; const rawRows: any[] = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index effb3afdd..26fb4d5fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,10 +279,10 @@ importers: version: link:../../packages/types '@objectstack/client': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 version: 17.3.0(ai@7.0.65(zod@4.4.3)) @@ -778,10 +778,10 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/lint': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.0.0 version: 17.3.0(ai@7.0.65(zod@4.4.3)) @@ -1194,7 +1194,7 @@ importers: version: link:../types '@objectstack/formula': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 version: 17.3.0(ai@7.0.65(zod@4.4.3)) @@ -1259,7 +1259,7 @@ importers: version: link:../types '@objectstack/client': specifier: ^17.0.0 - version: 17.2.0(ai@7.0.65(zod@4.4.3)) + version: 17.3.0(ai@7.0.65(zod@4.4.3)) '@objectstack/spec': specifier: ^17.2.0 version: 17.3.0(ai@7.0.65(zod@4.4.3)) @@ -4252,32 +4252,23 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@objectstack/client@17.2.0': - resolution: {integrity: sha512-bVRrWyqpn71U6HxQ2IuOuI5sg6b/xp0ZGaKms41+y1a/22bv1Q9cdTd3nY71jGvKmVZ/TJMEd8+D1poAPQQrKQ==} + '@objectstack/client@17.3.0': + resolution: {integrity: sha512-EoxDSZPxdqiInaEQ2eZlRAL0LES73sT2xVUqbUW84pXcB2f+s2uztOZmdkE/IDS65oBrMe8NnJ++q5RrwgI+wg==} engines: {node: '>=22.0.0'} - '@objectstack/core@17.2.0': - resolution: {integrity: sha512-82I4eL/qcuFKOBhXWEGjJPAS9mOfwGVd9jeRbhRGuUZTm282sCuVoR/QC1JkDVxsFE0kSAtE4ej0XsYxP/spZQ==} + '@objectstack/core@17.3.0': + resolution: {integrity: sha512-MgH+HYLEl7dCQJ9XW4Wi/mBX32+WMUSf5X65DplUvHvfOeLQHnDg/GoXjnNVFBI+82A0tgkBPpvsvn7haduY8A==} engines: {node: '>=22.0.0'} - '@objectstack/formula@17.2.0': - resolution: {integrity: sha512-5p6gn92AwNj0e1tfT6mCjBkIUOPjjPr4C6K7GtOAfL20MRIILKgVtf94rZBpWr8+WBGRND050RpssgtN8CqQ/A==} + '@objectstack/formula@17.3.0': + resolution: {integrity: sha512-zLR5DdFshJOvNMX9G4CPBPbWeVrWa/b1GxcMFhyNldtJCb92YlGsgd7vw8OWUkoxE+k9AnbfbxbDaxSxY2Ecgw==} - '@objectstack/lint@17.2.0': - resolution: {integrity: sha512-PbyvKuiDdIQ61nVTy2y+19wl3bFJ3uTQk8u0l+OpNJsUYR1YxXCMnayC/h7h66kTPLs4IgjVTOedc3hV0CiSkg==} + '@objectstack/lint@17.3.0': + resolution: {integrity: sha512-pRFjIZ+sQmlPT2N24aM9szwHK50P3AjiwmHEFi6DUHJGtDq/Tt4HtZ+89nF7WP0MfT1AK8nlNadxsDrHPF8UCg==} engines: {node: '>=22.0.0'} - '@objectstack/sdui-parser@17.2.0': - resolution: {integrity: sha512-ntkTsawiwCVx1wubBdLqfuM1KslPy+PwRTevX2YqBC567KVsRMyn2RbmcDXTAVLUDQNOl2yab7oI7vXZO82mag==} - - '@objectstack/spec@17.2.0': - resolution: {integrity: sha512-nQzyR9+9JQEtLzFastQbj0ETLLbOm4sSY+sNUEX2k0QgZThCsMB1rKPv100c6GQZYg1BaLfz5xc8tv9oH3cKqg==} - engines: {node: '>=22.0.0'} - peerDependencies: - ai: ^7.0.0 - peerDependenciesMeta: - ai: - optional: true + '@objectstack/sdui-parser@17.3.0': + resolution: {integrity: sha512-LbzxlLBoDBd0yxoIYMhYuXJ+j3iEsqvvBtrB0JWWda8Iy6mhPqbpyjj11Y5bZ2A0yGf20z0b/vXLDi1ISNIwpA==} '@objectstack/spec@17.3.0': resolution: {integrity: sha512-mceFp9swHHISMycZk8TjdqjBhi0GboQoapRSNeFyaI8GBIpw5cRc37GrWrIFVTZLaVCc7TF3XHSi1qqjaBNmzA==} @@ -4288,6 +4279,10 @@ packages: ai: optional: true + '@objectstack/types@17.3.0': + resolution: {integrity: sha512-G5c2+V0gzR5co5XcZ0Zs4uTOp42421Yj6fWEWVV6kEpKfIzaDYUzv1HfmwdOfLklrbtNiF3loBg6jB00BVge/g==} + engines: {node: '>=22.0.0'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -7670,9 +7665,6 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.6: - resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} - fast-uri@3.1.7: resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} @@ -12718,32 +12710,33 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@objectstack/client@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/client@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/core': 17.2.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/core': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/core@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/core@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/types': 17.3.0(ai@7.0.65(zod@4.4.3)) zod: 4.4.3 transitivePeerDependencies: - ai - '@objectstack/formula@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/formula@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: '@marcbachmann/cel-js': 8.0.0 - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) transitivePeerDependencies: - ai - '@objectstack/lint@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/lint@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - '@objectstack/formula': 17.2.0(ai@7.0.65(zod@4.4.3)) - '@objectstack/sdui-parser': 17.2.0 - '@objectstack/spec': 17.2.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/formula': 17.3.0(ai@7.0.65(zod@4.4.3)) + '@objectstack/sdui-parser': 17.3.0 + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) sucrase: 3.35.1 @@ -12751,21 +12744,20 @@ snapshots: transitivePeerDependencies: - ai - '@objectstack/sdui-parser@17.2.0': {} + '@objectstack/sdui-parser@17.3.0': {} - '@objectstack/spec@17.2.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: pg-connection-string: 2.14.0 zod: 4.4.3 optionalDependencies: ai: 7.0.65(zod@4.4.3) - '@objectstack/spec@17.3.0(ai@7.0.65(zod@4.4.3))': + '@objectstack/types@17.3.0(ai@7.0.65(zod@4.4.3))': dependencies: - pg-connection-string: 2.14.0 - zod: 4.4.3 - optionalDependencies: - ai: 7.0.65(zod@4.4.3) + '@objectstack/spec': 17.3.0(ai@7.0.65(zod@4.4.3)) + transitivePeerDependencies: + - ai '@open-draft/deferred-promise@2.2.0': {} @@ -14872,7 +14864,7 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.6 + fast-uri: 3.1.7 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -16271,10 +16263,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.6: {} - - fast-uri@3.1.7: - optional: true + fast-uri@3.1.7: {} fast-wrap-ansi@0.2.2: dependencies: From 33abf586d67a7828171003849798cee5c4948e78 Mon Sep 17 00:00:00 2001 From: os-sam Date: Sat, 5 Sep 2026 13:36:29 +0000 Subject: [PATCH 08/13] test(types): re-point the select-option pins at 17.3.0's authoring boundary `@objectstack/spec` 17.3.0 implements the maintainer's 2026-08-25 ruling on objectui#6140 / objectui#6153 (Option A): `SelectOptionSchema.description` and a type-gated `rows` become declared, authorable keys. Two pin files still asserted the 17.2.0 truth -- that the spec REFUSES both -- so they were measurably false, and one of them was the only remaining `Type Check` blocker (TS2344 at lines 101 and 124). Re-pointing them executes the ruling; it makes no new one. Measured against the installed 17.3.0 rather than read from a changelog: `SelectOptionSchema` declares six keys (`color`, `default`, `description`, `label`, `value`, `visibleWhen`); `rows` is accepted on `textarea`, `markdown`, `html` and `richtext` and refused on `text` / `select` as a `custom` refinement rather than `unrecognized_keys`; `icon` and `disabled` are still refused by name, at `options.0` when routed through `FieldSchema`. The boundary moved, it did not disappear, so every re-point keeps a refusal opposite it: - `description` and `rows` are asserted ADMITTED **and** value-enforced (a wrongly-typed `description` is `invalid_type` at its key; `rows: 0` is `too_small`, `rows: '4'` is `invalid_type`). A merely-tolerated key passes the first assertion and fails the second, which is what makes the pair a reading. - `icon` / `disabled` carry the refusal half, so a schema that had gone permissive cannot pass either file. - The four inert rich-text keys (`toolbar` / `preview` / `minHeight` / `maxHeight`) are newly pinned as still-refused on all four multiline types. The ruling's expansion stops at `rows` (objectui#7635 records this: do not widen), and that door has to be seen to stay shut. - `rows`'s type gating is pinned in both directions, because "declared" is not "declared everywhere". In the convergence file the type-level halves moved for a reason worth stating: `SelectOptionBase` derives the spec's keys BY REFERENCE, so `description` arrived on BOTH faces with no edit to the type at all. The form face's pre-convergence equality therefore became a SET DIFFERENCE -- the same idiom the object-metadata face has used for `default` since the convergence -- and the "the two faces differ on exactly `value`" assertion now says so directly instead of excusing `description` from the comparison. NOT done here, deliberately: the prose in `packages/types/src/field-types.ts` and `select-option.ts` still describes the 17.2.0 boundary and is now false in the other direction. Those comment sites are objectui#7635's declared surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../select-option-spec-extension-7014.test.ts | 169 ++++++++++++++---- ...lect-option-tier1-convergence-7014.test.ts | 85 ++++++++- 2 files changed, 211 insertions(+), 43 deletions(-) diff --git a/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts b/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts index 11a1a4bf6..e7c39b9d0 100644 --- a/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts +++ b/packages/types/src/__tests__/select-option-spec-extension-7014.test.ts @@ -1,34 +1,61 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The objectui-side select-option / editor keys are extensions the spec - * REFUSES BY NAME — pinned so the comments that say so cannot rot (objectui#7014). + * Where the authoring boundary actually runs for the select-option and + * multiline-editor keys — pinned so the comments that describe it cannot rot + * (objectui#7014, re-pointed for `@objectstack/spec` 17.3.0). * - * Why this exists. `SelectOptionMetadata` (packages/types/src/field-types.ts) - * and the two `rows` declarations beside it each carried a doc comment - * asserting the installed `@objectstack/spec` DECLARES the key: + * ## What this file was, and what moved + * + * objectui#7014 measured three doc comments in `packages/types/src/field-types.ts` + * that claimed the installed `@objectstack/spec` DECLARED the keys beside them: * * "Aligns `@objectstack/spec` `SelectOptionSchema.description`" * "`@objectstack/spec` `FieldSchema.rows` (a positive integer, authorable …)" * - * Measured on `@objectstack/spec@17.2.0`, all three are false: the spec has no - * such key and rejects it BY NAME. A false canonical claim is not stale - * documentation — it is a planted premise for the next agent, which is the - * whole failure class `scripts/check-spec-symbol-derivation.mjs` exists to - * prevent. That gate could not see these, because it reads only the comment - * block attached to a DECLARATION and validates a citation only at SYMBOL - * granularity; both claims sit on MEMBERS and dangle at the member - * (`SelectOptionSchema` is a live export, `.description` is not a key of it). + * Against `@objectstack/spec@17.2.0` those claims were false — the spec had no + * such keys and refused them BY NAME — and a false canonical claim is a planted + * premise for the next agent, not stale documentation. So this file pinned the + * BOUNDARY rather than removing anything. + * + * ⭐ At `@objectstack/spec` **17.3.0** the boundary MOVED, and it moved to where + * those original comments said it was. The maintainer's 2026-08-25 ruling on + * objectui#6140 / objectui#6153 (Option A) declared both keys, and 17.3.0 + * implements it: `SelectOptionSchema.description` and a `rows` gated to the four + * multiline editor types are now authorable. Re-pointing these assertions + * EXECUTES that ruling; it does not make a new one. + * + * ## The boundary moved, it did not disappear — and that is the point * - * The keys themselves are legitimate and consumed — objectui#6153 for the - * option `description` (LookupField searches it), objectui#6140 for `rows` - * (RichTextField reads it). What was wrong was the attribution. So this pin - * asserts the BOUNDARY rather than removing anything: these are read-model - * extensions that must never reach authored object metadata. + * Two of the three keys #7014 measured are now declared. The third is not, and + * neither are the keys around them. Everything below is written as a PAIR, so + * this file keeps failing loudly in whichever direction the contract next moves: * - * Every assertion below pairs the refusal with a CONTROL that accepts the same - * payload minus the key, so a red here means "the key's status changed", never - * "the fixture drifted". + * - `description` and `rows` are DECLARED, each with a value-level assertion + * so "the key is admitted" is not confused with "anything may be written + * into it" (a key that were merely tolerated would pass the first and fail + * the second). + * - `icon` and `disabled` remain OUTSIDE the option vocabulary, refused BY + * NAME, and they are the control that proves the schema still refuses + * anything at all rather than having gone permissive. + * - `rows` is TYPE-GATED: declared on `textarea` / `markdown` / `html` / + * `richtext` and refused on the field types that do not take it. "Declared" + * does not mean "declared everywhere", and the refusal there arrives as a + * cross-field refinement rather than `unrecognized_keys`. + * - The four inert rich-text keys (`toolbar` / `preview` / `minHeight` / + * `maxHeight`) are STILL undeclared. The ruling's expansion stops at `rows` + * (objectui#7635 records this explicitly: ⛔ do not widen), so they are + * pinned as refused — that door has to be seen to stay shut. + * + * ⚠️ The prose in `packages/types/src/field-types.ts` and + * `packages/types/src/select-option.ts` still describes the 17.2.0 boundary and + * is now false in the other direction. Correcting it is objectui#7635's + * declared surface (the comment/prose sites), not this file's — recorded here + * so the two halves are not repaired twice or, worse, once. + * + * Every assertion pairs its verdict with a CONTROL that changes exactly one + * thing, so a red here reads "the key's status changed" and never "the fixture + * drifted". */ import { describe, it, expect } from 'vitest'; @@ -48,41 +75,77 @@ const refusedByName = (result: { success: boolean; error?: { issues: readonly an (i) => i.code === 'unrecognized_keys' && (i.keys ?? []).includes(key) ); -describe('spec SelectOptionSchema is the boundary these extensions sit outside', () => { - it('declares exactly the five keys the corrected comments name', () => { - // If the spec ever ADDS `description`/`icon`/`disabled`, this fails and the - // comments in field-types.ts must be re-corrected rather than left stale. - expect(SPEC_OPTION_KEYS).toEqual(['color', 'default', 'label', 'value', 'visibleWhen']); +/** Pull any issue whose `path` ends at `key`, whatever its code. */ +const issueAtPath = (result: { success: boolean; error?: { issues: readonly any[] } }, key: string) => + result.success + ? undefined + : result.error!.issues.find((i) => (i.path ?? []).at(-1) === key); + +describe('spec SelectOptionSchema — `description` is inside the vocabulary, `icon`/`disabled` are not', () => { + it('declares exactly the six keys 17.3.0 leaves it with', () => { + // If this list changes again, the comments in field-types.ts and + // select-option.ts must be re-pointed with it rather than left stale. + expect(SPEC_OPTION_KEYS).toEqual([ + 'color', + 'default', + 'description', + 'label', + 'value', + 'visibleWhen', + ]); }); it('accepts the control option', () => { expect(SpecSelectOptionSchema.safeParse(validOption).success).toBe(true); }); - for (const key of ['description', 'icon', 'disabled'] as const) { - it(`refuses the objectui-only key \`${key}\` BY NAME`, () => { + it('ADMITS `description` — the key ruled authorable on 2026-08-25', () => { + const res = SpecSelectOptionSchema.safeParse({ ...validOption, description: 'Blocks the release' }); + expect(res.success).toBe(true); + // …and it is admitted as a DECLARATION, not as tolerance: a wrongly-typed + // value is refused AT the key. A merely-ignored key would pass this too, + // which is what makes the pair a reading. + const wrongType = SpecSelectOptionSchema.safeParse({ ...validOption, description: 42 }); + expect(wrongType.success).toBe(false); + expect(issueAtPath(wrongType, 'description')?.code).toBe('invalid_type'); + }); + + for (const key of ['icon', 'disabled'] as const) { + it(`still refuses the objectui-only key \`${key}\` BY NAME`, () => { const res = SpecSelectOptionSchema.safeParse({ ...validOption, [key]: key === 'disabled' ? true : 'x' }); expect(res.success).toBe(false); expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); + // Control, per fixture: the key is the only difference. + expect(SpecSelectOptionSchema.safeParse(validOption).success).toBe(true); }); } }); -describe('FieldSchema routes options through that strict schema', () => { +describe('FieldSchema routes options through that schema', () => { const field = (options: unknown[]) => ({ name: 'status', type: 'select', label: 'Status', options }); it('accepts a field whose options carry only spec keys', () => { expect(FieldSchema.safeParse(field([validOption])).success).toBe(true); }); - it('fails the WHOLE field when an option carries `description`', () => { + it('accepts the WHOLE field when an option carries `description`', () => { const res = FieldSchema.safeParse(field([{ ...validOption, description: 'help' }])); + expect(res.success).toBe(true); + }); + + it('still fails the WHOLE field when an option carries `icon`, naming it at its path', () => { + const res = FieldSchema.safeParse(field([{ ...validOption, icon: 'flame' }])); expect(res.success).toBe(false); - expect(refusedByName(res, 'description')).toBeDefined(); + const named = refusedByName(res, 'icon'); + expect(named, "expected unrecognized_keys naming 'icon'").toBeDefined(); + // The refusal is reported AT the option, not at the field — this is what + // "routes options through that schema" means, and it is the half that would + // silently disappear if options were ever parsed loosely. + expect((named as { path?: unknown[] }).path).toEqual(['options', 0]); }); }); -describe('FieldSchema refuses `rows` by name on every multiline editor type', () => { +describe('FieldSchema declares `rows` on the four multiline editor types', () => { const base = (type: string) => ({ name: 'body', type, label: 'Body' }); for (const type of ['textarea', 'markdown', 'html', 'richtext'] as const) { @@ -90,10 +153,48 @@ describe('FieldSchema refuses `rows` by name on every multiline editor type', () expect(FieldSchema.safeParse(base(type)).success).toBe(true); }); - it(`\`${type}\` with \`rows\` is refused BY NAME`, () => { + it(`\`${type}\` ADMITS \`rows\`, and enforces it as a positive integer`, () => { + expect(FieldSchema.safeParse({ ...base(type), rows: 4 }).success).toBe(true); + // The value half: declared does not mean unchecked. + const zero = FieldSchema.safeParse({ ...base(type), rows: 0 }); + expect(zero.success).toBe(false); + expect(issueAtPath(zero, 'rows')?.code).toBe('too_small'); + const stringy = FieldSchema.safeParse({ ...base(type), rows: '4' }); + expect(stringy.success).toBe(false); + expect(issueAtPath(stringy, 'rows')?.code).toBe('invalid_type'); + }); + + for (const key of ['toolbar', 'preview', 'minHeight', 'maxHeight'] as const) { + it(`\`${type}\` still refuses the inert rich-text key \`${key}\` BY NAME`, () => { + const value = key === 'toolbar' ? ['bold'] : key === 'preview' ? true : 200; + const res = FieldSchema.safeParse({ ...base(type), [key]: value }); + expect(res.success).toBe(false); + expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}' on ${type}`).toBeDefined(); + // Control, per fixture: the key is the only difference. + expect(FieldSchema.safeParse(base(type)).success).toBe(true); + }); + } + } +}); + +describe('`rows` is TYPE-GATED — declared is not the same as declared everywhere', () => { + const base = (type: string) => ({ name: 'body', type, label: 'Body' }); + + for (const type of ['text', 'select'] as const) { + it(`control: \`${type}\` without \`rows\` is accepted`, () => { + expect(FieldSchema.safeParse(base(type)).success).toBe(true); + }); + + it(`\`${type}\` refuses \`rows\` — and as a cross-field refinement, not \`unrecognized_keys\``, () => { const res = FieldSchema.safeParse({ ...base(type), rows: 4 }); expect(res.success).toBe(false); - expect(refusedByName(res, 'rows'), `expected unrecognized_keys naming 'rows' on ${type}`).toBeDefined(); + const at = issueAtPath(res, 'rows'); + expect(at, `expected an issue at 'rows' on ${type}`).toBeDefined(); + // The distinction matters: `rows` IS a declared key of the field + // vocabulary, so the schema does not report it as unrecognized. It is the + // TYPE that does not take it, which arrives as a refinement. + expect(at?.code).toBe('custom'); + expect(refusedByName(res, 'rows')).toBeUndefined(); }); } }); diff --git a/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts index 844e88387..63a824290 100644 --- a/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts +++ b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts @@ -39,6 +39,30 @@ * as an OPTIONAL key, so every document that face accepted before it is still * accepted. It is asserted below rather than left implicit. * + * ## ⭐ What `@objectstack/spec` 17.3.0 changed here, and why nothing was edited + * + * 17.3.0 adopted `SelectOptionSchema.description` (maintainer ruling + * 2026-08-25 on objectui#6140 / objectui#6153, Option A). `SelectOptionBase` + * derives the spec's keys BY REFERENCE, so the key arrived on BOTH faces with + * no edit to `../select-option` at all — which is exactly the property the + * derivation exists to have, and it is worth saying out loud that the pins + * below moved while the type did not. + * + * Two consequences are written into the assertions rather than left implicit: + * + * 1. The FORM face gained a member it never declared, so its + * pre-convergence equality became a SET DIFFERENCE — the same idiom the + * object-metadata face has used for `default` since the convergence. A + * plain `Equal` there would now have to be repaired every time the spec + * grows, which is the hand-copy failure this file exists to prevent. + * 2. The two faces no longer differ on `description` — it is inherited on + * both — so the "differ on exactly `value`" assertion states that + * directly instead of excusing `description` from the comparison. + * + * The runtime half moved the same way: `description` is asserted ACCEPTED, and + * `disabled` / `icon` carry the refusal half so a schema that had gone + * permissive cannot pass this file. + * * ## The control the spec fixtures need * * A select option's `value` is a lowercase machine identifier with a minimum @@ -96,9 +120,26 @@ interface PRE_CONVERGENCE_SelectOptionMetadata { visibleWhen?: ExpressionWire; } -/** The SDUI form face is member-for-member what it was. */ -export type assertionFormFaceUnchanged = - Expect< Equal< SelectOption, PRE_CONVERGENCE_SelectOption > >; +/** + * The SDUI form face kept every member it had, at the same type — stated by + * removing the one key the spec has since added and comparing what is left. + * `PRE_CONVERGENCE_SelectOption` above is a HISTORICAL record and is never + * edited to match; it is the fixed end of this comparison. + */ +export type assertionFormFaceKeptEveryMember = + Expect< Equal< Omit< SelectOption, 'description' >, PRE_CONVERGENCE_SelectOption > >; + +/** + * …and `description` is the ONLY key it gained since. Written as a set + * difference so a second key cannot ride along silently: add one and this + * stops being `'description'`. + */ +export type assertionFormFaceGainedOnlyDescription = + Expect< Equal< Exclude< keyof SelectOption, keyof PRE_CONVERGENCE_SelectOption >, 'description' > >; + +/** …and it arrived OPTIONAL, so no form that type-checked before now fails. */ +export type assertionDescriptionIsOptional = + Expect< Equal< SelectOption['description'], string | undefined > >; /** * The object-metadata face kept every member it had, at the same type — the @@ -119,9 +160,16 @@ export type assertionMetadataFaceGainedOnlyDefault = export type assertionDefaultIsOptional = Expect< Equal< SelectOptionMetadata['default'], boolean | undefined > >; -/** The two faces differ on exactly one inherited member, and it is `value`. */ +/** + * The two faces differ on exactly one inherited member, and it is `value`. + * + * `description` used to be excused from this comparison because only the + * object-metadata face declared it; 17.3.0 put it in the derivation, so both + * faces carry it and the comparison states the difference without an + * exemption. Re-adding one here would hide the next divergence. + */ export type assertionOnlyValueDiffers = - Expect< Equal< Omit< SelectOption, 'value' >, Omit< SelectOptionMetadata, 'value' | 'description' > > >; + Expect< Equal< Omit< SelectOption, 'value' >, Omit< SelectOptionMetadata, 'value' > > >; export type assertionFormValueIsWide = Expect< Equal< SelectOption['value'], string | number | boolean > >; export type assertionMetadataValueIsSpecIdentifier = @@ -138,6 +186,7 @@ export type assertionMetadataValueIsSpecIdentifier = const SPEC_KEYS_ON_BASE: readonly (keyof SelectOptionBase)[] = [ 'color', 'default', + 'description', 'label', 'value', 'visibleWhen', @@ -184,6 +233,7 @@ export const acceptsEveryDeclaredKeyOnFormFace = (): SelectOption => ({ color: '#ef4444', default: false, visibleWhen: "'admin' in current_user.positions", + description: 'Blocks the release', disabled: true, icon: 'hash', }); @@ -240,7 +290,6 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { for (const [key, value] of [ ['disabled', true], ['icon', 'flame'], - ['description', 'Blocks the release'], ] as const) { it(`the spec refuses \`${key}\` BY NAME, with the same option minus the key accepted`, () => { const res = SpecSelectOptionSchema.safeParse({ ...CONTROL, [key]: value }); @@ -251,7 +300,21 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { }); } - it('a fully-populated read-model option is refused as a whole, naming all three', () => { + it('`description` is NOT one of them — 17.3.0 moved it inside the vocabulary', () => { + // The counterpart of the two refusals above, and the reason this file's + // dialect list is two keys and not three: `description` was an + // objectui-only key until the 2026-08-25 ruling declared it, so it is + // asserted ACCEPTED here rather than quietly dropped from the loop. + const res = SpecSelectOptionSchema.safeParse({ ...CONTROL, description: 'Blocks the release' }); + expect(res.success).toBe(true); + expect(refusedByName(res, 'description')).toBeUndefined(); + // Control: the schema has not gone permissive — it still refuses by name. + expect( + refusedByName(SpecSelectOptionSchema.safeParse({ ...CONTROL, icon: 'flame' }), 'icon'), + ).toBeDefined(); + }); + + it('a fully-populated read-model option is refused as a whole, naming the two still outside', () => { const readModel: SelectOptionMetadata = { label: 'High priority', value: 'high_priority', @@ -263,12 +326,16 @@ describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { }; const res = SpecSelectOptionSchema.safeParse(readModel); expect(res.success).toBe(false); - for (const key of ['description', 'disabled', 'icon']) { + for (const key of ['disabled', 'icon']) { expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); } + // …and `description` is NOT among them. Stated positively, because the + // whole-document refusal would still be red with `description` refused too, + // and this file would then be pinning a boundary the contract has left. + expect(refusedByName(res, 'description')).toBeUndefined(); // Control: the same document with only the spec keys parses. - const { description: _d, disabled: _di, icon: _i, ...specOnly } = readModel; + const { disabled: _di, icon: _i, ...specOnly } = readModel; expect(SpecSelectOptionSchema.safeParse(specOnly).success).toBe(true); }); }); From f389bec9078c5511b5f80f5f766f0d6095a0a1ad Mon Sep 17 00:00:00 2001 From: os-sam Date: Sat, 5 Sep 2026 13:45:53 +0000 Subject: [PATCH 09/13] chore(deps): collapse the postcss duplicate the first lockfile bump introduced `@object-ui/cli` failed `type-check` with `TS2321: Excessive stack depth comparing types 'Plugin & ExportedAPI' and 'Plugin'` in `src/utils/workspace-vite.ts`, a file this branch never touched. Cause, measured rather than inferred: the CLI's type-check program held TWO postcss copies. `@tailwindcss/postcss@4.3.3` depended on `postcss@8.5.26` while `autoprefixer@10.5.4` resolved its auto-installed peer to `postcss@8.5.28`, so `Plugin` on one side and `Plugin & ExportedAPI` on the other were structurally distinct types and the comparison exceeded the instantiation budget. `origin/main` has a single `autoprefixer@10.5.4(postcss@8.5.26)` and no 8.5.28 at all, which is why it is green. The duplicate is NOT from this round's family bump: `postcss@8.5.28` is present at `36baa6d57`, and the family-bump commit's lockfile diff touches neither postcss nor autoprefixer. It arrived with `aabc527cb`, the branch's first lockfile bump, whose own diff shows `postcss@8.5.26 -> 8.5.28` alongside the spec move. It stayed invisible because `turbo run type-check` aborts at its first failing task, and `@object-ui/types` failed before `@object-ui/cli` ever ran -- so "the 2 TS2344 are the ONLY Type Check blocker" was never a reading the old measurement could support. Collapsed in the lockfile only, by the same route as the `@objectstack/*` family: all eight manifests that declare postcss already declare `^8.5.26`, which admits 8.5.28, so the ranges were raised temporarily, resolved, then restored and re-installed. No declared range moved; `postcss@8.5.26` leaves the graph entirely and `@tailwindcss/postcss` now shares the single 8.5.28. `postcss@8.5.23` is a separate consumer, present on main too, and is untouched. After: `@object-ui/cli` `tsc --noEmit` exits 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .changeset/7122-postcss-dedupe.md | 19 ++++++++++ pnpm-lock.yaml | 58 +++++++++---------------------- 2 files changed, 35 insertions(+), 42 deletions(-) create mode 100644 .changeset/7122-postcss-dedupe.md diff --git a/.changeset/7122-postcss-dedupe.md b/.changeset/7122-postcss-dedupe.md new file mode 100644 index 000000000..71203b4c1 --- /dev/null +++ b/.changeset/7122-postcss-dedupe.md @@ -0,0 +1,19 @@ +--- +--- + +Dev-time only: collapse the `postcss` duplicate this branch's first lockfile +bump introduced (objectui#7122). + +`aabc527cb` resolved `@objectstack/spec` to 17.3.0 and, as a side effect of that +install, re-resolved `autoprefixer`'s auto-installed `postcss` peer to 8.5.28 +while `@tailwindcss/postcss` kept 8.5.26. `@object-ui/cli` depends on both and +declares `postcss` itself, so its type-check program held TWO postcss copies +with two structurally distinct `Plugin` types — and comparing +`Plugin & ExportedAPI` against `Plugin` across them exceeded the compiler's +instantiation budget: `TS2321: Excessive stack depth`, in a file this branch +never touched. `origin/main` carries one copy for that pair and is green. + +The split is resolved in the lockfile only. All eight manifests that declare +`postcss` already declare `^8.5.26`, which admits 8.5.28, so no declared range +moved and no published dependency declaration differs by a byte. Empty +frontmatter is the deliberate "no release" declaration. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26fb4d5fb..d9e2ae695 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,7 +318,7 @@ importers: version: 4.1.10(vitest@4.1.10) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) happy-dom: specifier: ^20.11.2 version: 20.11.2 @@ -327,7 +327,7 @@ importers: version: 1.31.0(react@19.2.8) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 react: specifier: 19.2.8 version: 19.2.8 @@ -463,7 +463,7 @@ importers: version: 19.2.4(@types/react@19.2.18) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -521,10 +521,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -1164,10 +1164,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 shadcn: specifier: ^4.18.0 version: 4.18.0(typescript@6.0.3) @@ -1342,7 +1342,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2197,7 +2197,7 @@ importers: version: 2.15.0(@types/node@26.2.0)(typescript@6.0.3) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2276,7 +2276,7 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -2850,10 +2850,10 @@ importers: version: 6.0.5(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) autoprefixer: specifier: ^10.5.4 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.28) postcss: specifier: ^8.5.26 - version: 8.5.26 + version: 8.5.28 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -9300,11 +9300,6 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9686,10 +9681,6 @@ packages: resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -14013,7 +14004,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.26 + postcss: 8.5.28 tailwindcss: 4.3.3 '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': @@ -14969,15 +14960,6 @@ snapshots: atomically@1.7.0: {} - autoprefixer@10.5.4(postcss@8.5.26): - dependencies: - browserslist: 4.28.6 - caniuse-lite: 1.0.30001806 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.26 - postcss-value-parser: 4.2.0 - autoprefixer@10.5.4(postcss@8.5.28): dependencies: browserslist: 4.28.6 @@ -18146,8 +18128,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.17: {} - nanoid@3.3.18: {} nanoid@6.0.1: {} @@ -18508,12 +18488,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.26: - dependencies: - nanoid: 3.3.17 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.28: dependencies: nanoid: 3.3.18 @@ -19332,7 +19306,7 @@ snapshots: kleur: 4.1.5 open: 11.0.1 ora: 8.2.0 - postcss: 8.5.26 + postcss: 8.5.28 postcss-selector-parser: 7.1.5 prompts: 2.4.2 recast: 0.23.21 @@ -20179,7 +20153,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: @@ -20194,7 +20168,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: From 9c1a1ac5f364f1ad950cdf10eb223c4d02f6fdc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 22:35:45 +0000 Subject: [PATCH 10/13] =?UTF-8?q?wip(spec-17.3.0):=20parity=20reconciliati?= =?UTF-8?q?on=20in=20progress=20=E2=80=94=20INCOMPLETE=20AND=20UNREVIEWED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rescue commit made by the PM seat after the dispatched dev was terminated mid-task by an account session limit (HTTP 429 on claude-opus-5, resets 2026-09-05T18:00Z). Committed so the work survives container reclaim; it is NOT a claim that any of it is correct, complete, or gated. Observed state at rescue time, and nothing beyond it: 18 modified files and 2 untracked changesets, 550 insertions / 115 deletions against the branch tip 339885d0c. No gate was run against this content by this seat. Not present in the diff: any change to an eager-closure ceiling or performance budget. The maintainer-authorised ceiling raise had not been made when the agent stopped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- ...7122-lookup-target-required-before-save.md | 28 ++++++ .../7122-spec-17-3-0-parity-reconciliation.md | 39 ++++++++ .../src/__tests__/spec-symbol-parity.test.ts | 4 +- ...tadataService.retiredObjectEnabled.test.ts | 8 +- ...tadataService.specKeyObjectPayload.test.ts | 11 ++- .../MetadataService.specKeyReference.test.ts | 96 ++++++++++++++++--- .../app-shell/src/services/MetadataService.ts | 77 +++++++++++++++ .../previews/__tests__/block-config.test.ts | 62 +++++++++++- .../__tests__/canvas-display-meta.test.tsx | 7 +- .../exclusion-reason-truthfulness.test.ts | 7 +- .../metadata-admin/previews/block-types.ts | 35 +++++-- packages/cli/src/utils/known-schema-types.ts | 2 - .../components/src/renderers/placeholders.tsx | 14 ++- ...MetadataFieldsPage.retiredIndexed.test.tsx | 6 +- ...tadataFieldsPage.specKeyReference.test.tsx | 62 +++++++++--- .../src/MetadataFieldsPage.tsx | 56 ++++++++++- packages/plugin-detail/src/index.tsx | 2 +- ...ord-details.hideEmptyRetired-7129.test.tsx | 76 +++++++++++++-- ...ct-schema-metadata-spec-derivation.test.ts | 74 +++++++++----- packages/types/src/field-types.ts | 66 +++++++------ 20 files changed, 617 insertions(+), 115 deletions(-) create mode 100644 .changeset/7122-lookup-target-required-before-save.md create mode 100644 .changeset/7122-spec-17-3-0-parity-reconciliation.md diff --git a/.changeset/7122-lookup-target-required-before-save.md b/.changeset/7122-lookup-target-required-before-save.md new file mode 100644 index 000000000..d69ad817d --- /dev/null +++ b/.changeset/7122-lookup-target-required-before-save.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-designer': patch +--- + +Refuse to save a `lookup` / `master_detail` field with no target, instead of +PUTting it and blocking the object (objectui#7122). + +`@objectstack/spec` 17.3.0 makes `reference` a hard requirement on the two +relationship field types — a `custom` refinement at path `reference`, measured +on the installed build; at 17.2.0 the requirement was prose only and +`{ type: 'lookup', label: 'L' }` parsed green. The designer relied on that +latitude and PUT half-filled drafts. + +Against a matched 17.3.0 backend that PUT returns `422 INVALID_METADATA` for the +WHOLE object document, so the damage is not confined to the incomplete field: +every later save of that object fails the same way until the draft is completed +or removed by hand. + +Both metadata writers now raise before the request — `MetadataService.saveFields` +and `MetadataFieldsPage`'s own field-map conversion — naming the field and what +to do about it. The message lands in the page's existing error banner, the same +one a nameless or duplicated field already produces; no new UI affordance, and +no request is issued. A relationship field WITH a target is unaffected. + +Picked over the cheaper alternative deliberately: flipping the parity pin green +while the product still PUT the draft would have pinned a known-broken save +path. diff --git a/.changeset/7122-spec-17-3-0-parity-reconciliation.md b/.changeset/7122-spec-17-3-0-parity-reconciliation.md new file mode 100644 index 000000000..28a1f21a2 --- /dev/null +++ b/.changeset/7122-spec-17-3-0-parity-reconciliation.md @@ -0,0 +1,39 @@ +--- +'@object-ui/types': patch +'@object-ui/components': patch +'@object-ui/cli': patch +'@object-ui/plugin-detail': patch +--- + +Reconcile the declared surface with `@objectstack/spec` 17.3.0 (objectui#7122). + +**`ObjectSchema.editMode` is now the spec's.** 17.3.0 adopted the key (measured: +the accept set went 42 → 43, gained set exactly `['editMode']`, lost set empty, +declared as the same `'page' | 'modal'` union objectui carried). Its local copy +is retired from `ObjectSchemaClientExtensions`, which is what that type's own pin +prescribed for this event, leaving the client delta empty. Nothing is removed +from the product: `editMode` stays authorable and stays typed on +`ObjectSchemaMetadata`, carried by the spec's `ServiceObject` instead of by a +local member — and a published, spec-validated object document may now carry it, +which at 17.2.0 was refused by name. + +**`user:profile` is retired across all three sites.** 17.3.0 dropped it from +`PageComponentType` (measured: the enum went 34 → 32 options, lost set exactly +`['user:profile', 'element:form']`, gained set empty). objectui went on knowing +it in three places, so all three moved together: the Studio palette exclusion +ledger, `PROTOCOL_COMPONENTS` in `renderers/placeholders.tsx`, and the +regenerated `known-schema-types.ts` the CLI checks schemas against. Nothing +user-reachable went with it — neither type had a renderer, `user:profile` had +only the dashed "Component Placeholder" scaffold, and the app shell's own +profile affordance is a React slot, never this block type. A page schema still +naming it now draws the loud "Unknown component type" panel rather than a silent +grey box, which is this repo's standing treatment for a type outside the +supported surface. + +**`record:details` sections document the eight keys 17.3.0 added.** +`group`, `hideEmpty`, `collapsible`, `showBorder`, `defaultCollapsed`, `icon`, +`description` and `headerColor` are now declared on a section entry (4 → 12 +members). Six of the eight are already honoured by `DetailSection`, so the +`sections` input description now teaches all of them, and says plainly which two +are not read here. Designer controls for them are a separate feature and are +deliberately not added. diff --git a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts index acfcde1fb..547d4b6e4 100644 --- a/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts +++ b/packages/app-shell/src/__tests__/spec-symbol-parity.test.ts @@ -439,7 +439,7 @@ describe('ObjectFieldGroup derives from the spec schema INPUT side', () => { // Still the real spec vocabulary, not a hand copy that merely agrees. type _HasSpecKeys = Assert< Extends< - 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded', + 'key' | 'label' | 'icon' | 'description' | 'visibleWhen' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded', keyof ObjectFieldGroup > >; @@ -447,7 +447,7 @@ describe('ObjectFieldGroup derives from the spec schema INPUT side', () => { Equal< Exclude< keyof ObjectFieldGroup, - 'key' | 'label' | 'icon' | 'description' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded' + 'key' | 'label' | 'icon' | 'description' | 'visibleWhen' | 'collapse' | 'collapsible' | 'collapsed' | 'defaultExpanded' >, never > diff --git a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts index fc8c45c1e..88c7aca35 100644 --- a/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts +++ b/packages/app-shell/src/services/MetadataService.retiredObjectEnabled.test.ts @@ -137,8 +137,14 @@ describe('objectui#6238 · the schema oracle — both tombstone keys are refused // key-name gate could never have stated it. `enable` passes on the name and // fails on the value, which is why this is objectui#4687's resolution // (delete the declaration) rather than objectui#6041's (rename it). + // 43, not the 42 this pin carried against 17.2.0: `@objectstack/spec` 17.3.0 + // ADOPTED `editMode` (measured — gained set exactly `['editMode']`, lost set + // empty). That adoption is unrelated to this file's subject and is pinned in + // `packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts`; + // the count rides here only as the corpus guard for the membership claims + // below, so it is re-pointed, not weakened. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(42); + expect(accept.size).toBe(43); expect(accept.has('enable')).toBe(true); expect(accept.has('enabled')).toBe(false); expect(accept.has('_deleted')).toBe(false); diff --git a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts index c70ab88fe..342bdb93e 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyObjectPayload.test.ts @@ -19,8 +19,9 @@ * shapes: `toObjectPayload` builds it and `saveObject` PUTs it whole to * `PUT /api/v1/meta/object/:name`. * - * Measured against the installed `@objectstack/spec` 17.2.0 (ESM build), whose - * `ObjectSchema` accept set is 42 keys: + * Measured against the installed `@objectstack/spec` (ESM build), whose + * `ObjectSchema` accept set is 43 keys at 17.3.0 (42 at 17.2.0, when this file + * was written; the one gained key is `editMode`, unrelated to the three below): * * ObjectSchema.safeParse({ ...base, group: 'Sales' }) => unrecognized_keys ["group"] * ObjectSchema.safeParse({ ...base, sortOrder: 3 }) => unrecognized_keys ["sortOrder"] @@ -140,8 +141,12 @@ describe('the instrument', () => { }); it('has no near-spelling for any of them — unlike objectui#6041, nothing here is a rename', () => { + // 43 at `@objectstack/spec` 17.3.0, which adopted `editMode` (measured — + // gained set exactly `['editMode']`, lost set empty). The count is this + // file's corpus guard, not its subject; every claim it guards is below and + // unchanged. const accept = new Set(Object.keys(ObjectSchema.shape as Record)); - expect(accept.size).toBe(42); + expect(accept.size).toBe(43); // `fieldGroups` is the only grouping key on the object, and it groups the // FIELDS INSIDE one object — it is not a category for objects themselves, // so `group` has no mapping target here. diff --git a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts index 90bdabb7f..b5c958b74 100644 --- a/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts +++ b/packages/app-shell/src/services/MetadataService.specKeyReference.test.ts @@ -141,24 +141,94 @@ describe('objectui#6041 · saveFields PUTs the relationship target as `reference expect(def.reference).toBe('account'); }); - it('a HALF-FILLED draft — type `lookup`, target left empty — still saves, exactly as before', async () => { - // The behavioural edge this card had to measure. The spec's prose calls - // `reference` "Required for relationship types", but that requirement is - // NOT enforced by the zod parse at 17.2.0: `{ type: 'lookup', label: 'L' }` - // parses green at field level AND through `ObjectSchema`. `undefined` is - // dropped by `JSON.stringify` under either spelling, so the wire bytes are - // byte-identical before and after this fix. + it('a HALF-FILLED draft — type `lookup`, target left empty — is REFUSED before any PUT', async () => { + // ⭐ This assertion is INVERTED from what it said at 17.2.0, and the + // inversion is the point of objectui#7122's ruled item 4. // - // ⚠ This case would still pass on a revert, and says so deliberately: it - // is here to prove the rename did NOT newly block a draft, which is a - // claim about the unchanged half. + // It used to read "still saves, exactly as before". The spec's prose called + // `reference` "Required for relationship types" while the zod parse did not + // enforce it — `{ type: 'lookup', label: 'L' }` parsed green at field level + // AND through `ObjectSchema` — so the designer was free to persist a + // target-less draft, and did. + // + // `@objectstack/spec` 17.3.0 closes that declared-but-unenforced gap: the + // same document is now refused by a `custom` refinement at path + // `reference`. Against a matched backend the PUT comes back 422 + // `INVALID_METADATA` for the WHOLE object document, which blocks every + // later save of that object — so flipping this pin green on its own would + // have pinned a known-broken save path. The product half was fixed first + // (`assertRelationshipTargetPresent` in `MetadataService.ts`), and this + // asserts THAT: the incomplete draft never reaches the wire. const { adapter, puts } = makeCapturingAdapter(); - await new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: undefined }]); + await expect( + new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: undefined }]), + ).rejects.toThrow(/needs a `reference`/); + + // The load-bearing half: refusing is only an improvement if it happens + // BEFORE the request. A guard that threw after the PUT would leave the + // object in exactly the state this change exists to prevent. + expect(puts).toHaveLength(0); + }); + + it('and it is the MISSING TARGET that is refused, not the type — the control', async () => { + // Without this, the assertion above is satisfied by a guard that refuses + // every `lookup`, which would break the feature rather than fix it. + const { adapter, puts } = makeCapturingAdapter(); + + await new MetadataService(adapter).saveFields('account', [LOOKUP]); const [def] = savedFields(puts); - expect('reference' in def).toBe(false); - expect('referenceTo' in def).toBe(false); + expect(def.reference).toBe('account'); expect(FieldSchema.safeParse(def).success).toBe(true); }); + + it('an empty-string target is refused too — `reference: ""` is not a target', async () => { + // The spec refuses it (`.min(1)`-shaped: measured, `reference: ''` fails the + // same `custom` issue at path `reference`), and a designer whose picker was + // opened and cleared emits exactly this. + const { adapter, puts } = makeCapturingAdapter(); + + await expect( + new MetadataService(adapter).saveFields('account', [{ ...LOOKUP, referenceTo: '' }]), + ).rejects.toThrow(/needs a `reference`/); + expect(puts).toHaveLength(0); + }); +}); + +describe('objectui#7122 · the guarded type list is the spec’s, not a hand-kept guess', () => { + // `RELATIONSHIP_TYPES_REQUIRING_REFERENCE` is a named list in + // `MetadataService.ts` (a full `FieldSchema` parse before the PUT is refused + // on purpose — it would reject plugin-registered keys the server accepts). + // A named list can go stale in both directions, so it is DERIVED here and + // compared, which turns any upstream movement into a red test rather than a + // silent hole in the guard. + const REQUIRE_REFERENCE = ['lookup', 'master_detail']; + + it('every spec field type that needs a target is guarded, and no other type is', () => { + const typeSchema = FieldSchema.shape.type as unknown as { + options?: string[]; + def?: { entries?: Record }; + }; + const allTypes: string[] = + typeSchema.options ?? Object.keys(typeSchema.def?.entries ?? {}); + + // Non-vacuity: a shape read that answered `[]` would make the derivation + // agree with an empty list and assert nothing at all. + expect(allTypes.length).toBeGreaterThan(20); + + const derived = allTypes.filter((type) => { + const parsed = FieldSchema.safeParse({ type, label: 'L' }); + return !parsed.success && parsed.error.issues.some((i) => i.path.join('.') === 'reference'); + }); + + expect([...derived].sort()).toEqual([...REQUIRE_REFERENCE].sort()); + }); + + it('a guarded type parses green once the target is supplied — the requirement is the VALUE, not the type', () => { + for (const type of REQUIRE_REFERENCE) { + expect(FieldSchema.safeParse({ type, label: 'L', reference: 'account' }).success).toBe(true); + expect(FieldSchema.safeParse({ type, label: 'L', reference: '' }).success).toBe(false); + } + }); }); diff --git a/packages/app-shell/src/services/MetadataService.ts b/packages/app-shell/src/services/MetadataService.ts index e41a23fa2..af4c15f70 100644 --- a/packages/app-shell/src/services/MetadataService.ts +++ b/packages/app-shell/src/services/MetadataService.ts @@ -187,6 +187,82 @@ function toObjectPayload(obj: ObjectDefinition, fields?: FieldMetadataPayload[]) }; } +/** + * Field types whose `reference` (the target object a relationship links to) + * `@objectstack/spec` requires to be present and non-empty. + * + * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for + * every one of `FieldSchema`'s 49 declared types: exactly two are refused for a + * missing target — `lookup` and `master_detail` — and no other type is refused + * at all on that minimal document. Kept as a named list rather than derived by + * probing at runtime, and pinned against the spec by + * `MetadataService.specKeyReference.test.ts` so it cannot go stale in either + * direction (a third type gaining the requirement, or one of these losing it). + * + * ⛔ Deliberately NOT "parse every field through `FieldSchema` before the PUT". + * That would refuse plugin-registered keys the SERVER accepts — measured on + * 17.2.0, `x_plugin_thing` is `unrecognized_keys` to the installed spec while + * the server that sent it takes it back — which is the same reason + * {@link RETIRED_FIELD_KEYS} is a named list instead of a schema filter. This + * guard states one invariant, not a client-side revalidation of the document. + */ +const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; + +/** + * Refuse a relationship field whose target is missing — BEFORE the PUT. + * + * ## Why this raises instead of letting the server answer + * + * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` + * and `master_detail` (a `custom` refinement at path `reference`, not an + * `unrecognized_keys` name refusal). Against a matched backend the PUT of a + * half-filled relationship draft comes back `422 INVALID_METADATA` — and this + * is the expensive part, the same trap `RETIRED_FIELD_KEYS` exists for: the + * refused document is the WHOLE object, so the failure is not confined to the + * incomplete field. Every later save of that object fails the same way until + * the draft is completed or removed. + * + * At 17.2.0 the requirement was prose only — `{ type: 'lookup', label: 'L' }` + * parsed green — so the designer was free to persist a target-less draft and + * did. 17.3.0 closes a declared-but-unenforced gap (ADR-0049's direction), and + * the reconciliation the maintainer ruled for it (objectui#7122, 2026-09-05, + * ruled item 4) is this one: the incomplete draft stays in the client and is + * never PUT. ⛔ The alternative — flipping the pin green and leaving the + * product PUTting it — was refused on the ground that it pins a known-broken + * save path. + * + * ## Why an exception, and why HERE + * + * Same mechanism, same reason and the same call site as the nameless-field and + * duplicate-name refusals below: it raises before the request, so a refused + * list issues no PUT at all, and the designer page runs its save inside a + * `try` whose `catch` already renders the message in the page's existing error + * surface (`data-testid="metadata-fields-page-error"`). No new UI affordance is + * introduced by this guard — the author sees the same banner they already see + * for a nameless or duplicated field. + * + * `MetadataFieldsPage` carries the sibling copy of this check for the same + * reason it carries the sibling `toFieldsMap` and `carryOver`: the two writers + * convert different input types on different paths, and neither owns the + * other's. Both are pinned. + */ +function assertRelationshipTargetPresent( + field: { type?: string; reference?: unknown }, + fieldName: string, + writer: string, +): void { + if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; + const reference = field?.reference; + if (typeof reference === 'string' && reference.trim() !== '') return; + throw new Error( + `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + + '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + + 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + + '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + + 'this field. Pick the target object, or change the field to a non-relationship type.', + ); +} + /** * Key a list of field payloads by field NAME — the shape `ObjectSchema.fields` * requires (objectui#6240). @@ -251,6 +327,7 @@ function toFieldsMap(fields: FieldMetadataPayload[]): Record { + /** + * Section keys the spec declares that this designer does NOT yet expose an + * editor for — every one of them arriving with `@objectstack/spec` 17.3.0, + * which grew the section entry from four member keys to twelve (measured: + * gained set exactly these eight, lost set empty). + * + * ⛔ This is a DEFERRAL, not a dismissal, and it is deliberately a hand-kept + * list rather than a loosened assertion. Eight new inspector controls is a + * feature, and the bump that revealed the gap is not the place to build it + * (maintainer ruling on objectui#7122, 2026-09-05, ruled item 5: "推迟,单开 + * feature 卡 —— 8 个新控件是功能,不是 bump 的尾巴"). Six of the eight are + * already HONOURED by the renderer through `DetailSection` (`icon`, + * `description`, `collapsible`, `defaultCollapsed`, `showBorder`, + * `headerColor`), so the gap is genuinely the control and not the capability; + * `group` is unimplemented here, and `hideEmpty` is retired on purpose + * (objectui#7129). All eight are documented on the `sections` input's + * description, which the sibling `recordDetailsInputs.spec-parity.test.ts` + * enforces — so they are discoverable in source mode today. + * + * The assertion below keeps its full force for everything else: a NINTH key + * landing upstream still fails here, and so does any entry of this list that + * stops being a spec key (a stale deferral) or that quietly gains a control + * without being removed from the list. + */ + const DEFERRED_SECTION_CONTROLS = [ + 'group', + 'hideEmpty', + 'collapsible', + 'showBorder', + 'defaultCollapsed', + 'icon', + 'description', + 'headerColor', + ]; + + it('exposes an editor for every key the spec declares on a section, bar the deferred eight', () => { expect(sectionsField?.kind).toBe('array'); const authored = (sectionsField?.itemFields ?? []).map((f) => f.name); - const missing = specSectionKeys.filter((k) => !authored.includes(k)); + const missing = specSectionKeys.filter( + (k) => !authored.includes(k) && !DEFERRED_SECTION_CONTROLS.includes(k), + ); // If this fails: the spec declares a section key the block designer gives - // authors no way to write. Add the itemField — a key that only source-mode - // editing can reach is a key Studio-built pages structurally cannot carry. + // authors no way to write, and that is not one of the eight consciously + // deferred above. Add the itemField — a key that only source-mode editing + // can reach is a key Studio-built pages structurally cannot carry. expect(missing, 'section keys with no designer control').toEqual([]); }); + it('the deferral list is neither stale nor a cover for a control that now exists', () => { + // The two ways the exemption above could rot, both closed here rather than + // left to a reader's diligence. Without this the list would be a permanent + // hole: a key removed upstream, or one that later gained a control, would + // sit in it forever and quietly shrink what the coverage assertion checks. + const authored = (sectionsField?.itemFields ?? []).map((f) => f.name); + expect( + DEFERRED_SECTION_CONTROLS.filter((k) => !specSectionKeys.includes(k)), + 'deferred key the spec no longer declares — drop it from the list', + ).toEqual([]); + expect( + DEFERRED_SECTION_CONTROLS.filter((k) => authored.includes(k)), + 'deferred key that now HAS a designer control — drop it from the list', + ).toEqual([]); + }); + it('the `name` editor is a text box carrying the snake_case convention', () => { const nameField = sectionsField?.itemFields.find((f) => f.name === 'name'); expect(nameField, 'record:details sections must expose the i18n anchor `name`').toBeDefined(); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx index b15bb58cb..f70e50512 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/canvas-display-meta.test.tsx @@ -174,7 +174,12 @@ describe('canvas chrome for a renderable-but-unoffered spelling (#5837)', () => // Counter-probe. `element:text_input` is unoffered for its own reason and is // in no alias group; a fix keyed on "is excluded" would hand it a friendly // icon it has not earned. - for (const type of ['element:text_input', 'element:record_picker', 'element:form', 'ai:chat_window']) { + // `element:form` was a fourth probe here until `@objectstack/spec` 17.3.0 + // retired it from `PageComponentType` and objectui dropped its now-stale + // palette exclusion with it (objectui#7122). Three probes still exercise + // the same property on three different exclusion reasons, so the + // counter-probe keeps its force. + for (const type of ['element:text_input', 'element:record_picker', 'ai:chat_window']) { expect(PALETTE_EXCLUSIONS[type], `${type} is not an exclusion any more`).toBeTruthy(); expect(resolveBlockDisplayMeta(type), `${type} borrowed display meta`).toBeUndefined(); expect(resolveBlockTone(type), `${type} borrowed a tone`).toBe(BLOCK_CATEGORY_TONE.misc); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts index d8496eae9..67df6ff5e 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/exclusion-reason-truthfulness.test.ts @@ -82,7 +82,12 @@ * vocabulary against `PlaceholderRenderer` under `namespace: * 'protocol-placeholder'`, which would make this file answer "has a renderer" * for types that have only the dashed "Component Placeholder" scaffold — - * `user:profile` is one (measured). This repo's own language is clear that the + * `user:profile` was the measured example, until `@objectstack/spec` 17.3.0 + * retired the type from `PageComponentType` and objectui#7122 dropped its + * placeholder registration with it. The MECHANISM is unchanged and so is the + * refusal to opt in; only the illustration went, and it is left named here + * because it is the reading the argument was built on. This repo's own + * language is clear that the * scaffold is not a renderer: `views/app-launcher-renderer.tsx` describes the * state before it existed as "nothing rendered it, so a page that authored it * drew a dashed box", with placeholders registered the whole time. Opting in diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts index 3f6216e3f..4bc87b87f 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-types.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-types.ts @@ -149,16 +149,33 @@ export const PALETTE_EXCLUSIONS: Record = { // Shell singletons — chrome the app shell owns, not page content. 'app:launcher': 'shell singleton — lives in the app shell chrome', 'global:notifications': 'shell singleton — lives in the app shell header', - 'user:profile': 'shell singleton — lives in the app shell header', - // No renderer, by decision — and these two are the ones that MEASURE that way. - // Nothing registers `form` under `namespace: 'element'` (the form renderers are - // `ui:form` in `components/renderers/form/form.tsx` and `view:form` in - // `plugin-form/src/index.tsx`; the object-bound alternative named below, - // `object-form`, is registered in that same file), and no `ai:` namespace - // registration exists anywhere — `components/renderers/placeholders.tsx` keeps - // `ai:chat_window` out on purpose so a referencing schema fails loudly. + // + // ⛔ `user:profile` and `element:form` are NOT missing entries — they are + // RETIRED UPSTREAM. `@objectstack/spec` 17.3.0 dropped both from + // `PageComponentType` (measured: the enum went 34 → 32 options, lost set + // exactly `['user:profile', 'element:form']`, gained set empty), and this + // ledger's contract is that every entry names a REAL spec type — pinned by + // `__tests__/block-config.test.ts`'s "every exclusion names a real spec type + // and carries a reason". An exclusion for a type that no longer exists is a + // decision about nothing. + // + // The reconciliation was made on all sites at once rather than here alone + // (objectui#7122): `user:profile` also came out of `PROTOCOL_COMPONENTS` in + // `@object-ui/components`' `renderers/placeholders.tsx`, and out of the + // regenerated `@object-ui/cli` `known-schema-types.ts` that derives from it. + // Deleting only this entry was refused twice, and rightly: it would silence + // the one loud signal while objectui went on knowing a type the spec had + // retired. Nothing user-reachable was removed — neither type had a renderer. + // `user:profile` had only the dashed "Component Placeholder" scaffold (the + // sibling `exclusion-reason-truthfulness.test.ts` measures exactly that and + // records that this repo does not count the scaffold as a renderer), the + // shell's own profile affordance is a React slot and never this block type, + // and nothing anywhere registered `element:form`. + // + // No renderer, by decision. No `ai:` namespace registration exists anywhere — + // `components/renderers/placeholders.tsx` keeps `ai:chat_window` out on + // purpose so a referencing schema fails loudly. 'ai:chat_window': 'no inline renderer — the floating chat overlay (plugin-chatbot) is canonical', - 'element:form': 'no renderer — use the object-bound `object-form` block', // Renders fine — excluded because it is not PAGE CONTENT, not because it is // unrenderable. Both types have a registered renderer under `namespace: // 'element'` (`components/renderers/basic/text-input.tsx:161`, diff --git a/packages/cli/src/utils/known-schema-types.ts b/packages/cli/src/utils/known-schema-types.ts index 14e8d373b..a3d3c4189 100644 --- a/packages/cli/src/utils/known-schema-types.ts +++ b/packages/cli/src/utils/known-schema-types.ts @@ -415,7 +415,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'protocol-placeholder:record:highlights', 'protocol-placeholder:record:path', 'protocol-placeholder:record:related_list', - 'protocol-placeholder:user:profile', 'protocol-placeholder:view:calendar', 'protocol-placeholder:view:drawer', 'protocol-placeholder:view:gallery', @@ -648,7 +647,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [ 'ul', 'url', 'user', - 'user:profile', 'utility', 'vector', 'view', diff --git a/packages/components/src/renderers/placeholders.tsx b/packages/components/src/renderers/placeholders.tsx index e93c07296..98ca24bb0 100644 --- a/packages/components/src/renderers/placeholders.tsx +++ b/packages/components/src/renderers/placeholders.tsx @@ -84,7 +84,19 @@ const PROTOCOL_COMPONENTS = [ 'record:details', 'record:highlights', 'record:related_list', 'record:activity', 'record:chatter', 'record:path', 'app:launcher', 'nav:menu', 'nav:breadcrumb', - 'global:search', 'global:notifications', 'user:profile', + 'global:search', 'global:notifications', + // 'user:profile' intentionally omitted — `@objectstack/spec` 17.3.0 RETIRED + // it from `PageComponentType` (measured: the enum went 34 → 32 options, lost + // set exactly `['user:profile', 'element:form']`, gained set empty), so it is + // no longer a page block any author can legitimately write. It never had a + // renderer here either, only the dashed scaffold below, and the shell's own + // profile affordance is a React slot rather than this block type — so nothing + // user-reachable went with it. Same reasoning as `ai:chat_window` further + // down: a page schema still naming it now produces the loud "Unknown + // component type" panel instead of a silent grey box, which is what sends the + // fix to the source. Retired across all three sites at once (objectui#7122): + // here, the Studio palette ledger (`app-shell` `previews/block-types.ts`), + // and the regenerated `@object-ui/cli` `known-schema-types.ts`. // 13. Dashboard Widgets 'widget:metric', 'widget:bar', 'widget:line', 'widget:pie', 'widget:funnel', diff --git a/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx index 10e83e0e5..8746468f9 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.retiredIndexed.test.tsx @@ -51,7 +51,11 @@ const OBJECT_BODY = { label: 'Widget', fields: { name: { type: 'text', label: 'Name', required: true }, - owner_id: { type: 'lookup', label: 'Owner', indexed: true, helpText: 'Record owner.' }, + // `reference` is not this file's subject: it is here because a `lookup` + // without a target is un-storable at `@objectstack/spec` 17.3.0 and the + // designer now refuses one before the PUT (objectui#7122). Without it every + // save below would raise on the target instead of exercising `indexed`. + owner_id: { type: 'lookup', label: 'Owner', reference: 'account', indexed: true, helpText: 'Record owner.' }, code: { type: 'text', label: 'Code', indexed: false }, }, indexes: [{ name: 'by_owner', fields: ['owner_id'] }], diff --git a/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx b/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx index b6ff8f10b..fb0250abe 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.specKeyReference.test.tsx @@ -50,7 +50,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { FieldSchema } from '@objectstack/spec/data'; import { MetadataClient } from '@object-ui/data-objectstack'; import type { DesignerFieldDefinition } from '@object-ui/types'; @@ -65,6 +65,17 @@ import type { DesignerFieldDefinition } from '@object-ui/types'; * left behind. `carryOver` spreads the previous server def verbatim, so * without a tombstone the key rides straight back out to the route that * rejects it — and the object stays blocked forever. + * + * ⚠️ `legacy_id` carries the canonical `reference` ALONGSIDE the misspelling, + * which it did not need to before `@objectstack/spec` 17.3.0. 17.3.0 makes a + * target-less `lookup` un-storable, and objectui#7122 fixed the product half + * by refusing such a field client-side before the PUT — so a fixture whose + * `legacy_id` had ONLY the retired spelling would now make every save in this + * file raise, and every assertion below would be measuring the guard instead + * of the strip. Both keys present is also the more faithful legacy document: + * a server that stored `referenceTo` and a later client that wrote + * `reference`. The strip assertion keeps its full force either way — the + * retired key is refused BY NAME, so its presence alone is the 422. */ const OBJECT_BODY = { name: 'probe_widget', @@ -72,7 +83,7 @@ const OBJECT_BODY = { fields: { name: { type: 'text', label: 'Name', required: true }, owner_id: { type: 'lookup', label: 'Owner', reference: 'account', inlineHelpText: 'Record owner.' }, - legacy_id: { type: 'lookup', label: 'Legacy', referenceTo: 'contact' }, + legacy_id: { type: 'lookup', label: 'Legacy', reference: 'contact', referenceTo: 'contact' }, }, }; @@ -256,16 +267,16 @@ describe('objectui#6041 · WRITE — the save carries `reference`, never `refere expect(FieldSchema.safeParse(fields.legacy_id).success).toBe(true); }); - it('a HALF-FILLED draft — type `lookup`, target left empty — still saves, exactly as before', async () => { - // The behavioural edge this card had to measure. The spec's prose calls - // `reference` "Required for relationship types", but that requirement is - // NOT enforced by the zod parse at 17.2.0: `{ type: 'lookup', label: 'L' }` - // parses green at field level AND through `ObjectSchema`. `undefined` is - // dropped by `JSON.stringify` under either spelling, so the wire bytes are - // byte-identical before and after this fix. + it('a HALF-FILLED draft — type `lookup`, target left empty — is REFUSED, with no PUT and a visible reason', async () => { + // ⭐ INVERTED at `@objectstack/spec` 17.3.0 (objectui#7122, ruled item 4). // - // ⚠ This case would still pass on a revert, and says so deliberately: it - // exists to prove the rename did NOT newly block a draft. + // It used to read "still saves, exactly as before": the spec's prose called + // `reference` "Required for relationship types" while the 17.2.0 zod parse + // did not enforce it, so `{ type: 'lookup', label: 'L' }` parsed green and + // the designer persisted target-less drafts. 17.3.0 enforces it, and a PUT + // of such a draft returns 422 `INVALID_METADATA` for the WHOLE object — + // blocking every later save of it, not just this field. So the product was + // fixed rather than the pin: the draft is refused in the client. await renderPage(); const next: DesignerFieldDefinition[] = [ ...designerProps!.fields, @@ -274,10 +285,37 @@ describe('objectui#6041 · WRITE — the save carries `reference`, never `refere await act(async () => { designerProps!.onFieldsChange!(next); }); + + // The whole point: nothing reached the wire. `onFieldsChange` is + // fire-and-forget, so a guard that threw anywhere but inside the page's + // save `try` would surface as an unhandled rejection and show the author + // nothing — which is the silent failure this refusal exists to end. + await waitFor(() => + expect(screen.getByTestId('metadata-fields-page-error').textContent).toMatch( + /needs a `reference`/, + ), + ); + expect(puts).toHaveLength(0); + // …and the author is told WHICH field, because an object save can carry + // dozens and a message that only says "a lookup" is not actionable. + expect(screen.getByTestId('metadata-fields-page-error').textContent).toContain('half_id'); + }); + + it('the same draft saves once its target is picked — the control for the refusal above', async () => { + // Without this, the refusal is satisfied by a guard that blocks every + // `lookup`, which would break authoring rather than protect it. + await renderPage(); + const next: DesignerFieldDefinition[] = [ + ...designerProps!.fields, + { id: 'fld_half', name: 'half_id', label: 'Half', type: 'lookup', referenceTo: 'contact' }, + ]; + await act(async () => { + designerProps!.onFieldsChange!(next); + }); await waitFor(() => expect(puts).toHaveLength(1)); const fields = savedFields(); - expect('reference' in fields.half_id).toBe(false); + expect(fields.half_id.reference).toBe('contact'); expect('referenceTo' in fields.half_id).toBe(false); expect(FieldSchema.safeParse(fields.half_id).success).toBe(true); }); diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index f6aac3b9a..636d836bb 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -253,6 +253,54 @@ function fromDesignerField( * the one that matters: it raises BEFORE the request, so a refused list issues * no PUT at all. */ +/** + * Field types whose `reference` (the target object a relationship links to) + * `@objectstack/spec` requires to be present and non-empty — the sibling of + * `MetadataService`'s list, kept here for the same reason this file keeps its + * own `toFieldsMap` and `carryOver`: the two writers convert different input + * types on different paths and neither owns the other's. + * + * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for + * every one of `FieldSchema`'s 49 declared types: exactly `lookup` and + * `master_detail` are refused for a missing target, and no other type is + * refused at all on that minimal document. + */ +const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; + +/** + * Refuse a relationship field whose target is missing — BEFORE the PUT. + * + * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` + * and `master_detail` (a `custom` refinement at path `reference`). Against a + * matched backend, PUTting a half-filled relationship draft returns `422 + * INVALID_METADATA` for the WHOLE object document — so the damage is not + * confined to the incomplete field: every later save of that object fails the + * same way until the draft is completed or removed. At 17.2.0 the requirement + * was prose only and this page persisted such drafts freely. + * + * The maintainer's reconciliation for that change (objectui#7122, 2026-09-05, + * ruled item 4) is to keep the incomplete draft in the client and never PUT it. + * This raises inside the caller's save `try`, so the message lands in the + * page's existing error surface — the same banner a nameless or duplicated + * field already produces, and no new UI affordance. + */ +function assertRelationshipTargetPresent( + field: { type?: string; reference?: unknown }, + fieldName: string, + writer: string, +): void { + if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; + const reference = field?.reference; + if (typeof reference === 'string' && reference.trim() !== '') return; + throw new Error( + `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + + '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + + 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + + '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + + 'this field. Pick the target object, or change the field to a non-relationship type.', + ); +} + function toFieldsMap( next: DesignerFieldDefinition[], prevFields: Record, @@ -288,7 +336,13 @@ function toFieldsMap( const prev = Object.prototype.hasOwnProperty.call(prevFields, name) ? prevFields[name] : undefined; - entries.push([name, fromDesignerField(designed, prev)]); + const emitted = fromDesignerField(designed, prev); + // Checked on the EMITTED entry, not on `designed`: `fromDesignerField` + // merges the carried-over server entry underneath the designer's values, so + // the target may legitimately arrive from `prev` on a field the author did + // not touch. Reading `designed.referenceTo` alone would refuse those. + assertRelationshipTargetPresent(emitted, name, '[MetadataFieldsPage]'); + entries.push([name, emitted]); }); return Object.fromEntries(entries); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 97d62a19c..2771cb39f 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -446,7 +446,7 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // above already said `rejects`.) inputs: [ { name: 'columns', type: 'enum', label: 'Columns', enum: ['1', '2', '3', '4'], defaultValue: '2', description: 'Number of columns for field layout (1-4)' }, - { name: 'sections', type: 'array', label: 'Sections', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields.' }, + { name: 'sections', type: 'array', label: 'Sections', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, six of which this renderer already honours through DetailSection: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette). Two are declared upstream and NOT read here: `group`, which objectui does not implement on a detail section, and `hideEmpty`, deliberately retired in objectui#7129 (maintainer 2026-09-01) in favour of DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle — authoring it does nothing on this renderer. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature.' }, { name: 'fields', type: 'array', label: 'Fields', description: 'Explicit field list (overrides highlightFields)' }, // `hideFields` is DECLARED, not merely honoured (objectui#3808). The spec // declares it (objectstack#5611) and `RecordDetailsRenderer` has read it diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx index aaa36479b..af9fb28a6 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx @@ -16,7 +16,7 @@ * * | party | said | * |-------------------------------------------|----------------------------| - * | `@objectstack/spec` `RecordDetailsProps` | ⛔ REFUSES the key | + * | `@objectstack/spec` `RecordDetailsProps` | ⛔ REFUSED it (17.2.0; see the 2026-09-05 note below) | * | `@object-ui/types` `DetailViewSection` | ✅ declared it | * | `./zod/views.zod.ts` `DetailViewSectionSchema` | ⛔ absent | * | `RecordDetailsRenderer` | ✅ honoured it | @@ -29,6 +29,24 @@ * refusing, keep the mirror absent. `DetailSection`'s auto-hide heuristic * (4 fields / 25% empty; 3 / 20% on mobile) is now the WHOLE contract. * + * ## ⚠️ 2026-09-05 — the spec moved back, and this file now records a DIVERGENCE + * + * `@objectstack/spec` 17.3.0 RE-DECLARES `hideEmpty` on the `record:details` + * section entry (measured: the entry went 4 → 12 member keys, `hideEmpty` among + * the eight gained, lost set empty). One clause of the ruling — "keep the spec + * refusing" — therefore describes nothing any more, through no act of this + * repo. + * + * objectui's own three parties are UNCHANGED and still agree: the type does not + * declare it, the mirror omits it, the renderer does not read it. 1/4 below is + * pointed at the measured upstream truth so the divergence is a stated fact + * rather than a red test; every other assertion is untouched. + * + * ⇒ Whether objectui re-adopts the key is a MAINTAINER decision (it reverses + * the ruling and re-adds a deleted control) and is reported on objectui#7122, + * NOT taken here. If it is re-adopted, this file is the checklist: three + * parties to move, not one. + * * ## Why one file * * Alignment is a claim about FOUR sources at once, and each of them is green on @@ -114,23 +132,61 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('DetailViewSection.hideEmpty is retired — all four parties agree (#7129)', () => { - it('1/4 — `@objectstack/spec` REFUSES the key on a `record:details` section', () => { +describe('DetailViewSection.hideEmpty is retired in objectui — and the spec re-declared it at 17.3.0 (#7129)', () => { + it('1/4 — ⚠️ `@objectstack/spec` 17.3.0 DECLARES the key again: the fourth party moved', () => { + // ⭐ READ THIS BEFORE CHANGING ANYTHING ELSE IN THIS FILE. + // + // This assertion is inverted from what it said at 17.2.0, and the inversion + // is NOT objectui following the spec back. It records that the ruling's + // fourth party changed its answer underneath the ruling. + // + // The 2026-09-01 ruling (总监批 #28) converged four disagreeing contracts on + // the spec's answer, in these words: "retire the declaration and the read, + // keep the spec refusing, keep the mirror absent". `@objectstack/spec` + // 17.3.0 then re-declared `hideEmpty` on the `record:details` section entry + // — measured, as one of eight keys the entry gained (4 → 12 members, lost + // set empty). So the clause "keep the spec refusing" is no longer a + // description of anything, through no act of this repo. + // + // ⛔ What has NOT changed, and what this file still pins in full: objectui's + // three parties still agree the key is retired. 2/4 (the mirror omits it), + // 3/4 (the type does not declare it) and 4/4 (nothing reads it, end to end) + // are untouched below. Authoring `hideEmpty` on this renderer still does + // nothing, which is the behaviour the ruling ordered. + // + // ⇒ Whether objectui should now re-adopt the key is a MAINTAINER decision — + // it would reverse a five-day-old ruling and re-add a control the ruling + // deleted — and it is reported on objectui#7122 rather than taken here. The + // assertion is pointed at the measured truth so that the divergence is a + // stated, pinned fact instead of a red test somebody eventually deletes. const parsed = RecordDetailsProps.safeParse({ sections: [{ label: 'Contact', fields: ['phone'], hideEmpty: true }], }); - // Envelope, not a bare failure: the code, and the key it names. - expect(parsed.success).toBe(false); - expect(parsed.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); - const refused = parsed.error?.issues.flatMap( + expect(parsed.success).toBe(true); + // Value reachability, not just key presence: a declared-but-unusable key + // would leave the divergence smaller than this comment claims. + expect( + (parsed.data as { sections?: { hideEmpty?: boolean }[] })?.sections?.[0]?.hideEmpty, + ).toBe(true); + + // CONTROL, and it is what stops this from reading as "the spec went soft": + // the section object is still STRICT, so an undeclared key is still refused + // by name. `hideEmpty` parses because it was DECLARED, not because + // unrecognized keys stopped being refused. + const undeclared = RecordDetailsProps.safeParse({ + sections: [{ label: 'Contact', fields: ['phone'], __objectui_7129_probe__: true }], + }); + expect(undeclared.success).toBe(false); + expect(undeclared.error?.issues.map((i) => i.code)).toContain('unrecognized_keys'); + const refused = undeclared.error?.issues.flatMap( (i) => (i as unknown as { keys?: string[] }).keys ?? [], ); - expect(refused).toContain('hideEmpty'); + expect(refused).toContain('__objectui_7129_probe__'); // CONTROL: a declared section key parses AND its value survives, so the - // refusal above is about `hideEmpty` and not about a section object the - // probe built wrong. + // reading above is about the section object the probe built and not about + // a probe that built one wrong. const control = RecordDetailsProps.safeParse({ sections: [{ label: 'Contact', fields: ['phone'], columns: 2 }], }); diff --git a/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts index 9bf2d39e8..d714f93b3 100644 --- a/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts +++ b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts @@ -33,7 +33,12 @@ * live runtime read. Growing `ObjectSchemaClientExtensions` fails the * key-set assertion, forcing the promote-upstream / justify-here * decision consciously (same forcing function as - * `object-view-spec-parity.test.ts`). + * `object-view-spec-parity.test.ts`). At `@objectstack/spec` 17.3.0 that + * surface is EMPTY: `editMode`, its only member, was PROMOTED UPSTREAM — + * which is route one of this very guard, arriving on its own. The + * assertions below moved from "the spec must not declare it" to "the spec + * declares it and the delta no longer restates it", which is the same + * invariant (exactly one declaration of the key) read from the other side. * * When one of these fails, do NOT edit this file first. Decide whether the * key belongs upstream in `@objectstack/spec` (promote it — the #5362 route @@ -59,21 +64,37 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? true : false; type Assert = T; +type Extends = A extends B ? true : false; // 1. Structural derivation — the alias IS spec type + client delta. type _derivation = Assert< Equal >; -// 4. The client delta stays exactly the measured set. `editMode` is read by -// `@object-ui/app-shell` (`utils/recordFormNavigation.ts`, routed by -// `AppContent`'s central `handleEdit` dispatcher). +// 4. The client delta stays exactly the measured set — and at 17.3.0 that set +// is EMPTY. `editMode` was the one member; the spec adopted it (measured: +// `ObjectSchema`'s accept set 42 → 43 keys, gained set exactly `['editMode']`, +// lost set empty, declared as the same `'page' | 'modal'` union), so the key is +// carried by `ServiceObject` now and restating it here would be the two- +// declarations fork this guard exists to prevent. The app-shell read +// (`utils/recordFormNavigation.ts`, routed by `AppContent`'s central +// `handleEdit` dispatcher) is unchanged and still typed — through the +// derivation instead of through the delta. type _extensionSurface = Assert< - Equal + Equal +>; + +// …and `editMode` really is reachable on the derived type, not merely absent +// from the delta. This is the half a `keyof … = never` assertion cannot state: +// an empty delta would read identically if the spec had NOT adopted the key, +// and the member would then be silently unwritable. +type _editModeStillTyped = Assert< + Extends<{ name: string; fields: Record; editMode: 'page' }, ObjectSchemaMetadata> >; // 2. The three keys #5362 measured as read-but-undeclared are admitted, and -// a typed document can carry the client extension alongside them. +// a typed document can carry `editMode` alongside them — carried by the spec's +// own `ServiceObject` since 17.3.0, no longer by a local delta member. const derivedAdmitsTheThreeKeys: ObjectSchemaMetadata = { name: 'task', fields: { @@ -149,27 +170,34 @@ describe('ObjectSchemaMetadata spec derivation (#5362)', () => { expect(keys).toContain('nameField'); }); - it('does not declare the client extension — editMode stays an objectui-side member', () => { - // If this starts failing, the spec has adopted `editMode`: retire it from - // `ObjectSchemaClientExtensions` and let the derivation carry it. - expect(keys).not.toContain('editMode'); + it('DECLARES editMode — the spec adopted it at 17.3.0 and the client delta retired its copy', () => { + // The prescription the previous version of this test carried ("If this + // starts failing, the spec has adopted `editMode`: retire it from + // `ObjectSchemaClientExtensions` and let the derivation carry it") has been + // executed. This is its other half: the key must be present HERE, or the + // retirement removed the declaration without anything replacing it. + expect(keys).toContain('editMode'); }); - it('accepts the spec-shaped document through the spec parse (value reachability, not just key presence)', () => { - const { editMode: _clientOnly, ...specShaped } = derivedAdmitsTheThreeKeys; - const parsed = SpecObjectSchema.safeParse(specShaped); + it('accepts the whole document — editMode included — through the spec parse', () => { + // Key presence is not value reachability: `editMode` could be declared and + // still refuse `'page'`. The fixture is passed WHOLE, with no key stripped + // out, which is the assertion that would have failed at 17.2.0. + const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys); expect(parsed.success).toBe(true); + expect(parsed.data?.editMode).toBe('page'); }); - it('rejects the client extension at the spec parse — editMode is client-side ONLY', () => { - // The spec parse is strict on unrecognized keys (objectstack#4001), so a - // published document carrying `editMode` is rejected loudly, not dropped. - // This pins what "client extension" means: the member exists on the CLIENT - // type for documents served by client-side data sources, and it cannot - // ride a spec-validated publish path. If the spec ever adopts `editMode`, - // this test and the absence test above both flip — retire the extension - // member and let the derivation carry the key. - const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys); + it('still refuses an INVENTED client key by name — the strictness that made editMode a delta has not gone soft', () => { + // The control for the two assertions above, and the reason they are not + // vacuous. `ObjectSchema` is strict on unrecognized keys (objectstack#4001); + // if it had instead been loosened to a passthrough, `editMode` would parse + // green for a reason that has nothing to do with adoption and this file + // would report a settlement that did not happen. + const parsed = SpecObjectSchema.safeParse({ + ...derivedAdmitsTheThreeKeys, + editModeX: 'page', + }); expect(parsed.success).toBe(false); if (parsed.success) return; const unrecognized = parsed.error.issues.find( @@ -178,7 +206,7 @@ describe('ObjectSchemaMetadata spec derivation (#5362)', () => { expect(unrecognized).toBeDefined(); expect( (unrecognized as { keys?: string[] } | undefined)?.keys, - ).toEqual(['editMode']); + ).toEqual(['editModeX']); }); it('keeps the compile-time fixtures alive', () => { diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index ea009bcee..185d0024b 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -895,36 +895,42 @@ import type { ServiceObject } from '@objectstack/spec/data'; /** * Client-side members the objectui runtime reads on the object document but * `@objectstack/spec` does not declare. Every member here must cite a live - * runtime read — this interface is the measured client DELTA on top of the - * spec document, not a place to restate spec keys (restating them would - * recreate the hand-written fork objectui#5362 retired). The member list is - * pinned by `__tests__/object-schema-metadata-spec-derivation.test.ts`, so - * growing it is a conscious decision: promote the key upstream to the spec, - * or add it here with the runtime read that justifies it. - */ -export interface ObjectSchemaClientExtensions { - /** - * Default UI mode for record create/edit interactions. - * - * - `'modal'` (default) — open the record form inside a `ModalForm` dialog - * overlaid on top of the current view. Suitable for short forms and - * quick edits. - * - `'page'` — navigate to a dedicated full-screen route - * (`/{objectName}/new` for create, `/{objectName}/record/:id/edit` for - * edit). URLs are deep-linkable, refresh-safe, and integrate with the - * browser back button. Recommended for objects with many fields, - * `tabbed` / `wizard` form layouts, or scenarios that benefit from - * shareable links to the create/edit form. - * - * The host application reads this flag in its central `handleEdit` - * dispatcher (see `@object-ui/app-shell` `AppContent` and - * `utils/recordFormNavigation.ts`) — switching the value requires no code - * changes. - * - * @default 'modal' - */ - editMode?: 'modal' | 'page'; -} + * runtime read — this type is the measured client DELTA on top of the spec + * document, not a place to restate spec keys (restating them would recreate + * the hand-written fork objectui#5362 retired). The member list is pinned by + * `__tests__/object-schema-metadata-spec-derivation.test.ts`, so growing it is + * a conscious decision: promote the key upstream to the spec, or add it here + * with the runtime read that justifies it. + * + * ## The delta is EMPTY today, and that is the pinned state + * + * `editMode` was the one member. `@objectstack/spec` 17.3.0 ADOPTED it — + * measured on the installed build, `ObjectSchema`'s accept set went 42 → 43 + * keys with gained set exactly `['editMode']` and an empty lost set, declared + * as `editMode?: 'page' | 'modal'`, the same union this file carried. So the + * key is no longer a client delta, and restating it here would be exactly the + * fork this type exists to prevent: two declarations of one key, with the + * local one shadowing the spec's for every reader. + * + * The retirement is what the pin's own docblock PRESCRIBED for this event + * ("If the spec ever adopts `editMode`, this test and the absence test above + * both flip — retire the extension member and let the derivation carry the + * key"), so it is executing a standing instruction, not a new decision. + * + * ⚠️ Nothing about `editMode` is removed from the product: it stays authorable + * and stays typed on {@link ObjectSchemaMetadata}, now carried by + * `ServiceObject`. `app-shell`'s `utils/recordFormNavigation.ts` read is + * unchanged. What DID change is that a published, spec-validated object + * document may now carry it — at 17.2.0 the spec refused the key by name. + * + * The type is kept (rather than deleted) because it is exported from + * `@object-ui/types` and because the derivation `spec type + client delta` is + * the shape this package pins; an empty delta states "objectui adds nothing + * here today", which is a fact worth keeping addressable. `Record` rather than `{}`: `keyof` answers `never`, and it does not trip + * `@typescript-eslint/no-empty-object-type`. + */ +export type ObjectSchemaClientExtensions = Record; /** * Object schema definition — the object document a data source's From 2761082b7f5c9629b2d7e055eab15a329e04894d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:13:57 +0000 Subject: [PATCH 11/13] =?UTF-8?q?wip(spec-17.3.0):=20two=20files=20in=20pr?= =?UTF-8?q?ogress=20=E2=80=94=20INCOMPLETE=20AND=20UNREVIEWED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rescue commit made by the PM seat after the container running the dispatched agent was restarted mid-task. Committed so the work survives; it is NOT a claim that it is correct or finished. Observed state at rescue time, and nothing beyond it: M packages/plugin-designer/src/MetadataFieldsPage.tsx M packages/types/src/__tests__/export-options-spec-parity.test.ts 56 insertions / 49 deletions. No gate was run against this content. Not present: any change to an eager-closure ceiling or performance budget. The maintainer-authorised ceiling raise had still not been made. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../src/MetadataFieldsPage.tsx | 96 +++++++++---------- .../export-options-spec-parity.test.ts | 9 +- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/packages/plugin-designer/src/MetadataFieldsPage.tsx b/packages/plugin-designer/src/MetadataFieldsPage.tsx index 636d836bb..bd007ff5f 100644 --- a/packages/plugin-designer/src/MetadataFieldsPage.tsx +++ b/packages/plugin-designer/src/MetadataFieldsPage.tsx @@ -201,6 +201,54 @@ function fromDesignerField( }; } +/** + * Field types whose `reference` (the target object a relationship links to) + * `@objectstack/spec` requires to be present and non-empty — the sibling of + * `MetadataService`'s list, kept here for the same reason this file keeps its + * own `toFieldsMap` and `carryOver`: the two writers convert different input + * types on different paths and neither owns the other's. + * + * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for + * every one of `FieldSchema`'s 49 declared types: exactly `lookup` and + * `master_detail` are refused for a missing target, and no other type is + * refused at all on that minimal document. + */ +const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; + +/** + * Refuse a relationship field whose target is missing — BEFORE the PUT. + * + * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` + * and `master_detail` (a `custom` refinement at path `reference`). Against a + * matched backend, PUTting a half-filled relationship draft returns `422 + * INVALID_METADATA` for the WHOLE object document — so the damage is not + * confined to the incomplete field: every later save of that object fails the + * same way until the draft is completed or removed. At 17.2.0 the requirement + * was prose only and this page persisted such drafts freely. + * + * The maintainer's reconciliation for that change (objectui#7122, 2026-09-05, + * ruled item 4) is to keep the incomplete draft in the client and never PUT it. + * This raises inside the caller's save `try`, so the message lands in the + * page's existing error surface — the same banner a nameless or duplicated + * field already produces, and no new UI affordance. + */ +function assertRelationshipTargetPresent( + field: { type?: string; reference?: unknown }, + fieldName: string, + writer: string, +): void { + if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; + const reference = field?.reference; + if (typeof reference === 'string' && reference.trim() !== '') return; + throw new Error( + `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + + '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + + 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + + '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + + 'this field. Pick the target object, or change the field to a non-relationship type.', + ); +} + /** * Key the designer's field list by field NAME — the shape `ObjectSchema.fields` * requires — and refuse the three lists that shape cannot carry @@ -253,54 +301,6 @@ function fromDesignerField( * the one that matters: it raises BEFORE the request, so a refused list issues * no PUT at all. */ -/** - * Field types whose `reference` (the target object a relationship links to) - * `@objectstack/spec` requires to be present and non-empty — the sibling of - * `MetadataService`'s list, kept here for the same reason this file keeps its - * own `toFieldsMap` and `carryOver`: the two writers convert different input - * types on different paths and neither owns the other's. - * - * Measured against the installed 17.3.0 by parsing `{ type, label: 'L' }` for - * every one of `FieldSchema`'s 49 declared types: exactly `lookup` and - * `master_detail` are refused for a missing target, and no other type is - * refused at all on that minimal document. - */ -const RELATIONSHIP_TYPES_REQUIRING_REFERENCE = ['lookup', 'master_detail']; - -/** - * Refuse a relationship field whose target is missing — BEFORE the PUT. - * - * `@objectstack/spec` 17.3.0 made `reference` a hard requirement on `lookup` - * and `master_detail` (a `custom` refinement at path `reference`). Against a - * matched backend, PUTting a half-filled relationship draft returns `422 - * INVALID_METADATA` for the WHOLE object document — so the damage is not - * confined to the incomplete field: every later save of that object fails the - * same way until the draft is completed or removed. At 17.2.0 the requirement - * was prose only and this page persisted such drafts freely. - * - * The maintainer's reconciliation for that change (objectui#7122, 2026-09-05, - * ruled item 4) is to keep the incomplete draft in the client and never PUT it. - * This raises inside the caller's save `try`, so the message lands in the - * page's existing error surface — the same banner a nameless or duplicated - * field already produces, and no new UI affordance. - */ -function assertRelationshipTargetPresent( - field: { type?: string; reference?: unknown }, - fieldName: string, - writer: string, -): void { - if (!RELATIONSHIP_TYPES_REQUIRING_REFERENCE.includes(String(field?.type))) return; - const reference = field?.reference; - if (typeof reference === 'string' && reference.trim() !== '') return; - throw new Error( - `${writer} cannot save the field \`${fieldName}\`: a \`${field?.type}\` field needs a ` + - '`reference` naming the object it links to, and this one has none. `@objectstack/spec` ' + - 'requires it (17.3.0), so the server refuses the whole object document with 422 ' + - '`INVALID_METADATA` — which would then block EVERY later save of this object, not just ' + - 'this field. Pick the target object, or change the field to a non-relationship type.', - ); -} - function toFieldsMap( next: DesignerFieldDefinition[], prevFields: Record, diff --git a/packages/types/src/__tests__/export-options-spec-parity.test.ts b/packages/types/src/__tests__/export-options-spec-parity.test.ts index bb4dd2806..fa02dd681 100644 --- a/packages/types/src/__tests__/export-options-spec-parity.test.ts +++ b/packages/types/src/__tests__/export-options-spec-parity.test.ts @@ -267,7 +267,14 @@ describe('the zod mirror binds exportOptions to the spec field (objectui#6956)', expect(issues.some((i) => i.path[0] === 'exportOptions')).toBe(true); const messages = issues.map((i) => i.message).join('\n'); expect(messages).toMatch(/pdf/); - expect(messages).toMatch(/8010|1301/); + // Was `/8010|1301/` — the issue numbers. `@objectstack/spec` 17.3.0 + // stripped those citations from its refusal messages and kept the + // prescriptive half, so this assertion moves to that half — exactly as + // the sibling pin in this file's `objectui#4535` block already does. The + // durable content of a refusal is the repair it prescribes; the citation + // is the half upstream felt free to drop. + expect(messages).toMatch(/'csv', 'xlsx' and 'json'/); + expect(messages).toMatch(/os migrate meta/); } // And the TYPE refuses it too, in both spellings — the renderer's contract // cannot be handed a value the platform refuses at publish. From dc82492a9850eb6c1e1f5ce94bf5f2ac851c7eff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:35:10 +0000 Subject: [PATCH 12/13] fix(spec-17.3.0): carry the ninth platform capability, and pin refusals on their ruling not its issue number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two families of red from the `@objectstack/spec` 17.3.0 bump, both of which executed an existing ruling rather than making a new one. `manage_org_presentation` (3 assertions, `packages/fields`). 17.3.0 declares a ninth platform capability; `CURATED_CAPABILITY_LABELS` carried eight, so the spec-parity pin failed in the "spec added a member" direction it was built for (objectui#6285 already ruled the curated set IS the spec's set, both ways). The label is the spec artifact's own (`Manage Organization Presentation`), not an invented one. Authored in all ten locale packs and in `useFieldTranslation.ts`, which the widget's own docblock requires of any edit to that list; the nine non-English strings are composed from each pack's established sibling vocabulary (`manage_org_users`, `manage_platform_settings`) rather than machine-translated, and a native review pass is welcome on them. The `#7496` token (14 assertions, 3 files). Every one was `expected '' to contain '#7496'`, while the sibling assertions on `RELATIVE path only` stayed green: 17.3.0 kept all five refusals and their reasoning and only restated provenance as `(ruled 2026-08-11)`. The pins asserted the citation FORM, not the citation — the objectui#7702 defect shape. Re-pinned on the durable property via a documented `CITES_ITS_RULING`, which still discriminates the spec's prose from a local hand-written sentence (the load-bearing mutation probe in `ObjectForm.submitRedirect.test.tsx`'s header). Not re-pinned to the new prose verbatim, which would only move the brittleness. Part of #7122 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../src/components/submitRedirect.test.ts | 26 +++++++++++++++++-- .../widgets/CapabilityMultiSelectField.tsx | 1 + .../fields/src/widgets/useFieldTranslation.ts | 1 + packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/ObjectForm.submitRedirect.test.tsx | 17 ++++++++++-- .../src/WizardForm.submitRedirect.test.tsx | 15 ++++++++++- 15 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/console/src/components/submitRedirect.test.ts b/apps/console/src/components/submitRedirect.test.ts index c2d22e81d..69b7e839d 100644 --- a/apps/console/src/components/submitRedirect.test.ts +++ b/apps/console/src/components/submitRedirect.test.ts @@ -114,6 +114,27 @@ const OUT_OF_CONTRACT: Array<[label: string, url: string]> = [ ['a token with inner spacing', '/thanks?x={{ record.id }}'], ]; +/** + * A refusal CITES THE RULING it comes from — the durable property, in either + * spelling the spec has used for it. + * + * This assertion's job is to discriminate the spec's own author-facing + * prescription from a locally hand-written sentence (mutation probe 3 in + * `ObjectForm.submitRedirect.test.tsx`'s header depends on it). It used to be + * spelled `toContain('#7496')`, which pinned the CITATION FORM rather than the + * citation: `@objectstack/spec` 17.3.0 kept every refusal and its reasoning and + * only restated the provenance as `(ruled 2026-08-11)` instead of `#7496`, and + * 14 assertions across three files went red for a token while the behaviour + * they guard never moved. Same defect shape as objectui#7702 — a pin asserting + * an incidental token instead of the durable property. + * + * ⛔ Not re-pinned to the new prose verbatim, which would only move the + * brittleness one release along. What is asserted is that provenance is + * PRESENT and machine-recognisable in one of the two forms upstream uses; a + * hand-written local sentence carries neither. + */ +const CITES_ITS_RULING = /\(ruled \d{4}-\d{2}-\d{2}\)|#\d{3,}/; + describe('the shape verdict is the contract’s, for every family', () => { it.each(IN_CONTRACT)('accepts %j, and so does the schema', (url) => { expect(specAccepts(url)).toBe(true); @@ -130,11 +151,12 @@ describe('the shape verdict is the contract’s, for every family', () => { expect(verdict.ok).toBe(false); if (verdict.ok) return; // Refusals are quotable: the author gets the spec's own prescription, which - // names the key and cites the ruling it comes from, so the sentence read + // names the key and cites the ruling it comes from (in whichever form that + // citation currently takes — see CITES_ITS_RULING), so the sentence read // here is the one the authoring door would have said. An empty or generic // message would be a silent drop wearing an error's clothes. expect(verdict.refusal).toMatch(/`(submitBehavior\.)?url`/); - expect(verdict.refusal).toContain('#7496'); + expect(verdict.refusal).toMatch(CITES_ITS_RULING); expect(verdict.refusal.length).toBeGreaterThan(40); }); diff --git a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx index cf60991d6..afc100dd3 100644 --- a/packages/fields/src/widgets/CapabilityMultiSelectField.tsx +++ b/packages/fields/src/widgets/CapabilityMultiSelectField.tsx @@ -130,6 +130,7 @@ const CURATED_CAPABILITY_LABELS = new Set([ 'manage_users', 'manage_org_users', 'manage_metadata', + 'manage_org_presentation', 'manage_platform_settings', // The spec spells these three with a dot (`setup.access`, `setup.write`, // `studio.access`). `labelFor` normalises dots to underscores before building diff --git a/packages/fields/src/widgets/useFieldTranslation.ts b/packages/fields/src/widgets/useFieldTranslation.ts index bb6fe1fda..d9be0bb86 100644 --- a/packages/fields/src/widgets/useFieldTranslation.ts +++ b/packages/fields/src/widgets/useFieldTranslation.ts @@ -151,6 +151,7 @@ const FIELD_DEFAULTS: Record = { 'capability.label.manage_users': 'Manage Users', 'capability.label.manage_org_users': 'Manage Organization Users', 'capability.label.manage_metadata': 'Manage Metadata', + 'capability.label.manage_org_presentation': 'Manage Organization Presentation', 'capability.label.manage_platform_settings': 'Manage Platform Settings', 'capability.label.setup_access': 'Setup Access', 'capability.label.setup_write': 'Write Settings', diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 70a769597..84ebbdf89 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -19,6 +19,7 @@ const ar = { manage_users: "إدارة المستخدمين", manage_org_users: "إدارة مستخدمي المؤسسة", manage_metadata: "إدارة البيانات الوصفية", + manage_org_presentation: "إدارة عرض المؤسسة", manage_platform_settings: "إدارة إعدادات المنصة", setup_access: "الوصول إلى الإعداد", setup_write: "كتابة الإعدادات", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index cc9e0fae3..36001f7ec 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -19,6 +19,7 @@ const de = { manage_users: "Benutzer verwalten", manage_org_users: "Organisationsbenutzer verwalten", manage_metadata: "Metadaten verwalten", + manage_org_presentation: "Organisationsdarstellung verwalten", manage_platform_settings: "Plattformeinstellungen verwalten", setup_access: "Zugriff auf die Einrichtung", setup_write: "Einstellungen schreiben", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 165f8ce99..3f28f755b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -30,6 +30,7 @@ const en = { manage_users: 'Manage Users', manage_org_users: 'Manage Organization Users', manage_metadata: 'Manage Metadata', + manage_org_presentation: 'Manage Organization Presentation', manage_platform_settings: 'Manage Platform Settings', setup_access: 'Setup Access', setup_write: 'Write Settings', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 9f5338586..b74c29121 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -19,6 +19,7 @@ const es = { manage_users: "Gestionar usuarios", manage_org_users: "Gestionar usuarios de la organización", manage_metadata: "Gestionar metadatos", + manage_org_presentation: "Gestionar la presentación de la organización", manage_platform_settings: "Gestionar la configuración de la plataforma", setup_access: "Acceso a la configuración", setup_write: "Escritura de la configuración", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f9ebeab6a..8d63debf5 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -19,6 +19,7 @@ const fr = { manage_users: "Gérer les utilisateurs", manage_org_users: "Gérer les utilisateurs de l'organisation", manage_metadata: "Gérer les métadonnées", + manage_org_presentation: "Gérer la présentation de l'organisation", manage_platform_settings: "Gérer les paramètres de la plateforme", setup_access: "Accès à la configuration", setup_write: "Écriture des paramètres", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 1ec0420a9..c2653b84a 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -19,6 +19,7 @@ const ja = { manage_users: "ユーザー管理", manage_org_users: "組織ユーザーの管理", manage_metadata: "メタデータ管理", + manage_org_presentation: "組織表示の管理", manage_platform_settings: "プラットフォーム設定の管理", setup_access: "設定へのアクセス", setup_write: "設定の書き込み", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 9afcaeea4..ea9a31f5b 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -19,6 +19,7 @@ const ko = { manage_users: "사용자 관리", manage_org_users: "조직 사용자 관리", manage_metadata: "메타데이터 관리", + manage_org_presentation: "조직 표시 관리", manage_platform_settings: "플랫폼 설정 관리", setup_access: "설정 접근", setup_write: "설정 쓰기", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 84d2c600c..b560308d8 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -19,6 +19,7 @@ const pt = { manage_users: "Gerenciar usuários", manage_org_users: "Gerenciar usuários da organização", manage_metadata: "Gerenciar metadados", + manage_org_presentation: "Gerenciar a apresentação da organização", manage_platform_settings: "Gerenciar configurações da plataforma", setup_access: "Acesso à configuração", setup_write: "Gravação de configurações", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index dacdb403b..948d5f002 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -19,6 +19,7 @@ const ru = { manage_users: "Управление пользователями", manage_org_users: "Управление пользователями организации", manage_metadata: "Управление метаданными", + manage_org_presentation: "Управление представлением организации", manage_platform_settings: "Управление настройками платформы", setup_access: "Доступ к настройке", setup_write: "Запись настроек", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9fbfd21db..2af19e091 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -29,6 +29,7 @@ const zh = { manage_users: '管理用户', manage_org_users: '管理组织用户', manage_metadata: '管理元数据', + manage_org_presentation: '管理组织展示', manage_platform_settings: '管理平台设置', setup_access: '访问 Setup', setup_write: '保存设置', diff --git a/packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx b/packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx index 8138dd324..ac62646d1 100644 --- a/packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx +++ b/packages/plugin-form/src/ObjectForm.submitRedirect.test.tsx @@ -63,7 +63,7 @@ * 3. **Replacing the spec's refusal with a local hand-written sentence**: **18 red * overall** — 14 in `submitRedirect.test.ts` (every one of the 13 refusal * families, plus the external-alternative case) and the 2 refusal tests in - * each component file, on their `RELATIVE path only` / `#7496` / + * each component file, on their `RELATIVE path only` / ruling-citation / * `protocol-relative` assertions. That is the load-bearing probe: the sentence * on screen is provably the live schema parse's, not a local string. * 4. **Deleting the `encodeURIComponent`**: **10 red** — `escapes an interpolated @@ -190,6 +190,19 @@ describe('ObjectForm redirect — an in-contract destination is followed', () => }); }); +/** + * A refusal CITES THE RULING it comes from, in either spelling the spec uses. + * + * This is the assertion that proves the sentence on screen is the live schema + * parse's and not a local hand-written string (mutation probe 3 below). It was + * spelled `toContain('#7496')` and pinned the citation FORM: `@objectstack/spec` + * 17.3.0 restated the same provenance as `(ruled 2026-08-11)`, so the token + * vanished while the refusal, its reasoning and its prescription all stayed. + * ⛔ Not re-pinned to the new prose verbatim — that moves the brittleness + * instead of removing it. + */ +const CITES_ITS_RULING = /\(ruled \d{4}-\d{2}-\d{2}\)|#\d{3,}/; + describe('ObjectForm redirect — an out-of-contract destination is refused, not dropped', () => { it('refuses a SAME-ORIGIN absolute url and says so on screen (defects 2 + 3)', async () => { const ds = makeDS(); @@ -206,7 +219,7 @@ describe('ObjectForm redirect — an out-of-contract destination is refused, not // not only a toast that scrolls away, and emphatically not silence. const alert = await screen.findByRole('alert'); expect(alert.textContent).toContain('RELATIVE path only'); - expect(alert.textContent).toContain('#7496'); + expect(alert.textContent).toMatch(CITES_ITS_RULING); expect(vi.mocked(toastError).mock.calls[0][0]).toContain('RELATIVE path only'); // The write SUCCEEDED, so the submitter is told that too — refusing the diff --git a/packages/plugin-form/src/WizardForm.submitRedirect.test.tsx b/packages/plugin-form/src/WizardForm.submitRedirect.test.tsx index a944c52c7..b68cd968d 100644 --- a/packages/plugin-form/src/WizardForm.submitRedirect.test.tsx +++ b/packages/plugin-form/src/WizardForm.submitRedirect.test.tsx @@ -139,6 +139,19 @@ describe('WizardForm redirect — an in-contract destination is followed', () => }); }); +/** + * A refusal CITES THE RULING it comes from, in either spelling the spec uses. + * + * This is the assertion that proves the sentence on screen is the live schema + * parse's and not a local hand-written string (mutation probe 3 below). It was + * spelled `toContain('#7496')` and pinned the citation FORM: `@objectstack/spec` + * 17.3.0 restated the same provenance as `(ruled 2026-08-11)`, so the token + * vanished while the refusal, its reasoning and its prescription all stayed. + * ⛔ Not re-pinned to the new prose verbatim — that moves the brittleness + * instead of removing it. + */ +const CITES_ITS_RULING = /\(ruled \d{4}-\d{2}-\d{2}\)|#\d{3,}/; + describe('WizardForm redirect — an out-of-contract destination is refused, not dropped', () => { it('refuses a SAME-ORIGIN absolute url and says so on screen (defects 2 + 3)', async () => { const ds = makeDS(); @@ -149,7 +162,7 @@ describe('WizardForm redirect — an out-of-contract destination is refused, not const alert = await screen.findByRole('alert'); expect(alert.textContent).toContain('RELATIVE path only'); - expect(alert.textContent).toContain('#7496'); + expect(alert.textContent).toMatch(CITES_ITS_RULING); expect(vi.mocked(toastError).mock.calls[0][0]).toContain('RELATIVE path only'); // The write succeeded and is confirmed… From e328e369c9ea7f5265719f409937374aaed62229 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 23:41:56 +0000 Subject: [PATCH 13/13] fix(spec-17.3.0): harvest three exemptions the contract outgrew, and re-derive two negative pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the 17.3.0 red set outside the `#7496` and capability families. Every verdict below is read off the installed artifact, not off an assertion name or a changelog. `registry-inputs-spec-parity` (5 assertions). Four of the five were ONE fact wearing four assertion names, two of which appear to point in opposite directions ("a published key must lose its entry" vs "spec no longer declares object-grid.defaultSort"). Neither reading is right: 17.3.0 made `object-grid.defaultSort` an ADR-0087 D2 TOMBSTONE — member is `z.never().optional()`, description opens `[REMOVED]`, `safeParse` answers `invalid_type` `expected: 'never'` at that path. It therefore left the authorable set while staying listed, which is precisely how the eight tombstones before it were harvested. Its exemption, its GA-pending entry and its carve-out row are deleted, and the block's prose parenthetical claiming a tombstone "would NOT make them stale here" is corrected — its own cited counter-example, the record_picker trio, was harvested exactly that way. The fifth emptied `OFF_SPEC_ARM_EXEMPTIONS` entirely: both arms converged upstream in the direction their own reasons named. `element:number.filter` now accepts `[]` and judges content at `filter.0` (objectui#6206's first option); `object-grid.data` now answers `invalid_union` at `data.provider`, the discriminated `ViewDataSchema` shape `ObjectGridSchema.data` already resolved to (objectui#6207's "convergence is upstream"). Neither declaration was edited — the contract moved to them. #6206 and #6207 are closable as resolved-upstream. Two negative pins whose premise evaporated, both re-derived rather than inverted: - `masterDetailFormTypeManifest` pinned that the manifest enum does NOT make a value unpublishable. 17.3.0 closed that gap deliberately: `invalid_value` naming the honoured pair, plus a bespoke prescription for the near-miss `wizard` citing ADR-0001 and the renderer measurement. The pin now records that both authorities agree, checks each independently, keeps a positive control on `simple`/`tabbed`, and pins the refusal's SHAPE so "the enum closed" cannot be confused with a dropped key or a cross-field rule. - `LocationField.optionalKeys` pinned that the location value schema is non-strict. 17.3.0 closed the shape (`unrecognized_keys` naming the retired `latitude`/`longitude` pair). The test carried a note for this exact event — "the guard could then be delegated to it" — and the re-derived answer is NO: the fence guards the object handed to `onChange` and no parse stands on that path, so strictness changed the CONSEQUENCE of a spread regression, not the widget's exposure. The key-level assertions stay; the strictness is pinned. Part of #7122 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E --- .../masterDetailFormTypeManifest.test.ts | 51 +++++++++++++-- .../registry-inputs-spec-parity.test.ts | 64 ++++++++++++++++--- .../LocationField.optionalKeys.test.tsx | 61 +++++++++++++----- 3 files changed, 143 insertions(+), 33 deletions(-) diff --git a/apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts b/apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts index 3f4549c14..9269f49bb 100644 --- a/apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts +++ b/apps/console/src/__tests__/masterDetailFormTypeManifest.test.ts @@ -80,15 +80,52 @@ describe('object-master-detail-form.formType — the manifest carries the closed }, ); - it('⚠️ does NOT make the value rejected at the publish boundary (objectui#5155)', async () => { - // The other half of the honest reading. objectstack owns this schema; the - // objectui enum cannot narrow it, and a reader who stopped at the test above - // would conclude the value is now impossible to publish. It is not. + it('AND the publish boundary rejects it too, since spec 17.3.0 (objectui#5155)', async () => { + // The other half of the honest reading — and it REVERSED at + // `@objectstack/spec` 17.3.0, which is why this test now says the opposite + // of what it used to. + // + // It was written as a negative pin: objectstack owns this schema, the + // objectui enum cannot narrow it, and a reader who stopped at the manifest + // assertions above would wrongly conclude the value was impossible to + // publish. That was true and worth pinning while the boundary accepted + // `formType: 'wizzard'`. 17.3.0 closed the gap deliberately — not a + // silent narrowing: the schema answers `invalid_value` naming the two + // honoured options, and for the near-miss `'wizard'` it carries a bespoke + // prescription citing ADR-0001 and the renderer measurement behind it + // ("only the current wizard step's fields mount … so parent + details never + // save through the atomic batch"). + // + // ⛔ The premise did not merely evaporate, so this is not an inverted + // assertion standing where a negative control used to. What the test owes + // its reader is the RELATIONSHIP between the two authorities, and that is + // what is pinned: the manifest diagnostic above and the publish boundary + // here now agree, and each is checked on its own so a future divergence + // fails HERE rather than reaching an author as a value that lints clean and + // then cannot be published. const spec: any = await import('@objectstack/spec/ui'); const schema = spec.ObjectMasterDetailFormPropsSchema; expect(schema, 'ObjectMasterDetailFormPropsSchema is not exported').toBeDefined(); - expect(schema.safeParse({ objectName: 'po', details: [], formType: 'wizzard' }).success).toBe( - true, - ); + + // Positive control first: the boundary still admits the honoured pair, so a + // wholesale schema breakage cannot read as "the refusal works". + for (const honoured of ['simple', 'tabbed']) { + expect( + schema.safeParse({ objectName: 'po', details: [], formType: honoured }).success, + `the boundary stopped accepting the honoured value ${honoured}`, + ).toBe(true); + } + + const refused = schema.safeParse({ objectName: 'po', details: [], formType: 'wizzard' }); + expect(refused.success).toBe(false); + if (refused.success) return; + + // The SHAPE of the refusal, not just its existence: a value refusal at the + // key. `unrecognized_keys` would mean the key stopped being declared, and a + // `custom` refinement would mean it is refused by a cross-field rule rather + // than by its own vocabulary — three different facts, and only one of them + // is "the enum closed". + const own = refused.error.issues.filter((i: any) => i.path.join('.') === 'formType'); + expect(own.map((i: any) => i.code)).toEqual(['invalid_value']); }); }); diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index e3a361769..fa6b9aa5f 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -794,7 +794,9 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // THESE DO NOT SELF-RETIRE ON A PIN BUMP, and that is deliberate: unlike the // GA-pending five above, no issue owns declaring them later. They retire only // if `@objectstack/spec` retires the keys upstream (an ADR-0087 D2 tombstone, - // which by itself would NOT make them stale here — see the record_picker trio) + // which by itself DOES make the entry dangling and stale here, because + // `specTopLevelKeys` subtracts tombstones — the record_picker trio was + // harvested that way, and `defaultSort` followed it at 17.3.0) // or if objectui un-deprecates a spelling. Do not resolve one by declaring the // input; that is the move the ruling refused. // @@ -804,7 +806,8 @@ const UNPUBLISHED_EXEMPTIONS: Record = { // `@deprecated` in `ObjectGridSchema`, AND declared by GA — measures TEN. The // five extra (`showPagination`, `defaultSort`, `defaultFilters`, // `resizableColumns`, `title`) are the same class by the same test, so they are - // carved out with it. Trimming back to exactly five is a one-line reversal + // carved out with it. `defaultSort` has since been harvested — spec 17.3.0 + // tombstoned it (see the note below), leaving nine. Trimming back to exactly five is a one-line reversal // (delete the entry, declare the input); publishing first and withdrawing later // is not, which is why the exemption is the direction taken while the card is // open. @@ -820,8 +823,27 @@ const UNPUBLISHED_EXEMPTIONS: Record = { '@deprecated in ObjectGridSchema ("Use searchableFields instead"); GA describes it as "read only when `searchableFields` is absent". A boolean cannot say WHICH fields to search, which is why the list is the surface. Read as back-compat, deliberately not published — the canonical `searchableFields` IS declared. Ruled carve-out, objectui#4648 (maintainer 2026-08-16).', 'object-grid.showPagination': '@deprecated in ObjectGridSchema ("Use pagination config instead"); GA describes it as "read only when `pagination` is absent". Read as back-compat, deliberately not published — the canonical `pagination` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', - 'object-grid.defaultSort': - '@deprecated in ObjectGridSchema ("Use sort instead"); GA describes it as the "Legacy single-sort fallback ({ field, order }), read only when `sort` is absent. Prefer `sort`". Read as back-compat, deliberately not published — the canonical `sort` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + /* + * `object-grid.defaultSort` WAS THE NINTH TOMBSTONE HARVESTED HERE — + * `@objectstack/spec` 17.3.0, and it died the way the eight above did. + * + * 17.3.0 converted the key to an ADR-0087 D2 tombstone: its member is + * `z.never().optional()` and its description opens `[REMOVED] … removed in + * @objectstack/spec 17 (ADR-0049) — it was the legacy second spelling of + * `sort` … Rename the key to `sort` and wrap the value in an array`. Read + * from the installed artifact, not from a changelog: `safeParse` of + * `{ defaultSort: … }` fails `invalid_type` `expected: 'never'` at that path. + * + * So it left the AUTHORABLE set while staying listed, and the two checks that + * police this list reported it DANGLING and STALE exactly as designed — + * deleting the entry is the only way to green. The carve-out reason it used + * to carry is now upstream's own prescription, which is strictly better: the + * contract refuses the spelling by name and says what to write instead. + * + * ⛔ Not resolved by declaring the input. `sort`, the canonical spelling, is + * declared already, and publishing a key the contract rejects by name is the + * one resolution the test below forbids in so many words. + */ 'object-grid.defaultFilters': '@deprecated in ObjectGridSchema ("Use filter instead"); GA describes it as the "Legacy base-filter fallback, read only when `filter` is absent. Prefer `filter`". Read as back-compat, deliberately not published — the canonical `filter` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', 'object-grid.resizableColumns': @@ -914,7 +936,6 @@ const GA_PENDING_UNPUBLISHED_KEYS = [ 'object-grid.pageSize', 'object-grid.showSearch', 'object-grid.showPagination', - 'object-grid.defaultSort', 'object-grid.defaultFilters', 'object-grid.resizableColumns', 'object-grid.title', @@ -1281,10 +1302,34 @@ const refusedArms = (type: string): string[] => * arrival gets REPORTED rather than declared away by editing the declaration. */ const OFF_SPEC_ARM_EXEMPTIONS: Record = { - 'element:number.filter:array': - 'Declared `array` (every other `filter` input in the repo is — object-grid, object-metric, record:related_list, plugin-list, data-list), while ComponentPropsMap[element:number].filter is a record/object ("Filter criteria") and refuses an array outright. The renderer is an opaque passthrough (elements.tsx:375-451, `filter?: unknown` → adapter.aggregate / find), so nothing in-tree settles which side moves: widening the spec entry to the ViewFilterRule array form every sibling filter uses, or re-declaring this one block. A contract question, filed as objectui#6206.', - 'object-grid.data:object': - 'Two spec authorities disagree about the KIND, so no declaration can satisfy both: ObjectGridSchema.data resolves to ViewDataSchema (an object discriminated on `provider`) while ComponentPropsMap[object-grid].data is `z.array(z.unknown())` ("Static inline rows"). The `object` arm is the DELIBERATE one — objectui#5090 / PR objectui#5108 changed it from `array` against ViewDataSchema, and plugin-grid/src/__tests__/gridDataInputContract.test.ts pins it there; flipping it back re-opens #5090 and fails `tsc` (TS2322, measured on that card). Convergence is upstream, filed as objectui#6207.', + /* + * EMPTY, AND THAT IS THE RESULT — both entries were harvested at + * `@objectstack/spec` 17.3.0, each resolved upstream in the direction its own + * reason named. Measured with this gate's own coarse probes against the + * installed artifact: + * + * - `element:number.filter:array` (objectui#6206) — the spec entry was a + * record and refused an array outright; 17.3.0 accepts `[]` and answers + * `invalid_type` at `filter.0` for `['Account']`. So the ARRAY KIND is + * accepted and only the content is judged, which is the first of the two + * resolutions the reason offered: "widening the spec entry to the + * ViewFilterRule array form every sibling filter uses". The declaration + * was right; the contract moved to it. + * - `object-grid.data:object` (objectui#6207) — the two spec authorities + * that disagreed have converged. `ComponentPropsMap[object-grid].data` + * was `z.array(z.unknown())`; it now answers `invalid_union` at + * `data.provider` for an object probe, i.e. the discriminated + * `ViewDataSchema` shape `ObjectGridSchema.data` already resolved to. + * That is exactly the "convergence is upstream" the entry was waiting on, + * and the `object` arm objectui#5090 / PR objectui#5108 deliberately + * chose is now the contract's own. + * + * ⛔ Neither was closed by editing a declaration — both declarations are + * byte-identical to what they were; the contract changed underneath them. + * `carries no stale arm exemption` is what forced the deletion, which is this + * file's exemption discipline working end to end. objectui#6206 and #6207 can + * be closed as resolved-upstream; reported on objectui#7122. + */ }; describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)', () => { @@ -1544,7 +1589,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // declared by GA, and deliberately NOT published (maintainer 2026-08-16). const CARVED_OUT_GRID_KEYS = [ 'defaultFilters', - 'defaultSort', 'fields', 'pageSize', 'resizableColumns', diff --git a/packages/fields/src/__tests__/LocationField.optionalKeys.test.tsx b/packages/fields/src/__tests__/LocationField.optionalKeys.test.tsx index 08140637d..b0b0fd436 100644 --- a/packages/fields/src/__tests__/LocationField.optionalKeys.test.tsx +++ b/packages/fields/src/__tests__/LocationField.optionalKeys.test.tsx @@ -38,12 +38,24 @@ * spread would carry a deprecated `latitude` / `longitude` key straight back * into the emitted object and undo #6272's rename. * - * ⚠️ And the spec schema cannot be that guard. `valueSchemaFor({ type: - * 'location' })` is a plain, NON-STRICT `z.object` — measured, and pinned in - * the last test below: it ACCEPTS `{ lat, lng, latitude, longitude }` and - * merely strips the two unknown keys from its parsed output, while the object - * handed to `onChange` still carries them. So every anti-dialect assertion here - * reads the EMITTED object's own keys rather than `safeParse`. + * ⚠️ And the spec schema is still not that guard — for a NARROWER reason since + * `@objectstack/spec` 17.3.0 closed the shape. It used to be a plain, + * NON-STRICT `z.object`: it ACCEPTED `{ lat, lng, latitude, longitude }` and + * merely stripped the two unknown keys from its parsed output, so a polluted + * emission validated GREEN and the dialect survived silently. 17.3.0 makes the + * same value `unrecognized_keys`, naming the retired pair and prescribing the + * rename. + * + * That is a strictly better contract, and it does NOT let this fence be + * delegated to it. The fence is about the object the widget hands to + * `onChange`, and nothing on that path parses — a spread regression would + * still physically carry `latitude` / `longitude` into the emitted object. What + * changed is the CONSEQUENCE, not the exposure: the leak used to end in a + * silent survival, and now ends in a loud refusal at whatever boundary next + * parses the value. So every anti-dialect assertion here still reads the + * EMITTED object's own keys rather than `safeParse`, and the last test below + * pins the strictness itself, because that is now the fact a future reader + * would otherwise have to re-measure. */ import { describe, it, expect, vi } from 'vitest'; @@ -214,18 +226,35 @@ describe('NEGATIVE CONTROL — the carry is a key-by-key pick, never a spread (o expect(emitted).toEqual(TYPED_PAIR); }); - it('pins WHY those assertions read keys and not safeParse: the schema is not strict', () => { - // Measured, and the reason the fence needs a guard of its own: the spec's - // `LocationValue` is a plain `z.object`, so an emission polluted by a - // spread still PASSES validation — zod merely strips the unknown keys from - // its parsed OUTPUT, while the object handed to `onChange` keeps them. + it('pins WHY those assertions read keys and not safeParse: the value never reaches a parse', () => { + // This test used to pin the opposite fact — that `LOCATION_SCHEMA` was + // NON-strict, so a polluted emission parsed green and the dialect survived + // silently. `@objectstack/spec` 17.3.0 closed the shape, and the note this + // test carried for that event ("if the spec ever turns strict, the guard + // could then be delegated to it") is now due. Re-derived rather than + // inverted, because the answer turns out to be NO. const polluted = { lat: 31.2304, lng: 121.4737, latitude: 30.2741, longitude: 120.1551 }; const parsed = LOCATION_SCHEMA.safeParse(polluted); - expect(parsed.success).toBe(true); - expect(Object.keys(parsed.data as object).sort()).toEqual(['lat', 'lng']); - // The object itself is where the dialect survives — hence the key-level - // assertions above. If the spec ever turns strict, this test is the note - // saying the guard could then be delegated to it. + expect(parsed.success).toBe(false); + if (parsed.success) return; + + // The shape of the refusal, so "strict" is pinned as the specific fact it + // is: the retired pair is refused BY NAME at the object root, which is also + // what makes the message able to prescribe the rename. + const [issue] = parsed.error.issues; + expect(issue.code).toBe('unrecognized_keys'); + expect([...((issue as { keys?: string[] }).keys ?? [])].sort()).toEqual([ + 'latitude', + 'longitude', + ]); + + // …and the delegation the old note asked about does NOT follow. The fence + // guards the object handed to `onChange`, and no parse stands on that path: + // the polluted object still carries the dialect whatever the schema thinks + // of it. Strictness changed the CONSEQUENCE of a spread regression (a loud + // refusal at the next parsing boundary instead of a silent survival), not + // the widget's exposure to it — so the key-level assertions above stay. expect(polluted).toHaveProperty('latitude'); + expect(Object.keys(polluted)).toContain('longitude'); }); });