From 67655bdf9140eea9daf89c0240fa27eaaf90bbff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:26:56 +0000 Subject: [PATCH 1/4] fix(spec)!: a dashboard refuses two globalFilters under one name DashboardSchema.globalFilters now declares each filter key (name, else field) once. One custom issue at the second declaration's name (or field) names the key, every index and the fix. Semantic migration entry ui-dashboard-global-filter-duplicate-name-refused carries the prescription; no conversion renames on the author's behalf. Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr Co-authored-by: Claude --- ...rd-global-filter-duplicate-name-refused.ts | 38 +++++ ...board-global-filter-duplicate-name.test.ts | 148 ++++++++++++++++++ packages/spec/src/ui/dashboard.zod.ts | 33 +++- .../src/ui/duplicate-global-filter-names.ts | 96 ++++++++++++ 4 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts create mode 100644 packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts create mode 100644 packages/spec/src/ui/duplicate-global-filter-names.ts diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts new file mode 100644 index 00000000000..f7160057085 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29023. An accept-set narrowing of the dashboard schema with no conversion: +// two globalFilters entries under one key (name, else field) are refused by the +// dashboard parse. Which definition keeps the name, and which widgets bind the +// renamed one, only the author knows. Same class as +// ui-dashboard-filter-binding-unknown-refused. +export const entry: SemanticMigration = { + id: 'ui-dashboard-global-filter-duplicate-name-refused', + surface: 'dashboard.globalFilters — two or more entries under the same filter key (the entry\'s ' + + '`name`, or its `field` when it has no `name`), e.g. globalFilters: [{ name: \'r\', field: ' + + '\'region\' }, { name: \'r\', field: \'status\' }]', + replacement: 'One entry per filter key on each dashboard. Rename all but one of the colliding entries ' + + '(and every widget `filterBindings` key that means a renamed one), or delete the copy. Two filters ' + + 'over the same `field` stay legal when their names differ.', + reason: + 'A global filter\'s key is the one the filter bar draws its control under, the key a widget\'s ' + + '`filterBindings` names and the `page.` variable its value is published as. The schema typed ' + + '`globalFilters` as a plain array, so two entries under one key parsed and the dashboard save door ' + + 'answered 200. The board then drew ONE control, and the value typed into it filtered the field of ' + + 'the other definition: the widget query carried the second entry\'s field and the tile read a number ' + + 'the author never asked for, with nothing on the page to say so. `DashboardSchema` now refuses the ' + + 'pair at every door that parses a dashboard: `defineStack`, `os validate`, `os build` and the ' + + 'dashboard save door (`422 INVALID_METADATA`), with one issue at the second declaration\'s `name` ' + + '(or `field`) naming the key and every index. Not convertible: which definition keeps the name, and ' + + 'whether the copy should go, is a fact only the author holds, and renaming on their behalf would ' + + 'silently unbind every widget that meant the renamed one.', + acceptanceCriteria: + 'Each dashboard parses without a `custom` issue at `globalFilters[i].name` (or `.field`) of the form ' + + '"This dashboard declares the global filter X twice in globalFilters: globalFilters[i] and ' + + 'globalFilters[j]", and saves. After renaming, check that every widget `filterBindings` key names the ' + + 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A ' + + 'stored `sys_metadata` dashboard that still repeats a key is refused on its next save; nothing ' + + 'rewrites it. Measured before the change: no authored dashboard in this repository\'s examples, ' + + 'platform objects, docs or skills repeats a filter key.', +}; diff --git a/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts b/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts new file mode 100644 index 00000000000..4aecb63d088 --- /dev/null +++ b/packages/spec/src/ui/dashboard-global-filter-duplicate-name.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #29023 — a dashboard declares each global filter key (its `name`, else its + * `field`) once. + * + * Measured on `main` @ `12aad143` before the rule: `DashboardSchema` accepted + * `globalFilters: [{ name: 'r', field: 'region' }, { name: 'r', field: 'status' }]`, + * the save door answered 200, and the board drew ONE control whose value + * filtered `status`, the second definition's field. The refusal is located at + * the second declaration's key and names every index. + */ +import { describe, expect, it } from 'vitest'; +import { DashboardSchema } from './dashboard.zod'; +import { duplicateGlobalFilterNames } from './duplicate-global-filter-names'; +import { globalFilterKey } from '../system/i18n-resolver'; + +const board = (globalFilters: unknown[]) => ({ + name: 'qa_probe_dfn', + label: 'QA probe', + widgets: [], + globalFilters, +}); + +const customIssues = (input: unknown) => { + const result = DashboardSchema.safeParse(input); + if (result.success) return []; + return result.error.issues.filter((i) => i.code === 'custom'); +}; + +describe('#29023 DashboardSchema refuses two globalFilters under one name', () => { + it('CONTROL: two filters with distinct names parse', () => { + const result = DashboardSchema.safeParse(board([ + { name: 'r', field: 'region', type: 'text' }, + { name: 's', field: 'status', type: 'text' }, + ])); + expect(result.success).toBe(true); + }); + + it('CONTROL: two distinct names over the SAME field parse (the key is the name, not the field)', () => { + const result = DashboardSchema.safeParse(board([ + { name: 'region_a', field: 'region', type: 'text' }, + { name: 'region_b', field: 'region', type: 'select', options: [{ value: 'north', label: 'North' }] }, + ])); + expect(result.success).toBe(true); + }); + + it('the card reproduction is refused at globalFilters[1].name, naming both indices, the name and the fix', () => { + const result = DashboardSchema.safeParse(board([ + { name: 'r', field: 'region', type: 'text' }, + { name: 'r', field: 'status', type: 'text' }, + ])); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['globalFilters', 1, 'name'] }); + expect(issues[0]!.message.startsWith( + "This dashboard declares the global filter 'r' twice in globalFilters: globalFilters[0] and globalFilters[1].", + )).toBe(true); + expect(issues[0]!.message).toMatch(/Rename all but one of them .* or delete the copy\.$/); + // Runtime strings carry no tracker number (check:doc-authoring). + expect(issues[0]!.message).not.toMatch(/#\d{3,}/); + }); + + it('two UNNAMED filters on one field collide on the field, located at globalFilters[1].field', () => { + const issues = customIssues(board([ + { field: 'region', type: 'text' }, + { field: 'region', type: 'select', options: [{ value: 'north', label: 'North' }] }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'field'] }); + expect(issues[0]!.message).toContain("the global filter 'region' twice"); + expect(issues[0]!.message).toContain('A filter with no `name` is keyed by its `field`.'); + }); + + it('a name colliding with another entry\'s field (no name) is the same key and is refused', () => { + const issues = customIssues(board([ + { field: 'region', type: 'text' }, + { name: 'region', field: 'sales_region', type: 'text' }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'name'] }); + }); + + it('an empty `name` keys by `field`, as `globalFilterKey` does', () => { + const issues = customIssues(board([ + { name: '', field: 'region', type: 'text' }, + { field: 'region', type: 'text' }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 1, 'field'] }); + }); + + it('three declarations of one name answer ONE issue at the second, listing every index', () => { + const issues = customIssues(board([ + { name: 'r', field: 'region' }, + { name: 'other', field: 'owner' }, + { name: 'r', field: 'status' }, + { name: 'r', field: 'stage' }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ path: ['globalFilters', 2, 'name'] }); + expect(issues[0]!.message.startsWith( + "This dashboard declares the global filter 'r' 3 times in globalFilters: " + + 'globalFilters[0], globalFilters[2] and globalFilters[3].', + )).toBe(true); + }); + + it('two repeated names answer one issue each', () => { + const issues = customIssues(board([ + { name: 'a', field: 'region' }, + { name: 'b', field: 'owner' }, + { name: 'a', field: 'status' }, + { name: 'b', field: 'stage' }, + ])); + expect(issues.map((i) => i.path)).toEqual([['globalFilters', 2, 'name'], ['globalFilters', 3, 'name']]); + }); + + it('the rule travels with `DashboardSchema.shape.globalFilters` (a mirror spreading the shape keeps it)', () => { + const slot = (DashboardSchema as unknown as { shape: Record }) + .shape.globalFilters!; + expect(slot.safeParse([{ name: 'r', field: 'region' }, { name: 'r', field: 'status' }]).success).toBe(false); + expect(slot.safeParse([{ name: 'r', field: 'region' }, { name: 's', field: 'status' }]).success).toBe(true); + expect(slot.safeParse(undefined).success).toBe(true); + }); +}); + +describe('#29023 duplicateGlobalFilterNames keys a filter the way globalFilterKey does', () => { + const filters = [ + { name: 'dept', field: 'department' }, + { field: 'created_at' }, + { name: '', field: 'owner_id' }, + { name: 'stage' , field: 'stage' }, + { label: 'Unkeyed' }, + ]; + + it('every keyed filter resolves to globalFilterKey(filter), and an unkeyed one is passed over', () => { + // Doubling each filter makes every keyed one a duplicate, so the list reads back the predicate's keys. + const keys = duplicateGlobalFilterNames([...filters, ...filters]).map((d) => d.name); + expect(keys).toEqual(filters.map((f) => globalFilterKey(f)).filter((k): k is string => k !== undefined)); + }); + + it('a non-array, a non-object entry and an entry with neither key are left to the schema', () => { + expect(duplicateGlobalFilterNames(undefined)).toEqual([]); + expect(duplicateGlobalFilterNames({ name: 'r' })).toEqual([]); + expect(duplicateGlobalFilterNames([null, 'r', 7, { label: 'x' }, { label: 'y' }])).toEqual([]); + }); +}); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 73f489dcea7..9d78c64d54f 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -14,6 +14,7 @@ import { DATE_RANGE_PRESETS } from '../data/date-range-presets'; import { ChartTypeSchema, ChartConfigSchema, type ChartType } from './chart.zod'; import { ActionType } from './action.zod'; import { navigationTargetRefusal } from './navigation-target'; +import { duplicateGlobalFilterNames } from './duplicate-global-filter-names'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; // `AriaPropsSchema` is no longer imported here: `widgets[].aria` was retired // (#5010). The shape itself is NOT removed — it stays live on `page.aria` / @@ -1668,6 +1669,25 @@ export const GlobalFilterSchema = lazySchema(() => strictObject({ // and it must be this binding, not a copy. .superRefine(checkGlobalFilterDateDefaultValue)); +/** + * A dashboard's `globalFilters` declares each filter key once (#29023). One + * located issue per repeated key, at the SECOND declaration's `name` (or its + * `field`, when that entry is keyed by `field`), naming the key and every + * index. Before this, the dashboard save door saved two filters under one + * name: the board drew one control, and its value filtered the other + * definition's field, so a tile read a number the author never asked for. + * + * Attached to the array, not to the dashboard object, so the rule travels with + * `DashboardSchema.shape.globalFilters` into any mirror that spreads the + * shape. Predicate and message: `duplicate-global-filter-names.ts` + * ({@link duplicateGlobalFilterNames}). Not exported: the schema is the door. + */ +function refuseDuplicateGlobalFilterNames(globalFilters: readonly unknown[], ctx: z.RefinementCtx): void { + for (const duplicate of duplicateGlobalFilterNames(globalFilters)) { + ctx.addIssue({ code: 'custom', path: [duplicate.indexes[1]!, duplicate.secondKey], message: duplicate.message }); + } +} + /** * Dashboard Schema * Represents a page containing multiple visualizations. @@ -1768,8 +1788,17 @@ export const DashboardSchema = lazySchema(() => strictObject({ allowCustomRange: z.boolean().default(true).describe('Allow users to pick a custom date range'), }).optional().describe('Global dashboard date range filter configuration'), - /** Global Filters */ - globalFilters: z.array(GlobalFilterSchema).optional().describe('Global filters that apply to all widgets in the dashboard'), + /** + * Global Filters. Each filter's key (its `name`, else its `field`) is + * declared once per dashboard: the key is what the filter bar draws a + * control under and what a widget's `filterBindings` names, so two entries + * under one key are two definitions behind one control. Refused by + * {@link refuseDuplicateGlobalFilterNames} (#29023). + */ + globalFilters: z.array(GlobalFilterSchema) + .superRefine(refuseDuplicateGlobalFilterNames) + .optional() + .describe('Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once'), /** * [#22611] The audience gate — the platform's one capability key, with the diff --git a/packages/spec/src/ui/duplicate-global-filter-names.ts b/packages/spec/src/ui/duplicate-global-filter-names.ts new file mode 100644 index 00000000000..8ccea35edf1 --- /dev/null +++ b/packages/spec/src/ui/duplicate-global-filter-names.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE predicate behind "a dashboard declares each global filter name once". + * + * A global filter's key is its `name`, or its `field` when it has no `name` + * (`GlobalFilterSchema.name` is declared as defaulting to `field`; the same + * derivation as `globalFilterKey` in `system/i18n-resolver.ts`, which this + * module cannot import without a cycle through `ui/component.zod.ts`). That key + * is the one the filter bar draws a control under, the one a widget's + * `filterBindings` names, and the `page.` variable the value is + * published as. Two entries under one key are two definitions behind one + * control: the board draws a single control, and the value typed into it + * filters the field of whichever definition the renderer kept, so a tile + * counts rows the author never asked for and nothing says so. + * + * Read by `DashboardSchema`'s `globalFilters` refinement (`dashboard.zod.ts`), + * so every door that parses a dashboard refuses the pair: `defineStack`, + * `os validate`, `os build` and the dashboard save door. + * + * Scope is ONE dashboard's own `globalFilters` array. The reserved name + * `dateRange` (the built-in date range) is a different question and is not + * judged here. + */ + +/** One filter key that a dashboard's `globalFilters` array declares more than once. */ +export interface DuplicateGlobalFilterName { + /** The repeated key (`name`, else `field`). */ + readonly name: string; + /** Every index of `globalFilters` that declares it, ascending. */ + readonly indexes: readonly number[]; + /** + * Which key carries the name on the SECOND declaration: `name` when that + * entry authors one, `field` when the key falls back to it. The refusal is + * located there. + */ + readonly secondKey: 'name' | 'field'; + /** The refusal's message, naming the key, every index and the fix. */ + readonly message: string; +} + +/** The key a global filter is addressed by: its non-empty `name`, else its non-empty `field`. */ +function filterKeyOf(filter: unknown): { name: string; key: 'name' | 'field' } | undefined { + if (filter === null || typeof filter !== 'object') return undefined; + const { name, field } = filter as { name?: unknown; field?: unknown }; + if (typeof name === 'string' && name.length > 0) return { name, key: 'name' }; + if (typeof field === 'string' && field.length > 0) return { name: field, key: 'field' }; + return undefined; +} + +/** `globalFilters[0] and globalFilters[1]`, `globalFilters[0], globalFilters[2] and globalFilters[5]`. */ +function joinIndexes(indexes: readonly number[]): string { + const sites = indexes.map((i) => `globalFilters[${i}]`); + if (sites.length <= 1) return sites.join(''); + return `${sites.slice(0, -1).join(', ')} and ${sites[sites.length - 1]}`; +} + +/** + * Every filter key that `globalFilters` declares more than once, in the order + * each key first appears. An empty list means nothing to refuse. Read on a + * parsed dashboard or a stored body alike: a non-array, a non-object entry and + * an entry with neither a string `name` nor a string `field` are left to the + * schema's own issues. + */ +export function duplicateGlobalFilterNames(globalFilters: unknown): DuplicateGlobalFilterName[] { + if (!Array.isArray(globalFilters)) return []; + const sitesByName = new Map(); + globalFilters.forEach((filter, index) => { + const resolved = filterKeyOf(filter); + if (!resolved) return; + const list = sitesByName.get(resolved.name); + if (list) list.push({ index, key: resolved.key }); + else sitesByName.set(resolved.name, [{ index, key: resolved.key }]); + }); + const out: DuplicateGlobalFilterName[] = []; + for (const [name, sites] of sitesByName) { + if (sites.length < 2) continue; + const indexes = sites.map((s) => s.index); + const unnamed = sites.some((s) => s.key === 'field'); + out.push({ + name, + indexes, + secondKey: sites[1]!.key, + message: + `This dashboard declares the global filter '${name}' ` + + `${indexes.length === 2 ? 'twice' : `${indexes.length} times`} in globalFilters: ${joinIndexes(indexes)}. ` + + 'A filter\'s name is the key the filter bar draws its control under and the key widgets bind it by in ' + + '`filterBindings`, so the board would draw one control whose value filters the field of only one of these ' + + 'definitions. ' + + (unnamed ? 'A filter with no `name` is keyed by its `field`. ' : '') + + 'Rename all but one of them (and every widget `filterBindings` key that means a renamed one), or delete ' + + 'the copy.', + }); + } + return out; +} From 0b82a24175d6efcd8e8ab287aeb86fe7988868be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:32:02 +0000 Subject: [PATCH 2/4] chore(spec): ledger rows, reference page and changeset for the duplicate global filter name refusal dropped-refinements.baseline.json gains the five sites the array refinement adds (ui/Dashboard and the four installed-package manifests); the dashboard reference page carries the new describe. Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr Co-authored-by: Claude --- ...-dashboard-global-filter-duplicate-name.md | 19 +++++++++++++++++++ content/docs/references/ui/dashboard.mdx | 2 +- .../spec/dropped-refinements.baseline.json | 7 ++++++- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 .changeset/29023-dashboard-global-filter-duplicate-name.md diff --git a/.changeset/29023-dashboard-global-filter-duplicate-name.md b/.changeset/29023-dashboard-global-filter-duplicate-name.md new file mode 100644 index 00000000000..f9fbdbc784a --- /dev/null +++ b/.changeset/29023-dashboard-global-filter-duplicate-name.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec)!: a dashboard that declares two global filters under one name is refused at every door that parses it + +Clause-②: no (narrowing) +Metadata-protocol: semantic +Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches a filter's `name` as "the stable key `filterBindings` references", defaulting to `field`, and no file under `skills/**` shows two filters under one key; every taught use stays true. + +**BREAKING** for a dashboard whose `globalFilters` repeats a filter key. + +- **Before.** `DashboardSchema` typed `globalFilters` as a plain array, so two entries under one key parsed and the dashboard save door (`PUT /api/v1/meta/dashboard/X`) answered 200. A filter's key is its `name`, or its `field` when it has no `name`: the key the filter bar draws its control under and the key a widget's `filterBindings` names. The board drew one control, and the value typed into it filtered the field of the other definition, so a tile read a number the author never asked for. +- **Now.** `DashboardSchema.globalFilters` refuses the pair, so every door that parses a dashboard refuses it: `defineStack`, `os validate`, `os build` and the dashboard save door (422 `INVALID_METADATA`). One issue per repeated key, at the second declaration's `name` (or its `field`, when that entry has no `name`), naming the key and every index: `This dashboard declares the global filter 'X' twice in globalFilters: globalFilters[i] and globalFilters[j]. …`. The rule sits on the array, so a mirror that spreads `DashboardSchema.shape` keeps it. +- **Not changed.** Distinct names, two filters with distinct names over the same `field`, and the same name on two different dashboards. +- **Fix.** Rename all but one of the colliding entries (and every widget `filterBindings` key that means a renamed one), or delete the copy. +- **Stored rows.** Nothing rewrites a stored dashboard that repeats a key: which definition keeps the name only the author knows. Its next save is refused until it is fixed. The upgrade guide entry is `ui-dashboard-global-filter-duplicate-name-refused`. + + diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 596513583d6..0b710f2a861 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -38,7 +38,7 @@ const result = DashboardSchema.parse(data); | **refreshIntervalSeconds** | `number` | optional | Auto-refresh interval in seconds | | **refreshInterval** | `never` | optional | [REMOVED] `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `refreshIntervalSeconds`; the value (seconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **dateRange** | `{ field?: string; defaultRange?: Enum<'today' \| 'yesterday' \| 'this_week' \| 'last_week' \| 'this_month' \| 'last_month' \| …>; allowCustomRange?: boolean }` | optional | Global dashboard date range filter configuration | -| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard | +| **globalFilters** | `{ name?: string; field: string; object?: string; label?: string \| Record; … }[]` | optional | Global filters that apply to all widgets in the dashboard. Each filter name (its field when it has no name) is declared once | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this dashboard. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index a91dce7c42b..8c18e5acf05 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 248, - "droppedRefinementSites": 983, + "droppedRefinementSites": 988, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -81,6 +81,7 @@ "manifest.assignmentRules.element.entries.element", "manifest.assignmentRules.element.entries.element.assignTo", "manifest.connectors.element.out", + "manifest.dashboards.element.globalFilters", "manifest.dashboards.element.globalFilters.element", "manifest.dashboards.element.header.actions.element", "manifest.dashboards.element.widgets.element", @@ -227,6 +228,7 @@ "data.options[1].manifest.assignmentRules.element.entries.element", "data.options[1].manifest.assignmentRules.element.entries.element.assignTo", "data.options[1].manifest.connectors.element.out", + "data.options[1].manifest.dashboards.element.globalFilters", "data.options[1].manifest.dashboards.element.globalFilters.element", "data.options[1].manifest.dashboards.element.header.actions.element", "data.options[1].manifest.dashboards.element.widgets.element", @@ -365,6 +367,7 @@ "options[1].manifest.assignmentRules.element.entries.element", "options[1].manifest.assignmentRules.element.entries.element.assignTo", "options[1].manifest.connectors.element.out", + "options[1].manifest.dashboards.element.globalFilters", "options[1].manifest.dashboards.element.globalFilters.element", "options[1].manifest.dashboards.element.header.actions.element", "options[1].manifest.dashboards.element.widgets.element", @@ -443,6 +446,7 @@ "data.packages.element.options[1].manifest.assignmentRules.element.entries.element", "data.packages.element.options[1].manifest.assignmentRules.element.entries.element.assignTo", "data.packages.element.options[1].manifest.connectors.element.out", + "data.packages.element.options[1].manifest.dashboards.element.globalFilters", "data.packages.element.options[1].manifest.dashboards.element.globalFilters.element", "data.packages.element.options[1].manifest.dashboards.element.header.actions.element", "data.packages.element.options[1].manifest.dashboards.element.widgets.element", @@ -1588,6 +1592,7 @@ }, "ui/Dashboard": { "sites": [ + "globalFilters", "globalFilters.element", "header.actions.element", "widgets.element", From 8331c7560cc4390343f9e1f508845bdf994e62ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:33:15 +0000 Subject: [PATCH 3/4] docs(spec): the duplicate global filter name entry states what a stored row does Measured at the save door: a stored dashboard that repeats a filter key still loads with _diagnostics valid:false carrying the refusal; its next save, draft or publish, answers 422 INVALID_METADATA. Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr Co-authored-by: Claude --- .changeset/29023-dashboard-global-filter-duplicate-name.md | 2 +- .../18.ui-dashboard-global-filter-duplicate-name-refused.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/29023-dashboard-global-filter-duplicate-name.md b/.changeset/29023-dashboard-global-filter-duplicate-name.md index f9fbdbc784a..403b5a5af94 100644 --- a/.changeset/29023-dashboard-global-filter-duplicate-name.md +++ b/.changeset/29023-dashboard-global-filter-duplicate-name.md @@ -14,6 +14,6 @@ Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches a filt - **Now.** `DashboardSchema.globalFilters` refuses the pair, so every door that parses a dashboard refuses it: `defineStack`, `os validate`, `os build` and the dashboard save door (422 `INVALID_METADATA`). One issue per repeated key, at the second declaration's `name` (or its `field`, when that entry has no `name`), naming the key and every index: `This dashboard declares the global filter 'X' twice in globalFilters: globalFilters[i] and globalFilters[j]. …`. The rule sits on the array, so a mirror that spreads `DashboardSchema.shape` keeps it. - **Not changed.** Distinct names, two filters with distinct names over the same `field`, and the same name on two different dashboards. - **Fix.** Rename all but one of the colliding entries (and every widget `filterBindings` key that means a renamed one), or delete the copy. -- **Stored rows.** Nothing rewrites a stored dashboard that repeats a key: which definition keeps the name only the author knows. Its next save is refused until it is fixed. The upgrade guide entry is `ui-dashboard-global-filter-duplicate-name-refused`. +- **Stored rows.** A stored dashboard that repeats a key still loads, with `_diagnostics` reading `valid: false` and carrying the refusal, and its next save (draft or publish) is refused until it is fixed. Nothing rewrites it: which definition keeps the name only the author knows. The upgrade guide entry is `ui-dashboard-global-filter-duplicate-name-refused`. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts index f7160057085..def130204a5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-dashboard-global-filter-duplicate-name-refused.ts @@ -32,7 +32,8 @@ export const entry: SemanticMigration = { + '"This dashboard declares the global filter X twice in globalFilters: globalFilters[i] and ' + 'globalFilters[j]", and saves. After renaming, check that every widget `filterBindings` key names the ' + 'filter it meant, and that `os validate` reports no `dashboard-filter-binding-unknown` finding. A ' - + 'stored `sys_metadata` dashboard that still repeats a key is refused on its next save; nothing ' + + 'stored `sys_metadata` dashboard that still repeats a key still loads, with `_diagnostics` reading ' + + '`valid: false` and carrying this refusal, and its next save (draft or publish) is refused; nothing ' + 'rewrites it. Measured before the change: no authored dashboard in this repository\'s examples, ' + 'platform objects, docs or skills repeats a filter key.', }; From b0d38071dd8c22f562ffebca88f90f1d2078782e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:34:04 +0000 Subject: [PATCH 4/4] chore(changeset): the duplicate global filter name refusal declares Metadata-protocol narrowed A refused value is narrowed in the AGENTS.md taxonomy; the claim is amended to match. Level, BREAKING banner and ADR-0087 marker unchanged. Claude-Session: https://claude.ai/code/session_01ADHkmNmHGTQ6fDSRm1srQr Co-authored-by: Claude --- .changeset/29023-dashboard-global-filter-duplicate-name.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/29023-dashboard-global-filter-duplicate-name.md b/.changeset/29023-dashboard-global-filter-duplicate-name.md index 403b5a5af94..4c0bdfd16c1 100644 --- a/.changeset/29023-dashboard-global-filter-duplicate-name.md +++ b/.changeset/29023-dashboard-global-filter-duplicate-name.md @@ -5,7 +5,7 @@ fix(spec)!: a dashboard that declares two global filters under one name is refused at every door that parses it Clause-②: no (narrowing) -Metadata-protocol: semantic +Metadata-protocol: narrowed Skills-docs: none — `skills/objectstack-ui/rules/dashboards.md` teaches a filter's `name` as "the stable key `filterBindings` references", defaulting to `field`, and no file under `skills/**` shows two filters under one key; every taught use stays true. **BREAKING** for a dashboard whose `globalFilters` repeats a filter key.