diff --git a/.changeset/adr-0089-d3a-strict-visibility-schemas.md b/.changeset/adr-0089-d3a-strict-visibility-schemas.md new file mode 100644 index 0000000000..03470bb8f6 --- /dev/null +++ b/.changeset/adr-0089-d3a-strict-visibility-schemas.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": major +--- + +ADR-0089 D3a: flip `.strict()` on the view form + page component schemas so a mis-layered or stale conditional-visibility key is a **loud parse error** instead of a silent strip. + +`FormFieldSchema`, `FormSectionSchema` (`view.zod.ts`) and `PageComponentSchema` (`page.zod.ts`) now reject unknown keys. Previously zod's default strip mode discarded any key these schemas did not declare — including a `visibleWhen` typo, a page-only `visibility` pasted onto a view field (or vice-versa), or a key surviving past its deprecation window — with no diagnostic, shipping inert metadata (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). + +- **Breaking:** metadata carrying a key not declared by these three schemas now fails validation at parse. A monorepo + examples sweep found a single offender (a test fixture using `id`/`title` on a form section instead of the canonical `name`/`label`); all first-party apps and platform metadata parse clean. +- The deprecated `visibleOn` (view form) / `visibility` (page component) aliases are **declared** keys, so they keep parsing and normalizing to `visibleWhen` — unchanged. +- Rejection messages name the offending key(s) and, when a key looks like the visibility predicate, point the author at the canonical `visibleWhen` (new `strictVisibilityError` zod error map, exported from `shared/visibility`). diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 69a9066090..ceef8f1171 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -818,6 +818,13 @@ The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated aliases: still accepted and folded into `visibleWhen` at parse time, so existing metadata keeps working. Author new metadata with `visibleWhen`. +Since `@objectstack/spec` 15 (ADR-0089 D3a) the form field/section and page +component schemas are **strict**: a key they do not declare — a `visibleWhen` +typo, a page-only `visibility` pasted onto a view field (or vice-versa), or a +stale key — is now a **loud parse error** instead of being silently dropped. The +error names the offending key and, when it looks like a visibility predicate, +points you back at `visibleWhen`. + Breakpoint-based show/hide is handled separately via the component's `responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see `packages/spec/src/ui/responsive.zod.ts`. diff --git a/docs/adr/0089-unify-visibility-predicate-naming.md b/docs/adr/0089-unify-visibility-predicate-naming.md index dee2e5c2d3..a3698696ea 100644 --- a/docs/adr/0089-unify-visibility-predicate-naming.md +++ b/docs/adr/0089-unify-visibility-predicate-naming.md @@ -1,6 +1,6 @@ # ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest -**Status**: Accepted (2026-07-14) — D1 + D2 + codemod + docs implemented in #2642. D3a (`.strict()` flip) and D3b (lint rule) deferred to follow-up per the ADR's staged rollout (sweep first, then flip). +**Status**: Accepted (2026-07-14) — D1 + D2 + codemod + docs implemented in #2642/#2900; renderer reads in objectui#2490 (#2904); D3b lint rule in #2900 + bidirectional binding-root follow-up (#2903). **D3a (`.strict()` flip) implemented (#2902)** — the monorepo + examples sweep found a single offender (a test fixture), and the strict flip ships as a major (`@objectstack/spec` 15.0.0). **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a declared-but-unchecked visibility key is exactly this class), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata — a mis-layered visibility key that zod strips is inert by accident), [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (the enforce-or-remove pass that already deleted a consumer-less `visibleOn` from `fieldGroups`), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the AI-authoring population this ADR optimizes for), [ADR-0058](./0058-expression-and-predicate-surface.md) (the CEL predicate surface these keys all belong to), [ADR-0087](./0087-metadata-protocol-upgrade-contract.md) (conversion-over-notification; this rename ships as an L1 invisible break via the alias mechanism) **Consumers**: `@objectstack/spec` (the field / view / page zod schemas + the alias normalization), `@objectstack/objectql` (`rule-validator` — the server-side enforcer), `@objectstack/lint` (the new mis-layer / wrong-root rule), ObjectUI form + page renderers, and every AI author of `*.object.ts` / `*.view.ts` / `*.page.ts` diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 8047052805..c2ba396fbe 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -408,7 +408,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { form: { type: 'tabbed', data: { provider: 'object', object: 'lead' }, - sections: [{ id: 'main', title: 'Main', fields: [{ field: 'first_name' }] }], + sections: [{ name: 'main', label: 'Main', fields: [{ field: 'first_name' }] }], }, }; await expect( diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index af11f4f6bf..ba7c261406 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -4457,6 +4457,7 @@ "resilientFetch (function)", "safeParsePretty (function)", "singularToPlural (function)", + "strictVisibilityError (const)", "suggestFieldType (function)", "tmpl (function)" ] diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index 1ee90f4c8a..f2050e4b9b 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import type { z } from 'zod'; + /** * # Conditional-visibility predicate normalization (ADR-0089) * @@ -64,3 +66,46 @@ export function normalizeVisibleWhen( } return { ...rest, visibleWhen: canonical } as Omit; } + +/** A key that is (or is a likely mis-spelling of) the visibility predicate. */ +function looksLikeVisibilityKey(key: string): boolean { + return /vis|conceal|hidden|show.?when/i.test(key); +} + +/** + * Custom zod `error` for the `.strict()` view/page schemas (ADR-0089 D3a). + * + * With `.strict()`, a key these schemas do not declare — a stale `visibleOn` past + * removal, a `visibleWhen` typo, or a wrong-layer paste — is now a **loud parse + * error** instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 + * no-silently-inert). This error map turns that rejection into a *fixable* one: it + * always names the offending key(s), and when a key looks like the + * conditional-visibility predicate it points the author at the canonical + * `visibleWhen`. Every other issue code defers to zod's default (`undefined`). + * + * Wire it as the object's `error` alongside `.strict()`: + * + * ```ts + * z.object({ ..., visibleWhen: Expr.optional() }, { error: strictVisibilityError }) + * .strict() + * .transform(normalizeVisibleWhen) + * ``` + */ +export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => { + if (issue.code !== 'unrecognized_keys') return undefined; + const keys = (issue as { keys?: readonly string[] }).keys ?? []; + const list = keys.map((k) => `\`${k}\``).join(', '); + const base = + `Unrecognized key(s) on this view/page schema: ${list}. ` + + `Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` + + `a mis-layered or stale key is now a loud parse error.`; + if (keys.some(looksLikeVisibilityKey)) { + return ( + base + + ' If this is the conditional-visibility predicate, the canonical key is ' + + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' + + 'component) are still accepted as deprecated aliases.' + ); + } + return base; +}; diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index acd18dbdae..0cebfbac77 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -1016,3 +1016,30 @@ describe('ADR-0089 — visibleWhen unification (page component)', () => { expect((parsed as Record).visibility).toBeUndefined(); }); }); + +describe('ADR-0089 D3a — strict page component schema (loud mis-layered keys)', () => { + it('rejects an unknown key on a page component instead of silently stripping it', () => { + const res = PageComponentSchema.safeParse({ type: 'element:text', notARealKey: 1 }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].code).toBe('unrecognized_keys'); + } + }); + + it('a visibility-ish typo is rejected AND the message points at `visibleWhen`', () => { + const res = PageComponentSchema.safeParse({ type: 'element:text', visibilty: "page.a == 1" }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].message).toContain('visibleWhen'); + } + }); + + it('the deprecated `visibility` alias is still accepted under strict (declared key)', () => { + expect(() => PageComponentSchema.parse({ type: 'element:text', visibility: "page.a == 1" })).not.toThrow(); + }); + + it('rejects a stale `visibleOn` key on a page component (that is the view-form alias, not a page one)', () => { + const res = PageComponentSchema.safeParse({ type: 'element:text', visibleOn: "page.a == 1" }); + expect(res.success).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 379c201d8e..d4e9689339 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen } from '../shared/visibility'; +import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility'; import { SortItemSchema } from '../shared/enums.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -123,7 +123,7 @@ export const PageComponentSchema = lazySchema(() => z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), -}).transform(normalizeVisibleWhen)); +}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); /** * Page Variable Schema diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 65288e1e32..9bed1ecbfc 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -2536,3 +2536,44 @@ describe('ADR-0089 — visibleWhen unification (view form)', () => { expect(parsed.visibleOn).toBeUndefined(); }); }); + +describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () => { + it('rejects an unknown key on a form field instead of silently stripping it', () => { + const res = FormFieldSchema.safeParse({ field: 'state', notARealKey: 1 }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].code).toBe('unrecognized_keys'); + } + }); + + it('a visibility-ish typo is rejected AND the message points at `visibleWhen`', () => { + const res = FormFieldSchema.safeParse({ field: 'state', visibleWhenn: "record.a == 1" }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].message).toContain('visibleWhen'); + } + }); + + it('the deprecated `visibleOn` alias is still accepted under strict (declared key)', () => { + expect(() => FormFieldSchema.parse({ field: 'state', visibleOn: "record.a == 1" })).not.toThrow(); + }); + + it('rejects a stale `visibility` key on a form field (that is the page-component alias, not a view one)', () => { + const res = FormFieldSchema.safeParse({ field: 'state', visibility: "record.a == 1" }); + expect(res.success).toBe(false); + }); + + it('a non-visibility unknown key is rejected without the visibleWhen hint', () => { + const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true }); + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.issues[0].message).not.toContain('visibleWhen'); + expect(res.error.issues[0].message).toContain('bogusKey'); + } + }); + + it('a strict form section still accepts canonical + alias keys', () => { + expect(() => FormSectionSchema.parse({ label: 'S', visibleWhen: 'record.a == 1', fields: [] })).not.toThrow(); + expect(() => FormSectionSchema.parse({ label: 'S', visibleOn: 'record.a == 1', fields: [] })).not.toThrow(); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index eedfbdf327..676a3b81e0 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -5,7 +5,7 @@ import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen } from '../shared/visibility'; +import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { SharingConfigSchema } from './sharing.zod'; import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod'; @@ -770,7 +770,7 @@ export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.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).'), -}).transform(normalizeVisibleWhen)); +}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); /** * Form Layout Section @@ -807,7 +807,7 @@ export const FormSectionSchema = lazySchema(() => z.object({ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config ])), -}).transform(normalizeVisibleWhen)); +}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); /** * Form View Schema