Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/adr-0089-d3a-strict-visibility-schemas.md
Original file line number Diff line number Diff line change
@@ -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`).
7 changes: 7 additions & 0 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0089-unify-visibility-predicate-naming.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/protocol-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4457,6 +4457,7 @@
"resilientFetch (function)",
"safeParsePretty (function)",
"singularToPlural (function)",
"strictVisibilityError (const)",
"suggestFieldType (function)",
"tmpl (function)"
]
Expand Down
45 changes: 45 additions & 0 deletions packages/spec/src/shared/visibility.ts
Original file line number Diff line number Diff line change
@@ -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)
*
Expand Down Expand Up @@ -64,3 +66,46 @@ export function normalizeVisibleWhen<T extends WithVisibilityAliases>(
}
return { ...rest, visibleWhen: canonical } as Omit<T, 'visibleOn' | 'visibility'>;
}

/** 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;
};
27 changes: 27 additions & 0 deletions packages/spec/src/ui/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,3 +1016,30 @@ describe('ADR-0089 — visibleWhen unification (page component)', () => {
expect((parsed as Record<string, unknown>).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);
});
});
4 changes: 2 additions & 2 deletions packages/spec/src/ui/page.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
6 changes: 3 additions & 3 deletions packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -770,7 +770,7 @@ export const FormFieldSchema: z.ZodType<any> = 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
Expand Down Expand Up @@ -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
Expand Down