From 00d2b7f9ec45af2602e36b4a43923f4d2dd2c4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:48:28 +0000 Subject: [PATCH 1/9] fix(deps): bump zod to the fixed line (>= 4.6.1) workspace-wide zod 4.4.3's `treeifyError`, `formatError` (`error.format()`) and `flattenError` (`error.flatten()`) walk an issue `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming an `Object.prototype` member is answered by the prototype and no node is ever created. Two failure modes follow: - a TERMINAL element adopts the inherited member as the node and then does `node._errors.push(...)` on it -- `TypeError: Cannot read properties of undefined (reading 'push')`; - a NON-TERMINAL element walks INTO `Object.prototype` and writes the next segment onto it -- the refusal message is silently dropped from the returned tree and the process gains a global prototype key. This repo emits exactly the terminal shape: the landed `__proto__` refusals guard open-key surfaces whose issue path is `['assignments','__proto__']`, so a consumer formatting our own refusal crashed on it. 4.6.1 fixes it with a `node()` helper that guards the read with `hasOwnProperty` and creates a `__proto__` node through `defineProperty`. Every declaration moves to the fixed line with its shape preserved (caret stays caret, `apps/docs`'s exact pin stays exact), and the lockfile is refreshed through pnpm rather than hand-edited. No `overrides` entry is needed: every other zod range in the tree, workspace and vendor, already admits 4.6.1, and the lockfile now holds a single zod copy. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- apps/docs/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/drivers/driver-turso/package.json | 2 +- packages/lint/package.json | 2 +- packages/mcp/package.json | 2 +- packages/metadata-core/package.json | 2 +- packages/metadata-protocol/package.json | 2 +- packages/metadata/package.json | 2 +- packages/objectql/package.json | 2 +- packages/rest/package.json | 2 +- packages/runtime/package.json | 2 +- packages/spec/package.json | 2 +- ...zod-error-formatter-proto-path.pin.test.ts | 210 ++++++++++++++++++ pnpm-lock.yaml | 125 ++++------- 15 files changed, 270 insertions(+), 91 deletions(-) create mode 100644 packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts diff --git a/apps/docs/package.json b/apps/docs/package.json index 96f1699b405..0a21869896a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -37,7 +37,7 @@ "postcss": "^8.5.26", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "zod": "4.4.3" + "zod": "4.6.1" }, "engines": { "node": ">=22.0.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index 5d84eb3ea81..0d8330cba3a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -119,7 +119,7 @@ "ts-morph": "^28.0.0", "tsx": "^4.23.12", "yaml": "^2.9.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "@objectstack/driver-turso": "workspace:^" diff --git a/packages/core/package.json b/packages/core/package.json index b901e014003..c5f35c78666 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,7 +38,7 @@ "dependencies": { "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "keywords": [ "objectstack", diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 571c9abb9c0..ef5bfff1a87 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -34,7 +34,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/spec": "workspace:*", "nanoid": "^6.0.1", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "better-sqlite3": "^13.0.3" diff --git a/packages/lint/package.json b/packages/lint/package.json index 9f9fedda1fd..1cb16c08427 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -50,7 +50,7 @@ "devDependencies": { "@types/node": "^26.2.0", "vitest": "^4.1.11", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "keywords": [ "objectstack", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 85d4ee15de3..5daf0eeda42 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -31,7 +31,7 @@ "@objectstack/formula": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/lint": "workspace:*", diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index fd114e45379..6c88cb5ec0c 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -43,7 +43,7 @@ ], "dependencies": { "@objectstack/spec": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "vitest": "^4.0.0" diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index ecc9c7d23c3..240180e615b 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -45,7 +45,7 @@ "@objectstack/metadata-core": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@types/node": "^26.2.0", diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 6dac4f471b1..c3400c410b6 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -88,7 +88,7 @@ "chokidar": "^5.0.0", "glob": "^13.0.6", "js-yaml": "^5.2.3", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/driver-sqlite-wasm": "workspace:*", diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 7b2710d80d5..1e11b2e67b1 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -35,7 +35,7 @@ "@objectstack/types": "workspace:*", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "tsx": "^4.23.12", diff --git a/packages/rest/package.json b/packages/rest/package.json index eacac0afc67..5885f560b75 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -36,7 +36,7 @@ "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "exceljs": "^4.4.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "devDependencies": { "@objectstack/driver-sql": "workspace:*", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 806bd2c1de3..17779c9e707 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -46,7 +46,7 @@ "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "quickjs-emscripten": "^0.32.0", - "zod": "^4.4.3", + "zod": "^4.6.1", "@objectstack/metadata-core": "workspace:*" }, "peerDependencies": { diff --git a/packages/spec/package.json b/packages/spec/package.json index 5e8e860225c..b1a0efc7a0d 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -321,7 +321,7 @@ }, "dependencies": { "pg-connection-string": "^2.14.0", - "zod": "^4.4.3" + "zod": "^4.6.1" }, "peerDependencies": { "ai": "^7.0.0" diff --git a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts new file mode 100644 index 00000000000..5ca8ddc8741 --- /dev/null +++ b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AssignmentConfigSchema } from '../automation/builtin-node-config.zod'; + +/** + * A VERSION FLOOR pin on zod's three error formatters — `treeifyError`, + * `formatError` (`error.format()`) and `flattenError` (`error.flatten()`). + * + * Unlike `record-proto-key-guard.test.ts` next door, which pins the BEHAVIOUR + * of our own guards and is deliberately version-agnostic, this file exists to + * fail on a zod below the fixed line. It is the regression pin for the bump + * away from 4.4.3, and it is the only thing in the tree that notices a + * downgrade. + * + * ## The defect it pins (measured on zod 4.4.3, both directions) + * + * All three formatters walked an issue's `path` by reading `curr[el]` and + * testing it for truthiness (`curr[el] = curr[el] || {...}`, or `??=`) before + * creating a node. A path element naming a member of `Object.prototype` is + * therefore answered BY THE PROTOTYPE, and no node is ever created. Two + * distinct failure modes follow, and they need separate assertions because + * they present in opposite ways: + * + * - TERMINAL element (`['assignments','__proto__']`, `['x','toString']`): + * the inherited member — a function — is adopted as the node, and the very + * next statement does `node._errors.push(...)` on it. It CRASHES: + * `TypeError: Cannot read properties of undefined (reading 'push')`. + * + * - NON-TERMINAL element (`['__proto__','anything']`): the walk continues + * INTO `Object.prototype` and writes the next segment onto it. The + * refusal message is silently lost from the returned tree, and the process + * gets a global prototype key it never asked for. Nothing throws, nothing + * logs; the caller renders an empty error list for a document that was + * refused. This is the mode nothing else in the tree would catch. + * + * 4.6.1 fixed it with a `node()` helper that guards the read with + * `Object.prototype.hasOwnProperty.call(obj, key)` and creates a `__proto__` + * node through `Object.defineProperty` (a bare assignment would hit the + * prototype SETTER instead of creating an own key). + * + * ## Why this repo is exposed, and not hypothetically + * + * The landed `__proto__` refusals (#17852 / #18847 / #19151) exist precisely + * because zod's open-key branches silently DROP a `__proto__` key. Those + * guards turned a silent drop into a loud refusal — and the loud refusal's + * issue path is `['assignments','__proto__']`, which is exactly the terminal + * shape the formatters crashed on. A consumer that formatted our own refusal + * crashed on it. The first test below drives that real schema rather than a + * modelled one. + * + * ## Reading these assertions + * + * Every lookup of a prototype-member key goes through {@link ownValue}, never + * `node.__proto__` or `node[key]`: on a bare read `node.__proto__` is answered + * by the prototype accessor and `node.toString` by the inherited function, so + * a plain read cannot tell "the formatter created this node" from "JavaScript + * answered from the prototype" — which is the very confusion the defect is + * made of. `getOwnPropertyDescriptor` asks the only question that + * discriminates: is there an OWN key here? + */ + +/** The refusal message the process must never be able to invent on its own. */ +const MESSAGE = 'pin: the refusal text that must survive formatting'; + +/** + * Keys these tests would see appear on `Object.prototype` under the defect. + * Cleaned after every test so one failure cannot cascade into the next file. + */ +const CANARY_KEYS = ['polluted', '_errors', 'errors'] as const; + +afterEach(() => { + for (const key of CANARY_KEYS) { + if (Object.prototype.hasOwnProperty.call(Object.prototype, key)) { + delete (Object.prototype as Record)[key]; + } + } +}); + +/** + * Read `key` as an OWN property of `node`, or `undefined` if it is not one. + * + * @param node - the formatter output node to read. + * @param key - the path element, including one naming an `Object.prototype` + * member. + * @returns the own value, never an inherited one. + */ +function ownValue(node: unknown, key: string): unknown { + if (node === null || typeof node !== 'object') return undefined; + return Object.getOwnPropertyDescriptor(node, key)?.value; +} + +/** Own keys `Object.prototype` did not have before the formatters ran. */ +function prototypePollution(): string[] { + return CANARY_KEYS.filter((key) => + Object.prototype.hasOwnProperty.call(Object.prototype, key), + ); +} + +/** A `ZodError` carrying exactly one issue at `path`, for path shapes no landed schema emits yet. */ +function errorAtPath(path: (string | number)[]): z.ZodError { + return new z.ZodError([{ code: 'custom', path, message: MESSAGE, input: undefined }]); +} + +describe("zod renders a refusal whose path names an Object.prototype member (the bump's regression pin)", () => { + it("formats the repo's OWN landed `__proto__` refusal instead of crashing on it", () => { + // `JSON.parse` is what makes `__proto__` an own enumerable key — an object + // literal's `{ __proto__: ... }` sets the actual prototype instead, and the + // guard would never see a key at all. + const config = JSON.parse('{"assignments":{"__proto__":"x"}}') as unknown; + + const result = AssignmentConfigSchema.safeParse(config); + expect(result.success).toBe(false); + if (result.success) return; + + // The shape this pin exists for: a TERMINAL `__proto__`, emitted by a + // guard that is live in this package today. + const [issue] = result.error.issues; + expect(issue.path).toEqual(['assignments', '__proto__']); + const refusal = issue.message; + expect(refusal).toContain('__proto__'); + + // Each of the three formatters must RETURN, and must carry the refusal at + // the path the issue named. On 4.4.3 every one of these three throws + // `TypeError: Cannot read properties of undefined (reading 'push')`. + const tree = z.treeifyError(result.error); + const assignments = ownValue(tree.properties, 'assignments'); + expect(ownValue(ownValue(assignments, 'properties'), '__proto__')).toEqual({ + errors: [refusal], + }); + + const formatted = result.error.format(); + expect(ownValue(ownValue(formatted, 'assignments'), '__proto__')).toEqual({ + _errors: [refusal], + }); + + const flattened = result.error.flatten(); + expect(ownValue(flattened.fieldErrors, 'assignments')).toEqual([refusal]); + + expect(prototypePollution()).toEqual([]); + }); + + it('keeps the message and leaves `Object.prototype` alone on a NON-TERMINAL `__proto__` — the silent mode', () => { + // The mode with no crash to notice: on 4.4.3 the walk steps INTO + // `Object.prototype`, writes `polluted` onto it, and returns a tree with + // the refusal missing. Both halves are asserted, because a fix that + // stopped the pollution while still dropping the message would be no fix. + const error = errorAtPath(['__proto__', 'polluted']); + + const tree = z.treeifyError(error); + const treeNode = ownValue(tree.properties, '__proto__'); + expect(ownValue(ownValue(treeNode, 'properties'), 'polluted')).toEqual({ + errors: [MESSAGE], + }); + + const formatted = z.formatError(error); + const formattedNode = ownValue(formatted, '__proto__'); + expect(ownValue(formattedNode, 'polluted')).toEqual({ _errors: [MESSAGE] }); + + expect(prototypePollution()).toEqual([]); + }); + + it.each(['toString', 'constructor', 'valueOf', 'hasOwnProperty'])( + 'formats a terminal path element named `%s` as an own key', + (member) => { + const error = errorAtPath(['config', member]); + + const tree = z.treeifyError(error); + const treeParent = ownValue(tree.properties, 'config'); + expect(ownValue(ownValue(treeParent, 'properties'), member)).toEqual({ + errors: [MESSAGE], + }); + + const formatted = z.formatError(error); + expect(ownValue(ownValue(formatted, 'config'), member)).toEqual({ + _errors: [MESSAGE], + }); + + // `flattenError` reads only the FIRST path element, so it is pinned on a + // single-element path rather than through `config` above. + const flattened = z.flattenError(errorAtPath([member])); + expect(ownValue(flattened.fieldErrors, member)).toEqual([MESSAGE]); + + expect(prototypePollution()).toEqual([]); + }, + ); + + it('positive control — an ORDINARY path renders the same way, so the assertions above are not vacuous', () => { + // Every assertion in this file is `ownValue(...) === {errors:[MESSAGE]}`. + // If that spelling were simply wrong, the tests would fail for a reason + // having nothing to do with zod's version. This leg proves the spelling + // reads a real formatter node on a path the defect never touched. + const error = errorAtPath(['assignments', 'ordinary']); + + const tree = z.treeifyError(error); + const parent = ownValue(tree.properties, 'assignments'); + expect(ownValue(ownValue(parent, 'properties'), 'ordinary')).toEqual({ + errors: [MESSAGE], + }); + + expect(z.formatError(error)).toMatchObject({ + assignments: { ordinary: { _errors: [MESSAGE] } }, + }); + expect(z.flattenError(errorAtPath(['ordinary'])).fieldErrors).toEqual({ + ordinary: [MESSAGE], + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d41005bad..1fce1f4d11b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,13 +85,13 @@ importers: dependencies: fumadocs-core: specifier: 16.14.4 - version: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3) + version: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1) fumadocs-mdx: specifier: 15.2.3 - version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3))(next@16.3.3(@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@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + version: 15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1))(next@16.3.3(@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@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) fumadocs-ui: specifier: 16.14.4 - version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3))(next@16.3.3(@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)(tailwindcss@4.3.3) + version: 16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1))(next@16.3.3(@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)(tailwindcss@4.3.3) lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) @@ -151,8 +151,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 zod: - specifier: 4.4.3 - version: 4.4.3 + specifier: 4.6.1 + version: 4.6.1 examples/app-crm: dependencies: @@ -616,8 +616,8 @@ importers: specifier: ^2.9.0 version: 2.9.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-turso': specifier: workspace:* @@ -873,8 +873,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/metadata-core': specifier: workspace:* @@ -1093,8 +1093,8 @@ importers: specifier: ^6.0.1 version: 6.0.1 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1159,14 +1159,14 @@ importers: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@6.0.3))(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 packages/mcp: dependencies: '@modelcontextprotocol/sdk': specifier: ^1.30.0 - version: 1.30.0(zod@4.4.3) + version: 1.30.0(zod@4.6.1) '@objectstack/core': specifier: workspace:* version: link:../core @@ -1180,8 +1180,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/lint': specifier: workspace:* @@ -1226,8 +1226,8 @@ importers: specifier: ^5.2.3 version: 5.2.3 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-sqlite-wasm': specifier: workspace:* @@ -1251,8 +1251,8 @@ importers: specifier: workspace:* version: link:../spec zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1316,8 +1316,8 @@ importers: specifier: workspace:* version: link:../types zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -1371,8 +1371,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: tsx: specifier: ^4.23.12 @@ -2229,8 +2229,8 @@ importers: specifier: ^4.4.0 version: 4.4.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/driver-sql': specifier: workspace:* @@ -2332,8 +2332,8 @@ importers: specifier: ^0.32.0 version: 0.32.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@objectstack/platform-objects': specifier: workspace:* @@ -2863,8 +2863,8 @@ importers: specifier: ^2.14.0 version: 2.14.0 zod: - specifier: ^4.4.3 - version: 4.4.3 + specifier: ^4.6.1 + version: 4.6.1 devDependencies: '@types/node': specifier: ^26.2.0 @@ -2874,7 +2874,7 @@ importers: version: 4.1.11(vitest@4.1.11) ai: specifier: ^7.0.66 - version: 7.0.66(zod@4.4.3) + version: 7.0.66(zod@4.6.1) tsx: specifier: ^4.23.12 version: 4.23.12 @@ -9378,9 +9378,6 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zod@4.6.1: resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} @@ -9389,21 +9386,21 @@ packages: snapshots: - '@ai-sdk/gateway@4.0.52(zod@4.4.3)': + '@ai-sdk/gateway@4.0.52(zod@4.6.1)': dependencies: '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.1) '@vercel/oidc': 3.2.0 - zod: 4.4.3 + zod: 4.6.1 - '@ai-sdk/provider-utils@5.0.27(zod@4.4.3)': + '@ai-sdk/provider-utils@5.0.27(zod@4.6.1)': dependencies: '@ai-sdk/provider': 4.0.7 '@standard-schema/spec': 1.1.0 '@workflow/serde': 4.1.0 eventsource-parser: 3.1.1 undici: 7.29.0 - zod: 4.4.3 + zod: 4.6.1 '@ai-sdk/provider@4.0.7': dependencies: @@ -10598,28 +10595,6 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 - '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': - dependencies: - '@hono/node-server': 2.0.12(hono@4.13.7) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.6.1(express@5.2.1) - hono: 4.13.7 - jose: 6.2.7 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) - transitivePeerDependencies: - - supports-color - '@modelcontextprotocol/sdk@1.30.0(zod@4.6.1)': dependencies: '@hono/node-server': 2.0.12(hono@4.13.7) @@ -11902,12 +11877,12 @@ snapshots: agent-base@7.1.4: {} - ai@7.0.66(zod@4.4.3): + ai@7.0.66(zod@4.6.1): dependencies: - '@ai-sdk/gateway': 4.0.52(zod@4.4.3) + '@ai-sdk/gateway': 4.0.52(zod@4.6.1) '@ai-sdk/provider': 4.0.7 - '@ai-sdk/provider-utils': 5.0.27(zod@4.4.3) - zod: 4.4.3 + '@ai-sdk/provider-utils': 5.0.27(zod@4.6.1) + zod: 4.6.1 ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -13100,7 +13075,7 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.7.1 - fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3): + fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1): dependencies: '@fumari/image-size': 0.1.0 estree-util-value-to-estree: 3.5.0 @@ -13132,18 +13107,18 @@ snapshots: next: 16.3.3(@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: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zod: 4.4.3 + zod: 4.6.1 transitivePeerDependencies: - supports-color - fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3))(next@16.3.3(@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@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): + fumadocs-mdx@15.2.3(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1))(next@16.3.3(@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@19.2.8)(rolldown@1.0.3)(vite@8.0.16(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1) github-slugger: 2.0.0 magic-string: 1.2.0 mdast-util-mdx: 3.0.0 @@ -13157,7 +13132,7 @@ snapshots: vfile: 6.0.3 yaml: 2.9.0 yuku-analyzer: 0.8.7 - zod: 4.4.3 + zod: 4.6.1 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -13169,7 +13144,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3))(next@16.3.3(@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)(tailwindcss@4.3.3): + fumadocs-ui@16.14.4(@types/mdx@2.0.14)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1))(next@16.3.3(@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)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -13185,7 +13160,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.21(@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) class-variance-authority: 0.7.1 cnfast: 0.1.0 - fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.4.3) + fumadocs-core: 16.14.4(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.170.15(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.3(@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)(zod@4.6.1) lucide-react: 1.31.0(react@19.2.8) motion: 13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -16125,16 +16100,10 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 - zod-to-json-schema@3.25.2(zod@4.4.3): - dependencies: - zod: 4.4.3 - zod-to-json-schema@3.25.2(zod@4.6.1): dependencies: zod: 4.6.1 - zod@4.4.3: {} - zod@4.6.1: {} zwitch@2.0.4: {} From bc3c3ec348cb899156735461a011bc1edc6afcd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:53:31 +0000 Subject: [PATCH 2/9] chore(changeset): record the zod floor move for the eleven packages that ship it Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../19581-zod-formatter-proto-path-bump.md | 30 +++++++++++++++++++ ...zod-error-formatter-proto-path.pin.test.ts | 29 +++++++++++------- 2 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 .changeset/19581-zod-formatter-proto-path-bump.md diff --git a/.changeset/19581-zod-formatter-proto-path-bump.md b/.changeset/19581-zod-formatter-proto-path-bump.md new file mode 100644 index 00000000000..9cec009c183 --- /dev/null +++ b/.changeset/19581-zod-formatter-proto-path-bump.md @@ -0,0 +1,30 @@ +--- +"@objectstack/cli": patch +"@objectstack/core": patch +"@objectstack/driver-turso": patch +"@objectstack/mcp": patch +"@objectstack/metadata": patch +"@objectstack/metadata-core": patch +"@objectstack/metadata-protocol": patch +"@objectstack/objectql": patch +"@objectstack/rest": patch +"@objectstack/runtime": patch +"@objectstack/spec": patch +--- + +**The declared `zod` floor moves from `^4.4.3` to `^4.6.1`**, because on zod below 4.6.1 the three standard error formatters — `z.treeifyError()`, `error.format()` and `error.flatten()` — cannot render a refusal these packages actually emit (#19581). + +Clause-②: no + +**What breaks below the new floor.** All three formatters walked an issue's `path` by reading `curr[el]` and testing it for truthiness before creating a node, so a path element naming a member of `Object.prototype` was answered by the prototype and no node was ever created. Two different failures follow: + +| path shape | what happened on `^4.4.3` | +|:---|:---| +| terminal element (`['assignments','__proto__']`, `['x','toString']`) | the inherited member is adopted as the node, then `node._errors.push(...)` runs on it — `TypeError: Cannot read properties of undefined (reading 'push')` | +| non-terminal element (`['__proto__', …]`) | the walk continues **into** `Object.prototype` and writes the next segment onto it — the message is silently dropped from the returned tree and the process gains a global prototype key | + +**Why it reached this platform's consumers.** `@objectstack/spec` refuses a `__proto__` key on its open-key authoring surfaces, and that refusal's issue path is `['assignments','__proto__']` — precisely the terminal shape. Anything that formatted one of these refusals for display crashed on it, and the crash was in the formatter, not in the guard. The guards themselves are unchanged and still necessary: 4.6.1 still drops a `__proto__` key from `z.record()` and `.catchall()` output, which is what they exist to refuse. + +**What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. + +`@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. diff --git a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts index 5ca8ddc8741..a40512201b7 100644 --- a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts +++ b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts @@ -66,16 +66,25 @@ import { AssignmentConfigSchema } from '../automation/builtin-node-config.zod'; const MESSAGE = 'pin: the refusal text that must survive formatting'; /** - * Keys these tests would see appear on `Object.prototype` under the defect. - * Cleaned after every test so one failure cannot cascade into the next file. + * `Object.prototype`'s own keys before any formatter in this file has run. + * + * Captured as a SNAPSHOT rather than as a guessed list of key names, because + * the defect's pollution is not limited to the path element: walking into + * `Object.prototype` also makes the walker install its own container key + * (`properties` for `treeifyError`) there. A fixed roster missed that one, and + * the leak then made a LATER test fail for a reason having nothing to do with + * its own subject — a polluted `Object.prototype.properties` is inherited, so + * the next `curr.properties ??= {}` finds it non-nullish, creates no own + * container, and quietly shares one object across every tree the process + * builds afterwards. */ -const CANARY_KEYS = ['polluted', '_errors', 'errors'] as const; +const PRISTINE_PROTOTYPE_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype)); afterEach(() => { - for (const key of CANARY_KEYS) { - if (Object.prototype.hasOwnProperty.call(Object.prototype, key)) { - delete (Object.prototype as Record)[key]; - } + // Restore, so one test's pollution cannot cascade into the next test or the + // next file in this worker. + for (const key of prototypePollution()) { + delete (Object.prototype as Record)[key]; } }); @@ -92,10 +101,10 @@ function ownValue(node: unknown, key: string): unknown { return Object.getOwnPropertyDescriptor(node, key)?.value; } -/** Own keys `Object.prototype` did not have before the formatters ran. */ +/** Own keys `Object.prototype` has gained since this file was loaded. */ function prototypePollution(): string[] { - return CANARY_KEYS.filter((key) => - Object.prototype.hasOwnProperty.call(Object.prototype, key), + return Object.getOwnPropertyNames(Object.prototype).filter( + (key) => !PRISTINE_PROTOTYPE_KEYS.has(key), ); } From 85a917b4cbc267c6be1a4738bb4af214843a8f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 05:00:20 +0000 Subject: [PATCH 3/9] fix(spec): regenerate the reference docs the zod bump corrects, and type the pin Two mechanical consequences of moving zod to 4.6.1, both regenerated with the repo's own commands rather than hand-edited. `content/docs/references/**` (`gen:docs`): four pages now render `ViewFilterRule.operator` as REQUIRED where they rendered it optional. That is a CORRECTION, not a narrowing -- `operator` carries no `.optional()` and the runtime has always required it. The key is declared through `z.preprocess(normalizeFilterOperator, z.enum(...))`, and a `z.preprocess`'s inner transform hardcodes `_zod.optin = "optional"` regardless of what it wraps. 4.6.1 stops the requiredness computation from believing that flag: measured on one object with a preprocessed required key, `toJSONSchema(io: 'input').required` reads `["field"]` on 4.4.3 and `["operator","field"]` on 4.6.1. The flag itself is unchanged in both, so the existing `optin`/`optout` correction in `refuseProtoOwnKey` is unaffected and still load-bearing. The tracked JSON Schemas do not move -- `check:authorable-surface` is green. The pin's tree reads now go through `ownValue` for the container key too. A `ZodError` built from raw issues types its tree without `properties`, so the direct read did not compile; asking for an OWN `properties` is also the stricter assertion, and matches how every other lookup in the file is spelled. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 4 ++-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/page.mdx | 4 ++-- content/docs/references/ui/view.mdx | 22 +++++++++---------- ...zod-error-formatter-proto-path.pin.test.ts | 8 +++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 88d647fc075..216cea29529 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1665,7 +1665,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1750,7 +1750,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 9c31b03097d..668cff37b64 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -365,7 +365,7 @@ const result = ApiMethod.parse(data); | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 9f929e20688..bdb0bcadbe0 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -216,7 +216,7 @@ View filter rule | **source** | `string` | optional | Source object name for the page | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | optional | Columns shown by the page. Blank = all object fields. Defined directly on the page (no view inheritance). | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Default sort order for the page, defined directly on the page. | -| **filterBy** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | +| **filterBy** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Always-on page filter (base filter). | | **levels** | `integer` | optional | Number of hierarchy levels to display | | **sourceView** | `string` | optional | @deprecated Legacy named-view inheritance. Define columns/sort/filterBy on the page instead. | | **appearance** | `{ showDescription?: boolean; allowedVisualizations?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>[] }` | optional | Appearance and visualization configuration | @@ -274,7 +274,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object to query | | **view** | `string` | optional | Named view to apply | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Additional filter criteria — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in ComponentPropsMap shares; AND-combined with the filter of the named view. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order | | **limit** | `integer` | optional | Max records to display | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ea800bd3ae5..c1ed28b02a9 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -279,7 +279,7 @@ Form-view select option — the object-field option shape minus the per-option ` | :--- | :--- | :--- | :--- | | **displayFields** | `string[]` | optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. | | **maxResults** | `integer` | optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. | | **object** | `string` | optional | Referenced-object override for the picker search; omitted → resolved from the `reference` key on the field definition. | ### Nested Shape: `FormField.keyField` @@ -793,7 +793,7 @@ Map view configuration | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -895,7 +895,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | ### Nested Shape: `ListView.userFilters` @@ -1129,7 +1129,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1205,7 +1205,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1307,7 +1307,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | -| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | optional | Filter operator | +| **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | | **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | ### Nested Shape: `ObjectListView.selection` @@ -1531,7 +1531,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label | | **icon** | `string` | optional | Tab icon name | | **view** | `string` | optional | Referenced list view name from listViews | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Tab-specific filter criteria | | **order** | `integer` | optional | Tab display order | | **pinned** | `boolean` | optional (default: `false`) | Pin tab (cannot be removed by users) | | **isDefault** | `boolean` | optional (default: `false`) | Set as the default active tab | @@ -1808,7 +1808,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -1893,7 +1893,7 @@ Tab configuration for multi-tab view interface | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -2133,7 +2133,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | @@ -2309,7 +2309,7 @@ This schema accepts one of the following structures: | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | -| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | +| **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | | **filterableFields** | `string[]` | optional | Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering. Prefer userFilters | diff --git a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts index a40512201b7..100bfd70d8a 100644 --- a/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts +++ b/packages/spec/src/shared/zod-error-formatter-proto-path.pin.test.ts @@ -135,7 +135,7 @@ describe("zod renders a refusal whose path names an Object.prototype member (the // the path the issue named. On 4.4.3 every one of these three throws // `TypeError: Cannot read properties of undefined (reading 'push')`. const tree = z.treeifyError(result.error); - const assignments = ownValue(tree.properties, 'assignments'); + const assignments = ownValue(ownValue(tree, 'properties'), 'assignments'); expect(ownValue(ownValue(assignments, 'properties'), '__proto__')).toEqual({ errors: [refusal], }); @@ -159,7 +159,7 @@ describe("zod renders a refusal whose path names an Object.prototype member (the const error = errorAtPath(['__proto__', 'polluted']); const tree = z.treeifyError(error); - const treeNode = ownValue(tree.properties, '__proto__'); + const treeNode = ownValue(ownValue(tree, 'properties'), '__proto__'); expect(ownValue(ownValue(treeNode, 'properties'), 'polluted')).toEqual({ errors: [MESSAGE], }); @@ -177,7 +177,7 @@ describe("zod renders a refusal whose path names an Object.prototype member (the const error = errorAtPath(['config', member]); const tree = z.treeifyError(error); - const treeParent = ownValue(tree.properties, 'config'); + const treeParent = ownValue(ownValue(tree, 'properties'), 'config'); expect(ownValue(ownValue(treeParent, 'properties'), member)).toEqual({ errors: [MESSAGE], }); @@ -204,7 +204,7 @@ describe("zod renders a refusal whose path names an Object.prototype member (the const error = errorAtPath(['assignments', 'ordinary']); const tree = z.treeifyError(error); - const parent = ownValue(tree.properties, 'assignments'); + const parent = ownValue(ownValue(tree, 'properties'), 'assignments'); expect(ownValue(ownValue(parent, 'properties'), 'ordinary')).toEqual({ errors: [MESSAGE], }); From 7f933db2f6887d782ce749c9c2aa7dfd0901142a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:29:05 +0000 Subject: [PATCH 4/9] fix(spec): an unknown-key refusal is terminal again, so union doors keep their diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From zod 4.5.0 the `unrecognized_keys` issue carries `continue: true`. Two things follow, and both were measured on this codebase with the same bodies on 4.4.3 and 4.6.1: 1. a closed shape's own `.refine()` / `.superRefine()` now run AFTER the unknown-key refusal, adding a second contradictory complaint; 2. `handleUnionResults` — byte-identical across 4.4.3 / 4.5.0 / 4.6.1, so not itself the change — sees exactly one non-aborted member and returns its issues UNWRAPPED, so the union never raises `invalid_union`. On `ViewMetadataSchema` that made a `viewKind` + `config` ViewItem body answer with the CONTAINER branch's "wrap it: defineView({ list: … })", and the retirement prescription naming the retired member was never reached, because `focusClaimedBranch` only fires on an `invalid_union`. `closedObject` re-declares a closed shape through a constructor that marks its unknown-key issue non-continuable before `runChecks` reads the payload. `util.clone()` rebuilds through `_zod.constr`, so `.strict()`, `.extend()`, `.refine()` and `.omit()` all carry it forward. It rewrites a flag on an issue that was already raised and adds, removes or re-codes nothing, so the acceptance face cannot move. Applied at `strictObject` (every closed authoring shape), and at the three closed shapes that do not come through it: the form-field base, the normalized filter's group branch, and the two `lifecycle.onlyWhen` comparand arms. All three keep their existing expression spelling so the strictness ledger and `declaration-map` read them unchanged — `check:generated` regenerates nothing. `apps/docs` moves from an exact `4.6.1` to `^4.6.1`, matching the twelve sibling manifests; the lockfile is regenerated by pnpm and still resolves one zod. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- apps/docs/package.json | 2 +- packages/spec/src/data/filter.zod.ts | 13 ++- packages/spec/src/data/object.zod.ts | 13 ++- packages/spec/src/shared/strict-object.ts | 105 +++++++++++++++++++++- packages/spec/src/ui/view.zod.ts | 20 ++++- pnpm-lock.yaml | 2 +- 6 files changed, 145 insertions(+), 10 deletions(-) diff --git a/apps/docs/package.json b/apps/docs/package.json index 0a21869896a..f80058eeaa6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -37,7 +37,7 @@ "postcss": "^8.5.26", "tailwindcss": "^4.3.3", "typescript": "^6.0.3", - "zod": "4.6.1" + "zod": "^4.6.1" }, "engines": { "node": ">=22.0.0" diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index d17f958abb6..a17bc199e1f 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; +import { closedObject } from '../shared/strict-object'; import { assertListComparandShapes } from './filter-comparand-shape'; import { normalizeFilterComparandTypes } from './filter-comparand-type'; import { bareDateRangePresetComparandMessage, isDateRangePresetName } from './date-range-presets'; @@ -1993,14 +1994,22 @@ const normalizedMemberSchema = (position: string) => * package's own tests (swept with `FilterConditionSchema`'s 20+ call sites as * the positive control), so the narrowing has no measured internal caller. */ +/* + * [#19581] `closedObject` states for the GROUP branch what `abort: true` states + * for the field branch above: its refusal is terminal, so it cannot become the + * member union's lone continuable spokesman. Same lesson, same union, other + * arm — from zod 4.5.0 an `unrecognized_keys` issue is continuable, so without + * this a bad operand inside a legitimate group reports the group's raw + * `Unrecognized key: "c"` instead of the union's own `Not a valid $and member`. + */ export const NormalizedFilterSchema: z.ZodType = z.lazy(() => - z.object({ + closedObject(z.object({ $and: z.array(normalizedMemberSchema('$and member')).optional(), $or: z.array(normalizedMemberSchema('$or member')).optional(), $not: normalizedMemberSchema('$not operand').optional(), - }).strict() + }).strict()) ); // ============================================================================ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d919e5507fa..767533a80d4 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -12,7 +12,7 @@ import { ObjectListViewSchema } from '../ui/view.zod'; import { EvaluatedExpressionInputSchema, TemplateExpressionInputSchema, type EvaluatedExpression, type EvaluatedExpressionInput } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { strictObject } from '../shared/strict-object'; +import { closedObject, strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { retiredKey } from '../shared/retired-key'; import { refuseRecordProtoKey } from '../shared/record-proto-key-guard'; @@ -858,8 +858,15 @@ const lifecycleOnlyWhenSchema = z.record( z.string(), z.number(), z.boolean(), - z.object({ $in: z.array(z.union([z.string(), z.number()])).min(1) }).strict(), - z.object({ $null: z.boolean() }).strict(), + // [#19581] `closedObject` — not `strictObject` — because the curated + // message these two arms render is pinned as written + // (`union-author-message-pins.test.ts`, "class B: bare `.strict()`"), and + // routing them through the `strictObject` error map would reword it. All + // the wrapper does is make the unknown-key refusal terminal for the arm + // that raised it, which is what keeps this union reporting `invalid_union` + // instead of collapsing onto whichever arm zod judged closest. + closedObject(z.object({ $in: z.array(z.union([z.string(), z.number()])).min(1) }).strict()), + closedObject(z.object({ $null: z.boolean() }).strict()), ]), ); diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts index 24c9e672f39..da60033a1af 100644 --- a/packages/spec/src/shared/strict-object.ts +++ b/packages/spec/src/shared/strict-object.ts @@ -349,11 +349,114 @@ export function strictObjectError( return error; } +/** + * [#19581] An unknown key is a TERMINAL refusal of the surface that raised it — + * restored here because zod stopped treating it as one. + * + * ## What moved, measured on both lines + * + * From zod 4.5.0 the `unrecognized_keys` issue carries `continue: true` + * (`v4/core/schemas.js`, both the shape-phase and the `handleCatchall` push; + * absent on 4.4.3). The vendor states the intent in the line above it: the + * issue *"describes the shape of the input, not the validity of the parsed + * value, so it never aborts. The parse still fails; the schema's own checks + * just get to run first, and an enclosing intersection can reconcile the key + * against a sibling operand."* + * + * That is a reasonable general-purpose posture and it is not this repo's. + * Here a closed shape is a contract (Prime Directive #12): a key the surface + * does not declare is not shape information awaiting reconciliation, it is the + * refusal. Two things follow from the flag, and both were measured on this + * codebase with the same bodies and `packages/spec/node_modules/zod` repointed + * per line, the swap proven on disk before either reading was believed: + * + * 1. **The surface's own checks now run after it.** `runChecks` reads + * `util.aborted(payload)` once, on entry, so a continuable unknown-key issue + * lets every `.refine()` / `.superRefine()` on the same object fire and add + * a SECOND, contradictory complaint about a body that was already refused. + * 2. **Every union containing that surface loses its envelope.** + * `handleUnionResults` — byte-identical on 4.4.3, 4.5.0 and 4.6.1, so not + * itself the change — returns a single non-aborted member's issues + * UNWRAPPED instead of pushing `invalid_union`: + * + * ```js + * const nonaborted = results.filter((r) => !util.aborted(r)); + * if (nonaborted.length === 1) { final.value = nonaborted[0].value; return nonaborted[0]; } + * ``` + * + * A member whose only complaint is an unknown key is now that one member, so + * the union's message becomes THAT branch's prescription — chosen by zod's + * "which branch got furthest" heuristic rather than by the door's own branch + * rule. On `ViewMetadataSchema` the observable effect was a `viewKind` + + * `config` ViewItem body being answered with the CONTAINER branch's + * *"wrap it: `defineView({ list: … })`"*, and a retirement prescription that + * names the retired member never being reached at all. + * + * ## Why it is applied at `_zod.parse` and not as a check + * + * The flag has to be settled BEFORE `runChecks` computes `isAborted`, which + * rules out a `.check()` — a check runs after the parse and cannot retroactively + * gate its siblings. A check would also be worse in a second way: `util.extend` + * refuses to overwrite a key on any object carrying checks, so adding one here + * would break `.extend()` on every closed shape in the package. + * + * ⛔ **It cannot move the acceptance face.** It only rewrites a flag on an issue + * that has already been raised; a body with no issues never reaches it, and no + * issue is added, removed or re-coded. What changes is which competing + * complaint an author reads, and whether the union keeps its envelope. + */ +const markUnknownKeyRefusalTerminal =

}>(payload: P): P => { + for (const issue of payload.issues) { + if (issue.code === 'unrecognized_keys') (issue as { continue?: boolean }).continue = false; + } + return payload; +}; + +/** + * [#19581] The `ZodObject` variant {@link closedObject} builds. + * + * It is a constructor rather than an instance-level patch because + * `util.clone()` rebuilds through `inst._zod.constr`: `.strict()`, `.strip()`, + * `.extend()`, `.refine()` and `.omit()` all clone, so an instance-level wrap + * would survive exactly until the first derived schema. Declaring it here makes + * every descendant carry it by construction. + * + * `_zod.def` is untouched, so the emitted JSON Schema, the authorable surface + * and `instanceof z.ZodObject` (trait-based) are all byte-identical. + */ +const ZodClosedObject = z.core.$constructor('ZodClosedObject', (inst: any, def: any) => { + (z.ZodObject as unknown as { init: (i: unknown, d: unknown) => void }).init(inst, def); + const parse = inst._zod.parse; + inst._zod.parse = (payload: any, ctx: any) => { + const done = parse(payload, ctx); + return done instanceof Promise + ? done.then(markUnknownKeyRefusalTerminal) + : markUnknownKeyRefusalTerminal(done); + }; +}); + +/** + * [#19581] Re-declare a closed object schema so its unknown-key refusal is + * terminal — see {@link markUnknownKeyRefusalTerminal}. + * + * Exported for the closed shapes that do NOT come through {@link strictObject} + * — a bare `z.object(…).strict()` whose curated message is pinned as written + * and must not be re-routed through the `strictObject` error map. + */ +export function closedObject(schema: S): S { + return new (ZodClosedObject as unknown as new (def: unknown) => S)( + (schema as unknown as { _zod: { def: unknown } })._zod.def, + ); +} + /** * A `.strict()` object whose unknown-key error names the surface, echoes the * offending key, and suggests the closest declared key — with the candidate * list read from `shape` rather than transcribed alongside it. + * + * [#19581] Built as a {@link closedObject}, so the refusal is terminal for this + * surface on every zod line. */ export function strictObject(options: StrictObjectOptions, shape: T) { - return z.object(shape, { error: strictObjectError(options, shape) }).strict(); + return closedObject(z.object(shape, { error: strictObjectError(options, shape) }).strict()); } diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 860d3ac1e6d..b6f5c46c53e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -51,7 +51,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { strictObject, strictObjectError } from '../shared/strict-object'; +import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; @@ -3157,7 +3157,7 @@ const FormFieldBaseSchema = lazySchema(() => { visibleOn: EvaluatedExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), }; - return z.object(shape, { + const base = z.object(shape, { error: strictObjectError({ ...VISIBILITY_STRICT_OPTIONS, // #8202 — this shape names ITSELF. The shared table's `'this view/page @@ -3193,6 +3193,22 @@ const FormFieldBaseSchema = lazySchema(() => { aliases: { disabled: 'readonly' }, }, shape), }); + // [#19581] Sealed HERE, on the open base, rather than on the `.strict()` + // extension below. Three instruments read these two declarations and all + // three keep reading them unchanged this way: the literal `z.object(shape, + // { error: strictObjectError(…) })` above is still the expression the + // strictness ledger's AST reader counts as this file's one `authorable` + // strip site (see this schema's docblock), `FormFieldSchema`'s expression + // below is still the composition `declaration-map` unwinds to reach + // `FormFieldBaseSchema`, and `closedObject` reaches the extension anyway — + // `util.clone()` rebuilds through `_zod.constr`, so `.extend()` and + // `.strict()` both carry the seal forward. Sealing an OPEN shape is a no-op + // by itself (it has no unknown key to refuse); the extension is where it + // bites, and without it that member is the only non-aborted arm of the + // `z.union([z.string(), FormFieldSchema])` a section's `fields` uses, so zod + // returns its issues unwrapped instead of the `invalid_union` this door's + // diagnosis is built on. + return closedObject(base); }); /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fce1f4d11b..6cd22a12977 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,7 +151,7 @@ importers: specifier: ^6.0.3 version: 6.0.3 zod: - specifier: 4.6.1 + specifier: ^4.6.1 version: 4.6.1 examples/app-crm: From 56ace769d408c3fa46f2528c46efb2c2a02b859d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:20:15 +0000 Subject: [PATCH 5/9] docs(changeset): record the diagnosis half of the zod floor move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor move and the diagnosis repair land together and revert together, so the changeset that ships to consumers has to carry both halves: what an unknown-key refusal does to a union's envelope, the before/after an author reads at `PUT /api/v1/meta/view`, and the one spelling that does NOT get the repair (a bare `z.object(…).strict()` / zod's own `z.strictObject`). Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../19581-zod-formatter-proto-path-bump.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.changeset/19581-zod-formatter-proto-path-bump.md b/.changeset/19581-zod-formatter-proto-path-bump.md index 9cec009c183..6ae342dfe28 100644 --- a/.changeset/19581-zod-formatter-proto-path-bump.md +++ b/.changeset/19581-zod-formatter-proto-path-bump.md @@ -28,3 +28,43 @@ Clause-②: no **What an upgrading consumer must do.** Nothing, if `zod` is resolved through these packages — the floor does it. A consumer that pins `zod` itself must move that pin to `^4.6.1` or higher; a pin below it reintroduces the crash on any refusal whose path names an `Object.prototype` member, including the ones these packages emit. `@objectstack/lint` also moves, but only in `devDependencies`, so nothing it publishes changes for a consumer and it takes no release here. + +## The second half the floor move needs: an unknown key refuses TERMINALLY again + +From zod 4.5.0 an `unrecognized_keys` issue carries `continue: true`, so it no +longer aborts the shape that raised it. Two things follow, and both were +measured on this package with the same bodies on 4.4.3 and 4.6.1: + +1. **A closed shape's own refinements now run after the refusal**, adding a + second complaint that contradicts the first. +2. **A union containing that shape loses its envelope.** zod's + `handleUnionResults` returns a single non-aborted member's issues + *unwrapped* instead of raising `invalid_union`, so the union's message + becomes whichever branch zod judged closest. + +At `PUT /api/v1/meta/view` that turned a retired-value refusal into the wrong +branch's prescription. Writing `type: 'page'` on a ViewItem answered: + +``` +Unrecognized key(s) on this view container: `viewKind`, `config`. + • `viewKind` belongs to a single VIEW, not to the container. Wrap it: … +``` + +— naming neither `page` nor its removal. It now answers, as it did before: + +``` +config.type: 'page' was removed from the list-view `type` enum in +@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — … +``` + +**What an upgrading consumer must do.** Nothing. No key or value changed +status: everything this package accepted before it accepts now, and everything +it refused it still refuses. What changed is which of several competing +complaints an author reads, and that a refusal behind a union is again +reported as `invalid_union` with its branches, which is what `z.treeifyError()` +and this package's own `formatZodError` expand. + +⚠️ A closed shape declared with a bare `z.object(…).strict()` or +`z.strictObject(…)` — zod's own, not this package's `strictObject` — does NOT +get this and will still collapse its union. Build closed authoring shapes with +`strictObject`, or re-declare an existing one through `closedObject`. From fa6d92b2670da25e7b1234e4ecd4e80515b21d4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 17:18:44 +0000 Subject: [PATCH 6/9] test(spec,cli): the union formatter pins are fed by a closed product door again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seven assertions that survived the zod floor move were passing over a shape they were never built to see. Both files declared their union fixture from raw zod primitives, and from zod 4.5.0 an `unrecognized_keys` issue carries `continue: true` — so a bare `z.strictObject` arm whose only complaint is an unknown key is the union's lone non-aborted member. `handleUnionResults` short-circuits on exactly that condition and returns the arm's issues unwrapped, so no `invalid_union` is raised and the expansion under test never runs. Both fixtures now feed `NormalizedFilterSchema`, a real product door whose `closedObject` seal makes its unknown-key refusal terminal. The union raises `invalid_union` again and the assertions measure the renderer. Not one assertion byte moved: 78 + 33 = 111 assertion statements extracted and diffed against the base, zero changed. Only the fixture declarations and the inputs they are fed changed. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/cli/test/format-zod-union.test.ts | 35 ++++++++++++------ packages/spec/src/shared/error-map.test.ts | 41 +++++++++++++++------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/packages/cli/test/format-zod-union.test.ts b/packages/cli/test/format-zod-union.test.ts index 42e2ef9127d..3446dbd120e 100644 --- a/packages/cli/test/format-zod-union.test.ts +++ b/packages/cli/test/format-zod-union.test.ts @@ -35,6 +35,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; import { ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { NormalizedFilterSchema } from '@objectstack/spec/data'; import { formatZodErrors } from '../src/utils/format'; import { childEnv } from './helpers/serve-process.js'; @@ -58,15 +59,29 @@ function render(error: z.ZodError): string { return stripAnsi(captured.join('\n')); } -/** The campaign's shape: a string form OR a strict object form. */ -const ACTION_REF = z.union([ - z.string(), - z.strictObject({ type: z.string(), params: z.record(z.string(), z.unknown()).optional() }), -]); +/** + * The campaign's shape: a string form OR a closed object form. + * + * [#19581] The closed arm is a REAL PRODUCT DOOR — `NormalizedFilterSchema`, + * whose `closedObject` seal makes its unknown-key refusal terminal — and not a + * `z.strictObject` declared here. From zod 4.5.0 an `unrecognized_keys` issue + * carries `continue: true`, so a bare `z.strictObject` arm whose only complaint + * is an unknown key is the union's lone NON-ABORTED member: zod's + * `handleUnionResults` short-circuits and returns that arm's issues unwrapped, + * no `invalid_union` is raised, and every assertion below passes over a shape it + * was never built to see. A fixture that cannot produce the code under test is + * not a weaker pin, it is no pin at all. + * + * ⛔ Do NOT re-declare this arm from raw zod primitives. What keeps these + * assertions honest is that the arm's refusal is TERMINAL, and terminal is a + * property the product's closed-object entry confers — not one a test file can + * assert about itself. + */ +const ACTION_REF = z.union([z.string(), NormalizedFilterSchema]); describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('prints the failing branch prose under the union line', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }).error!); + const out = render(ACTION_REF.safeParse({ args: { a: 1 } }).error!); // The union's own two lines are PRESERVED — they are what says "no branch // matched", and keeping them makes this change strictly additive: nothing // that printed before #5341 stopped printing. @@ -76,7 +91,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { }); it('drops the kind-mismatch branch that carries no prescription', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: 1 }).error!); + const out = render(ACTION_REF.safeParse({ args: 1 }).error!); // Paired deliberately: the `not` alone would also pass if the expansion // produced NOTHING — a green for the empty reason. The positive assertion // is what makes the negative one mean "selected against", not "absent". @@ -88,7 +103,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('resolves branch paths against the union, not relative to it', () => { const schema = z.object({ actions: z.array(ACTION_REF) }); - const out = render(schema.safeParse({ actions: [{ type: 'log', args: { a: 1 } }] }).error!); + const out = render(schema.safeParse({ actions: [{ args: { a: 1 } }] }).error!); expect(out).toContain('✗ actions.0: Unrecognized key: "args"'); // Never the bare relative path a naive splice would print. expect(out).not.toContain('✗ (root): Unrecognized key'); @@ -96,7 +111,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { it('expands a union nested inside a union', () => { const schema = z.object({ on: z.union([z.string(), z.object({ actions: z.array(ACTION_REF) })]) }); - const out = render(schema.safeParse({ on: { actions: [{ type: 'log', args: { a: 1 } }] } }).error!); + const out = render(schema.safeParse({ on: { actions: [{ args: { a: 1 } }] } }).error!); expect(out).toContain('✗ on.actions.0: Invalid input'); expect(out).toContain('✗ on.actions.0: Unrecognized key: "args"'); }); @@ -133,7 +148,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { }); it('counts a union as one issue however many lines explain it', () => { - const out = render(ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }).error!); + const out = render(ACTION_REF.safeParse({ args: { a: 1 } }).error!); expect(out).toContain('1 validation error(s) total'); }); }); diff --git a/packages/spec/src/shared/error-map.test.ts b/packages/spec/src/shared/error-map.test.ts index 7d5bb57b6b6..2e9625a8537 100644 --- a/packages/spec/src/shared/error-map.test.ts +++ b/packages/spec/src/shared/error-map.test.ts @@ -7,6 +7,7 @@ import { safeParsePretty, } from './error-map.zod'; import { FieldType } from '../data/field.zod'; +import { NormalizedFilterSchema } from '../data/filter.zod'; describe('objectStackErrorMap', () => { describe('FieldType enum errors', () => { @@ -187,14 +188,26 @@ describe('formatZodError', () => { // `submitBehavior` reached for `discriminatedUnion` in the first place. Both // directions are pinned below. describe('[#4971] formatZodError expands invalid_union branches', () => { - // The campaign's shape: a string form OR a strict object form. - const ACTION_REF = z.union([ - z.string(), - z.strictObject({ type: z.string(), params: z.record(z.string(), z.unknown()).optional() }), - ]); + // The campaign's shape: a string form OR a closed object form. + // + // [#19581] The closed arm is a REAL PRODUCT DOOR — `NormalizedFilterSchema`, + // whose `closedObject` seal this PR's `strict-object.ts` half put there — and + // not a `z.strictObject` declared here. From zod 4.5.0 an `unrecognized_keys` + // issue carries `continue: true`, so a bare `z.strictObject` arm whose only + // complaint is an unknown key is the union's lone NON-ABORTED member: zod's + // `handleUnionResults` short-circuits and returns that arm's issues unwrapped, + // no `invalid_union` is raised, and every assertion below passes over a shape + // it was never built to see. A fixture that cannot produce the code under test + // is not a weaker pin, it is no pin at all. + // + // ⛔ Do NOT re-declare this arm from raw zod primitives. What keeps these + // assertions honest is that the arm's refusal is TERMINAL, and terminal is a + // property the product's closed-object entry confers — not one a test file can + // assert about itself. + const ACTION_REF = z.union([z.string(), NormalizedFilterSchema]); it('renders the failing branch prose under the union line', () => { - const result = ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }); + const result = ACTION_REF.safeParse({ args: { a: 1 } }); expect(result.success).toBe(false); const formatted = formatZodError(result.error!); @@ -205,7 +218,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { }); it('drops the kind-mismatch branch that carries no prescription', () => { - const formatted = formatZodError(ACTION_REF.safeParse({ type: 'log', args: 1 }).error!); + const formatted = formatZodError(ACTION_REF.safeParse({ args: 1 }).error!); // `expected string, received object` is the string branch complaining that // the author did not write a string. They never meant to. expect(formatted).not.toContain('expected string'); @@ -214,7 +227,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('resolves nested paths against the union, not relative to it', () => { const schema = z.object({ actions: z.array(ACTION_REF) }); const formatted = formatZodError( - schema.safeParse({ actions: [{ type: 'log', args: { a: 1 } }] }).error!, + schema.safeParse({ actions: [{ args: { a: 1 } }] }).error!, ); expect(formatted).toContain('✗ actions.0: Invalid input'); expect(formatted).toContain('✗ actions.0: Unrecognized key: "args"'); @@ -225,7 +238,7 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('expands a union nested inside a union', () => { const schema = z.object({ on: z.union([z.string(), z.object({ actions: z.array(ACTION_REF) })]) }); const formatted = formatZodError( - schema.safeParse({ on: { actions: [{ type: 'log', args: { a: 1 } }] } }).error!, + schema.safeParse({ on: { actions: [{ args: { a: 1 } }] } }).error!, ); expect(formatted).toContain(' ✗ on: Invalid input'); expect(formatted).toContain(' ✗ on.actions.0: Invalid input'); @@ -299,21 +312,25 @@ describe('[#4971] formatZodError expands invalid_union branches', () => { it('counts the union as the one issue zod raised', () => { // The header must keep agreeing with `error.issues` / the REST body, or // the CLI and the API would disagree about how many things are wrong. - const result = ACTION_REF.safeParse({ type: 'log', args: { a: 1 } }); + const result = ACTION_REF.safeParse({ args: { a: 1 } }); expect(result.error!.issues).toHaveLength(1); expect(formatZodError(result.error!)).toContain('(1 issue)'); }); it('stops expanding after three levels of nesting', () => { // Bounded output: unions nest arbitrarily, the terminal does not. - const leaf = z.strictObject({ ok: z.string() }); + // [#19581] Same real product door as `ACTION_REF`, and for the same reason: + // a `z.strictObject` leaf declared here is the innermost union's lone + // non-aborted arm, so no `invalid_union` is raised at ANY level and the + // depth this measures is the depth of an expansion that never happened. + const leaf = NormalizedFilterSchema; const l1 = z.union([z.string(), leaf]); const l2 = z.union([z.string(), z.object({ n: l1 })]); const l3 = z.union([z.string(), z.object({ n: l2 })]); const l4 = z.union([z.string(), z.object({ n: l3 })]); const formatted = formatZodError( - l4.safeParse({ n: { n: { n: { ok: 'x', bogus: 1 } } } }).error!, + l4.safeParse({ n: { n: { n: { bogus: 1 } } } }).error!, ); const depths = formatted .split('\n') From dbd78c2d8a3d20c878a8775b48a11fcb75be4248 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 18:30:45 +0000 Subject: [PATCH 7/9] fix(spec): the closed-object seam survives its own import cycle and primes its map Two regressions this PR introduced, one root cause each, both in `shared/strict-object.ts`. 1. `ZodClosedObject` was a module-level `const`. `strictObject` runs at module scope for schemas inside the `field -> strict-object -> suggestions -> field` cycle and now reaches that constructor on every call, so whenever the loader enters this module second the call lands in the constructor's temporal dead zone. Under `OS_EAGER_SCHEMAS=1` -- how `build-schemas.ts` runs -- importing the package root died with `ReferenceError: Cannot access 'ZodClosedObject' before initialization`, raised from `data/field-value.zod.ts` before a single schema was built. It is now built on first use behind a hoisted function, the shape `declarationStore()` already documents; `markUnknownKeyRefusalTerminal` becomes a hoisted declaration for the same reason. 2. zod 4.6's `safeParse` returns its failure with `error` as a lazy getter, so issue finalization -- and with it the unknown-key map's one build -- slides from the parse that refused the key to whenever a consumer first reads `.error`. Measured on both lines with the same bodies: the prescription an author reads is byte-identical, so no message is lost; what moves is WHEN this module reaches across its own import cycle. The closed-object parse wrapper now primes the map on the refusal path, restoring the property `strict-object.test.ts` pins, which is `main`'s and is unmodified here. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- packages/spec/src/shared/strict-object.ts | 132 +++++++++++++++++----- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/packages/spec/src/shared/strict-object.ts b/packages/spec/src/shared/strict-object.ts index da60033a1af..96bc167d2e7 100644 --- a/packages/spec/src/shared/strict-object.ts +++ b/packages/spec/src/shared/strict-object.ts @@ -266,6 +266,29 @@ export function strictObjectDeclarations(): readonly StrictObjectDeclaration[] { return declarationStore(); } +/** + * [#19581] A {@link strictObjectError} map, plus the handle that BUILDS its + * unknown-key half without invoking it. + * + * The map is deliberately deferred (see the comment inside `strictObjectError`), + * and until zod 4.5 "deferred" and "built the moment a key is rejected" were the + * same instant: `safeParse` finalized every issue through the schema's error map + * before it returned. From 4.6 the failure result carries `error` as a lazy + * getter (`failure()` in `v4/core/parse.js`) whose stated purpose is to stop the + * result pinning the parsed value, so finalization — and with it this build — + * slides to whenever some consumer first reads `.error`. + * + * ⛔ That is not a message change: the prescription an author reads is + * byte-identical on both lines, measured. What moves is WHEN this module reaches + * across the `field → strict-object → suggestions → field` import cycle, from + * "inside the parse that refused the key" to "wherever the error object is first + * read". The cycle is the whole reason the build is deferred at all, so the + * moment it happens is a property worth keeping, not an implementation detail — + * which is why {@link markUnknownKeyRefusalTerminal} primes it on the refusal + * path and `strict-object.test.ts` pins that it has happened by then. + */ +type PrimableErrorMap = z.core.$ZodErrorMap & { prime: () => void }; + /** * Register an authoring surface and build its unknown-key error map, **without * closing the shape** — the half of {@link strictObject} that a schema whose @@ -310,16 +333,7 @@ export function strictObjectError( // than making each of them prove it is not in a loop. Same shape as the // deferred map `data/object.zod.ts` already carries for its TDZ problem. let build: z.core.$ZodErrorMap | undefined; - const error: z.core.$ZodErrorMap = (issue) => { - // A retired VALUE FORM is rejected before the unknown-key map is even - // consulted: `issue.code` here is `invalid_type` (the input is not an - // object), so `strictUnknownKeyError` would return undefined and zod's - // bare "expected object, received string" would be all the author sees. - if (retiredForms && issue.code === 'invalid_type') { - const prescription = - typeof issue.input === 'string' ? retiredForms[issue.input] : undefined; - if (prescription) return prescription; - } + const buildMap = (): z.core.$ZodErrorMap => // The table is recorded ONCE, below, with its `shape` — the strong record // the audit reads. Between #5483 and #5593 a second, transcription-shaped // registry existed for the 44 call sites that predated this helper, and @@ -327,7 +341,7 @@ export function strictObjectError( // `strictObject` surface would not be judged twice (the second time against // `knownKeys` rather than the shape). #5593 migrated the last of those call // sites and deleted the registry, so the suppression went with it. - return (build ??= strictUnknownKeyError({ + (build ??= strictUnknownKeyError({ surface, // Declared-but-unwritable keys (tombstones) are excluded — see // `acceptsNothing`. They stay in the SHAPE, so writing one still raises @@ -341,7 +355,26 @@ export function strictObjectError( aliases, guidance, guidanceSets, - }))(issue); + })); + + const error: z.core.$ZodErrorMap = (issue) => { + // A retired VALUE FORM is rejected before the unknown-key map is even + // consulted: `issue.code` here is `invalid_type` (the input is not an + // object), so `strictUnknownKeyError` would return undefined and zod's + // bare "expected object, received string" would be all the author sees. + if (retiredForms && issue.code === 'invalid_type') { + const prescription = + typeof issue.input === 'string' ? retiredForms[issue.input] : undefined; + if (prescription) return prescription; + } + return buildMap()(issue); + }; + + // [#19581] The handle that settles WHEN the reach across the import cycle + // happens — see `PrimableErrorMap`. Building is idempotent, so a caller may + // prime as often as it likes; the map is still constructed once. + (error as PrimableErrorMap).prime = () => { + buildMap(); }; declarationStore().push({ options, shape }); @@ -404,16 +437,39 @@ export function strictObjectError( * that has already been raised; a body with no issues never reaches it, and no * issue is added, removed or re-coded. What changes is which competing * complaint an author reads, and whether the union keeps its envelope. + * + * ⚠️ **A hoisted `function` declaration, for the reason `declarationStore()` + * above is one** — it runs from inside a constructor `strictObject` can reach + * while this module is still initializing, so a `const` arrow would be in its + * temporal dead zone exactly when it is first needed. + * + * It also primes the surface's unknown-key map, because from zod 4.6 a failed + * `safeParse` finalizes its issues only when `.error` is read — see + * {@link PrimableErrorMap}. Priming is confined to the refusal path: a clean + * parse raises no `unrecognized_keys` issue, so a shape nobody has written a + * bad key on still never reaches across the import cycle. */ -const markUnknownKeyRefusalTerminal =

}>(payload: P): P => { +function markUnknownKeyRefusalTerminal

}>( + inst: { _zod: { def: { error?: unknown } } }, + payload: P, +): P { + let refused = false; for (const issue of payload.issues) { - if (issue.code === 'unrecognized_keys') (issue as { continue?: boolean }).continue = false; + if (issue.code === 'unrecognized_keys') { + (issue as { continue?: boolean }).continue = false; + refused = true; + } + } + if (refused) { + const prime = (inst._zod.def.error as Partial | undefined)?.prime; + if (typeof prime === 'function') prime(); } return payload; -}; +} /** - * [#19581] The `ZodObject` variant {@link closedObject} builds. + * [#19581] The `ZodObject` variant {@link closedObject} builds, owned by a + * HOISTED function declaration. * * It is a constructor rather than an instance-level patch because * `util.clone()` rebuilds through `inst._zod.constr`: `.strict()`, `.strip()`, @@ -423,17 +479,39 @@ const markUnknownKeyRefusalTerminal =

('ZodClosedObject', (inst: any, def: any) => { - (z.ZodObject as unknown as { init: (i: unknown, d: unknown) => void }).init(inst, def); - const parse = inst._zod.parse; - inst._zod.parse = (payload: any, ctx: any) => { - const done = parse(payload, ctx); - return done instanceof Promise - ? done.then(markUnknownKeyRefusalTerminal) - : markUnknownKeyRefusalTerminal(done); - }; -}); +function closedObjectConstructor(): new (def: unknown) => unknown { + const self = closedObjectConstructor as unknown as { ctor?: new (def: unknown) => unknown }; + return (self.ctor ??= z.core.$constructor('ZodClosedObject', (inst: any, def: any) => { + (z.ZodObject as unknown as { init: (i: unknown, d: unknown) => void }).init(inst, def); + const parse = inst._zod.parse; + inst._zod.parse = (payload: any, ctx: any) => { + const done = parse(payload, ctx); + return done instanceof Promise + ? done.then((settled: any) => markUnknownKeyRefusalTerminal(inst, settled)) + : markUnknownKeyRefusalTerminal(inst, done); + }; + }) as unknown as new (def: unknown) => unknown); +} /** * [#19581] Re-declare a closed object schema so its unknown-key refusal is @@ -444,7 +522,7 @@ const ZodClosedObject = z.core.$constructor('ZodClosedObject', (inst: * and must not be re-routed through the `strictObject` error map. */ export function closedObject(schema: S): S { - return new (ZodClosedObject as unknown as new (def: unknown) => S)( + return new (closedObjectConstructor() as unknown as new (def: unknown) => S)( (schema as unknown as { _zod: { def: unknown } })._zod.def, ); } From 9abe79f981df11cec451805c2d5af8e507498e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 11:29:16 +0000 Subject: [PATCH 8/9] docs(spec): regenerate the four reference pages the main merge left on one side The merge of origin/main (afc3b64928) routed four generated reference pages through the os-regen driver, which defers them instead of text-merging: ui/view, ui/page, data/object and api/protocol. Each was edited on both sides since the fork point (eff0a96223), so the merge commit carries one side only. Regenerated from the merged tree with the chain the pre-commit hook names: `gen:schema` then `gen:docs`. No hand edit. Against main the four pages now differ by exactly the branch's own 32 changed lines, and against the pre-merge branch tip by exactly main's 46: both sides survive, nothing else moved. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 6 +++--- content/docs/references/data/object.mdx | 14 ++++++-------- content/docs/references/ui/page.mdx | 4 ++-- content/docs/references/ui/view.mdx | 22 +++++++++++----------- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 216cea29529..0b31de4d945 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1615,9 +1615,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | -| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | +| **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | --- @@ -1910,7 +1910,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it | +| **enableOnInstall** | `boolean` | optional | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive honours it on the registry row: `true` enables, `false` disables, and ABSENT keeps the row's current lifecycle state (a fresh install lands enabled) | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | ### Nested Shape: `InstallPackageRequest.manifest` @@ -2274,7 +2274,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | -| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `NO_DRAFT`. Absent = environment-wide. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 668cff37b64..de7fc53f46c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -153,7 +153,7 @@ const result = ApiMethod.parse(data); | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers; "__proto__", "constructor" and "prototype" are refused. | | **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | -| **tenancy** | `{ enabled: boolean; tenantField?: string; organizationField?: string }` | optional | Multi-tenancy configuration for SaaS applications | +| **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | | **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). | | **requiredPermissions** | `string[] \| { read?: string[]; create?: string[]; update?: string[]; delete?: string[] }` | optional | [ADR-0066 D3/⑤] Capabilities required to access this object (AND-gate) — `string[]` gates all CRUD, or a `{read,create,update,delete}` map gates per operation. | | **lifecycle** | `{ class: Enum<'record' \| 'audit' \| 'telemetry' \| 'transient' \| 'event'>; retention?: object; ttl?: object; storage?: object; … }` | optional | Data lifecycle contract (ADR-0057): class + retention/ttl/rotation/archive policies enforced by the platform LifecycleService. | @@ -218,7 +218,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | optional | Human readable label | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | -| **format** | `string` | optional | Format string (e.g. email, phone) | +| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | @@ -311,7 +311,7 @@ const result = ApiMethod.parse(data); | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | | **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. | -| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum. | +| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum. | ### Nested Shape: `Object.tenancy` @@ -319,7 +319,6 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | | **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | -| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | ### Nested Shape: `Object.access` @@ -461,7 +460,7 @@ const result = ApiMethod.parse(data); | **successMessage** | `string \| Record` | optional | Success message to show after execution | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | | **refreshAfter** | `boolean` | optional (default: `false`) | Refresh view after execution | -| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. | +| **undoable** | `boolean` | optional | Offer an Undo affordance after this single-record update action succeeds. `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set here, but `type: 'api'` is fulfilled by the console, which builds the undo envelope from `undoable` alone. Those two shapes — `operation: 'update'` and `type: 'api'` — are the whole fulfillable set; on a registered action `undoable` beside any other shape is refused, because nothing would anchor the capture. | | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | @@ -551,7 +550,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | optional | Human readable label | | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>` | ✅ | Field Data Type | | **description** | `string` | optional | Tooltip/Help text | -| **format** | `string` | optional | Format string (e.g. email, phone) | +| **format** | `string` | optional | Free-form string whose meaning depends on the field type and on the reader. The spec declares NO vocabulary for it and checks nothing but that it is a string, so any string parses on any field type. On an `autonumber` field it is the record-number PATTERN — the shorthand that predates `autonumberFormat`, which wins when both are present: `format: 'INV-{0000}'` mints `INV-0001` on both the query engine and the SQL driver, while a value carrying no `{...}` token is emitted as literal text with the bare counter appended (`format: 'email'` mints `email1`). Prefer `autonumberFormat` on a new field. On any other field type the server does not act on it: it picks no column type, coerces no value and runs no check from it. The Studio UI reads it as a display hint, in words and with defaults that its renderers own and declare. For example, the `date` and `datetime` cells read it as a display STYLE, and on a plain-text field the shared cell-renderer resolver reads a small set of words that promote the cell to a richer renderer, such as a link; each of those falls back silently to a default rendering when it does not recognise the word. To constrain a VALUE, use the field `type` (the write-time record validator's built-in email, url and phone checks key on `type`, never on this key) or a `format` validation rule, whose own `format` key is the closed set `email` \| `url` \| `phone` \| `json`. | | **required** | `boolean` | optional (default: `false`) | Write-time contract (ADR-0113): an insert must provide a non-null value, and an update may not null it out. On a multi-value lookup (`multiple: true`) required means NON-EMPTY array — an emptied required set fails validation loudly; `[]` does not satisfy it (maintainer ruling 2026-08-18). NOT a column constraint — the physical NOT NULL is a separate explicit opt-in (`storage.notNull`), so tightening this on a deployed object is safe: existing null rows stay readable, and editable as long as the write does not touch this field. | | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional (default: `false`) | Is searchable | @@ -667,7 +666,7 @@ External datasource binding (ADR-0015) | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | | **collapsible** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsed`; use the `collapse` enum. | -| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] Boolean pair with `collapsible`; use the `collapse` enum. | +| **collapsed** | `boolean` | optional | [DEPRECATED → collapse] true → 'collapsed' (collapsible, starts closed) on its own — it needs no `collapsible` and outranks `collapsible: false`; false → 'none', or 'expanded' beside `collapsible: true`. Use the `collapse` enum. | --- @@ -748,7 +747,6 @@ Boolean-or-predicates override for a built-in CRUD affordance. | :--- | :--- | :--- | :--- | | **enabled** | `boolean` | ✅ | Enable multi-tenancy for this object | | **tenantField** | `string` | optional | Column this object is tenant-scoped by. Omit it unless the tenant column genuinely is not the platform's: when undeclared the driver falls back to `organization_id`, the kernel-injected column the RLS predicates and `tenantPolicy()` also assume. A declared name is honoured only when the object really has that field — otherwise the same `organization_id` fallback applies. No default is materialized here on purpose. | -| **organizationField** | `string` | optional | STAMP-ONLY: column carrying the organization a row is ABOUT, consulted by the three sanctioned platform-row writers — audit stamping, the approval-row writer (`plugin-approvals`), and the automation-run recorder (`service-automation`) — via the shared `resolveRecordOrganizationField` resolver in `@objectstack/metadata-core`. It does NOT tenant-scope anything — no read path (`applyTenantScope`, `injectTenantOnInsert`, `computeTenantLayer0Filter`) reads it, so declaring it never walls the object and never hides rows. Declare it only when the organization a row belongs to lives under a column that deliberately is NOT the tenant column: `sys_api_key` is the shipped example — a credential table that must stay unwalled (`enabled: false`) while history/revocation audit rows stamp the organization of the key they describe (`active_organization_id`). Ordinary tenant objects omit it; their stamp column is resolved from `tenantField` / `organization_id` already. Honoured only when the object really has the field, like `tenantField`. | --- diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index bdb0bcadbe0..aa57dc249e5 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -41,7 +41,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ElementDataSource.sort[number]` @@ -114,7 +114,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `InterfacePageConfig.appearance` diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index c1ed28b02a9..93b40004fb2 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -317,7 +317,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- @@ -331,8 +331,8 @@ View filter rule | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -463,8 +463,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -479,8 +479,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -896,7 +896,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ListView.userFilters` @@ -1308,7 +1308,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Nested Shape: `ObjectListView.selection` @@ -2065,7 +2065,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| 'starts_with' \| 'ends_with' \| 'greater_than' \| 'less_than' \| 'greater_than_or_equal' \| … +10 more>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | ### Allowed Values: `ViewFilterRule.operator` @@ -2515,7 +2515,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **field** | `string` | ✅ | Field name to filter on | | **operator** | `Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>` | ✅ | Filter operator | -| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. | +| **value** | `string \| number \| boolean \| null \| (string \| number)[]` | optional | Filter value. The accepted SHAPE depends on the operator: `in` / `not_in` take an array (any length, including []), `between` takes exactly [min, max], every other operator takes a scalar. The unary operators (is_empty / is_not_empty / is_null / is_not_null) take their direction from the operator name and ignore this key. One operator bounds the VALUE as well as the shape: `icontains` takes a NON-EMPTY STRING, the comparand the Filter Protocol conformance table declares for it — an empty comparand constrains nothing and a non-string one would answer a query nobody wrote, and both are refused at the query path too. | --- From a6a34d0c05ecb35f359c74d1f7e5f3cead558cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 12:10:56 +0000 Subject: [PATCH 9/9] docs(spec): regenerate the three reference pages the second main merge left on one side The merge of origin/main (8cbc3c0084) routed three generated reference pages through the os-regen driver, which defers them instead of text-merging: ui/view, data/object and api/protocol. Each was edited on both sides since the previous merge point (afc3b64928), so the merge commit carries one side only. ui/page was edited on this branch only and keeps the branch bytes. Regenerated from the merged tree with the chain the pre-commit hook names: `gen:schema` then `gen:docs`. No hand edit. Against main the four pages differ by exactly the branch's own 32 changed lines, and against the pre-merge branch tip by exactly main's 54: both sides survive, nothing else moved. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 12 ++++---- content/docs/references/data/object.mdx | 6 ++-- content/docs/references/ui/view.mdx | 36 ++++++++++++------------ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 0b31de4d945..d2eeb72a61d 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1664,7 +1664,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1688,8 +1688,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1749,7 +1749,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1773,8 +1773,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index de7fc53f46c..7e4ddde94f6 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -363,7 +363,7 @@ const result = ApiMethod.parse(data); | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -387,8 +387,8 @@ const result = ApiMethod.parse(data); | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 93b40004fb2..4d92266aeb7 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -792,7 +792,7 @@ Map view configuration | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -817,8 +817,8 @@ Map view configuration | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1204,7 +1204,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1228,8 +1228,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1807,7 +1807,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1831,8 +1831,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -1892,7 +1892,7 @@ Tab configuration for multi-tab view interface | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -1916,8 +1916,8 @@ Tab configuration for multi-tab view interface | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2132,7 +2132,7 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -2157,8 +2157,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. | @@ -2308,7 +2308,7 @@ This schema accepts one of the following structures: | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | | | **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | -| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns | +| **columns** | `string[] \| { field: string; label?: string \| Record; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns — the PROJECTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: this list is the candidate set AND the baseline order; `hiddenFields` subtracts from it and `fieldOrder` orders what survives. `hiddenFields` and `fieldOrder` cannot add a field omitted here. An empty list declares no projection, so neither of them applies: which columns show is then left to the renderer (objectui's `ListView` grid derives the object's default columns). | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | | | **searchableFields** | `string[]` | optional | Fields enabled for search | @@ -2333,8 +2333,8 @@ This schema accepts one of the following structures: | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | | **rowColor** | `{ field: string; colors?: Record }` | optional | Color rows based on field value | -| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view | -| **fieldOrder** | `string[]` | optional | Explicit field display order for this view | +| **hiddenFields** | `string[]` | optional | Fields to hide in this specific view — the SUBTRACTION of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: each name listed here is removed from the `columns` projection before `fieldOrder` orders the remainder. A name `columns` never projected subtracts nothing. | +| **fieldOrder** | `string[]` | optional | Explicit field display order for this view — the ORDERING of the per-view field composition `columns` x `hiddenFields` x `fieldOrder`: it sorts what survives `columns` minus `hiddenFields` and never adds a field. A surviving column absent from `fieldOrder` sorts LAST, after every listed one, keeping its `columns`-relative order; a name listed here that did not survive orders nothing. | | **rowActions** | `string[]` | optional | Actions available for individual row items | | **bulkActions** | `string[]` | optional | Actions available when multiple rows are selected | | **bulkActionDefs** | `{ name: string; label?: string; icon?: string; variant?: Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'outline'>; … }[]` | optional | Rich bulk action definitions (schema-driven, executed via BulkActionDialog). Use a def for a mass data-plane mutation ('update' with a `patch` / 'delete') that no action expresses, or for an `operation: 'custom'` + `execution: 'aggregate'` entry that dispatches the action it NAMES once for the whole selection — the renderer injects `params._selectedIds: string[]` (read that on the server, not `recordId`) so a single call can produce one aggregate artifact (zip of QR codes, merged PDF, batch print). Aggregate results are all-or-nothing: a handler that cannot cover the whole selection must reject, and per-row retry is replaced by re-running the action. `batchSize` does not apply (the call is never chunked); set `maxRecords` on defs whose server work is expensive. For the PER-RECORD dispatch use `bulkActions: ['']` instead — the bare-string form, promoted with the action's own label, params and `visible`; a 'custom' def without `execution: 'aggregate'` has no dispatcher and is refused at parse time. Toolbar url/api actions can also interpolate the current selection via `${ctx.selection.ids}` / `${ctx.selection.count}`. |