Skip to content

Commit 28b7c28

Browse files
os-zhuangclaude
andauthored
feat(spec)!: strict view/page schemas — mis-layered visibility keys are loud errors (ADR-0089 D3a) (#2943)
* feat(spec)!: strict view/page schemas — mis-layered visibility keys are loud errors (ADR-0089 D3a) BREAKING CHANGE: FormFieldSchema, FormSectionSchema (view.zod.ts) and PageComponentSchema (page.zod.ts) are now `.strict()`. A key these schemas do not declare — a `visibleWhen` typo, a page-only `visibility` on a view field (or vice-versa), or a stale key past its deprecation window — was silently dropped by zod's default strip mode, shipping inert metadata with no diagnostic (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is now a loud parse error. - Deprecated `visibleOn` / `visibility` aliases are declared keys, so they keep parsing and normalizing to `visibleWhen` (D2 unchanged). - New `strictVisibilityError` zod error map (shared/visibility) names the offending key(s) and, when one looks like the visibility predicate, points the author at the canonical `visibleWhen`. - Monorepo + examples sweep found one offender: an objectql test fixture using `id`/`title` on a form section instead of `name`/`label` — fixed. Ships as a major (`@objectstack/spec` 15.0.0). Full spec suite green (6787), plus showcase/crm/platform-objects/objectql/metadata/rest/cli/ dogfood suites green under strict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7oGohmS4pT9H73zoV6Jdb * chore(spec): regenerate api-surface snapshot for strictVisibilityError Additive-only public API change (0 breaking, 1 added) from the new `strictVisibilityError` export (ADR-0089 D3a). Regenerated via `pnpm --filter @objectstack/spec gen:api-surface`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7oGohmS4pT9H73zoV6Jdb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29a4c90 commit 28b7c28

10 files changed

Lines changed: 139 additions & 7 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
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.
6+
7+
`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).
8+
9+
- **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.
10+
- The deprecated `visibleOn` (view form) / `visibility` (page component) aliases are **declared** keys, so they keep parsing and normalizing to `visibleWhen` — unchanged.
11+
- 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`).

content/docs/protocol/objectui/layout-dsl.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,13 @@ The legacy spellings `visibleOn` (view) and `visibility` (page) are `@deprecated
818818
aliases: still accepted and folded into `visibleWhen` at parse time, so existing
819819
metadata keeps working. Author new metadata with `visibleWhen`.
820820

821+
Since `@objectstack/spec` 15 (ADR-0089 D3a) the form field/section and page
822+
component schemas are **strict**: a key they do not declare — a `visibleWhen`
823+
typo, a page-only `visibility` pasted onto a view field (or vice-versa), or a
824+
stale key — is now a **loud parse error** instead of being silently dropped. The
825+
error names the offending key and, when it looks like a visibility predicate,
826+
points you back at `visibleWhen`.
827+
821828
Breakpoint-based show/hide is handled separately via the component's
822829
`responsive.hiddenOn` array (e.g. `hiddenOn: ['xs', 'sm']`), see
823830
`packages/spec/src/ui/responsive.zod.ts`.

docs/adr/0089-unify-visibility-predicate-naming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0089: Unify the conditional-visibility predicate under one name (`visibleWhen`), alias the rest
22

3-
**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).
3+
**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).
44
**Deciders**: ObjectStack Protocol Architects
55
**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)
66
**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`

packages/objectql/src/protocol-meta.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => {
408408
form: {
409409
type: 'tabbed',
410410
data: { provider: 'object', object: 'lead' },
411-
sections: [{ id: 'main', title: 'Main', fields: [{ field: 'first_name' }] }],
411+
sections: [{ name: 'main', label: 'Main', fields: [{ field: 'first_name' }] }],
412412
},
413413
};
414414
await expect(

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4457,6 +4457,7 @@
44574457
"resilientFetch (function)",
44584458
"safeParsePretty (function)",
44594459
"singularToPlural (function)",
4460+
"strictVisibilityError (const)",
44604461
"suggestFieldType (function)",
44614462
"tmpl (function)"
44624463
]

packages/spec/src/shared/visibility.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import type { z } from 'zod';
4+
35
/**
46
* # Conditional-visibility predicate normalization (ADR-0089)
57
*
@@ -64,3 +66,46 @@ export function normalizeVisibleWhen<T extends WithVisibilityAliases>(
6466
}
6567
return { ...rest, visibleWhen: canonical } as Omit<T, 'visibleOn' | 'visibility'>;
6668
}
69+
70+
/** A key that is (or is a likely mis-spelling of) the visibility predicate. */
71+
function looksLikeVisibilityKey(key: string): boolean {
72+
return /vis|conceal|hidden|show.?when/i.test(key);
73+
}
74+
75+
/**
76+
* Custom zod `error` for the `.strict()` view/page schemas (ADR-0089 D3a).
77+
*
78+
* With `.strict()`, a key these schemas do not declare — a stale `visibleOn` past
79+
* removal, a `visibleWhen` typo, or a wrong-layer paste — is now a **loud parse
80+
* error** instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078
81+
* no-silently-inert). This error map turns that rejection into a *fixable* one: it
82+
* always names the offending key(s), and when a key looks like the
83+
* conditional-visibility predicate it points the author at the canonical
84+
* `visibleWhen`. Every other issue code defers to zod's default (`undefined`).
85+
*
86+
* Wire it as the object's `error` alongside `.strict()`:
87+
*
88+
* ```ts
89+
* z.object({ ..., visibleWhen: Expr.optional() }, { error: strictVisibilityError })
90+
* .strict()
91+
* .transform(normalizeVisibleWhen)
92+
* ```
93+
*/
94+
export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => {
95+
if (issue.code !== 'unrecognized_keys') return undefined;
96+
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
97+
const list = keys.map((k) => `\`${k}\``).join(', ');
98+
const base =
99+
`Unrecognized key(s) on this view/page schema: ${list}. ` +
100+
`Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` +
101+
`a mis-layered or stale key is now a loud parse error.`;
102+
if (keys.some(looksLikeVisibilityKey)) {
103+
return (
104+
base +
105+
' If this is the conditional-visibility predicate, the canonical key is ' +
106+
'`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' +
107+
'component) are still accepted as deprecated aliases.'
108+
);
109+
}
110+
return base;
111+
};

packages/spec/src/ui/page.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,3 +1016,30 @@ describe('ADR-0089 — visibleWhen unification (page component)', () => {
10161016
expect((parsed as Record<string, unknown>).visibility).toBeUndefined();
10171017
});
10181018
});
1019+
1020+
describe('ADR-0089 D3a — strict page component schema (loud mis-layered keys)', () => {
1021+
it('rejects an unknown key on a page component instead of silently stripping it', () => {
1022+
const res = PageComponentSchema.safeParse({ type: 'element:text', notARealKey: 1 });
1023+
expect(res.success).toBe(false);
1024+
if (!res.success) {
1025+
expect(res.error.issues[0].code).toBe('unrecognized_keys');
1026+
}
1027+
});
1028+
1029+
it('a visibility-ish typo is rejected AND the message points at `visibleWhen`', () => {
1030+
const res = PageComponentSchema.safeParse({ type: 'element:text', visibilty: "page.a == 1" });
1031+
expect(res.success).toBe(false);
1032+
if (!res.success) {
1033+
expect(res.error.issues[0].message).toContain('visibleWhen');
1034+
}
1035+
});
1036+
1037+
it('the deprecated `visibility` alias is still accepted under strict (declared key)', () => {
1038+
expect(() => PageComponentSchema.parse({ type: 'element:text', visibility: "page.a == 1" })).not.toThrow();
1039+
});
1040+
1041+
it('rejects a stale `visibleOn` key on a page component (that is the view-form alias, not a page one)', () => {
1042+
const res = PageComponentSchema.safeParse({ type: 'element:text', visibleOn: "page.a == 1" });
1043+
expect(res.success).toBe(false);
1044+
});
1045+
});

packages/spec/src/ui/page.zod.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { z } from 'zod';
44
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
55
import { ExpressionInputSchema } from '../shared/expression.zod';
6-
import { normalizeVisibleWhen } from '../shared/visibility';
6+
import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility';
77
import { SortItemSchema } from '../shared/enums.zod';
88
import { FilterConditionSchema } from '../data/filter.zod';
99
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
@@ -123,7 +123,7 @@ export const PageComponentSchema = lazySchema(() => z.object({
123123

124124
/** ARIA accessibility attributes */
125125
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
126-
}).transform(normalizeVisibleWhen));
126+
}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen));
127127

128128
/**
129129
* Page Variable Schema

packages/spec/src/ui/view.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,3 +2536,44 @@ describe('ADR-0089 — visibleWhen unification (view form)', () => {
25362536
expect(parsed.visibleOn).toBeUndefined();
25372537
});
25382538
});
2539+
2540+
describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', () => {
2541+
it('rejects an unknown key on a form field instead of silently stripping it', () => {
2542+
const res = FormFieldSchema.safeParse({ field: 'state', notARealKey: 1 });
2543+
expect(res.success).toBe(false);
2544+
if (!res.success) {
2545+
expect(res.error.issues[0].code).toBe('unrecognized_keys');
2546+
}
2547+
});
2548+
2549+
it('a visibility-ish typo is rejected AND the message points at `visibleWhen`', () => {
2550+
const res = FormFieldSchema.safeParse({ field: 'state', visibleWhenn: "record.a == 1" });
2551+
expect(res.success).toBe(false);
2552+
if (!res.success) {
2553+
expect(res.error.issues[0].message).toContain('visibleWhen');
2554+
}
2555+
});
2556+
2557+
it('the deprecated `visibleOn` alias is still accepted under strict (declared key)', () => {
2558+
expect(() => FormFieldSchema.parse({ field: 'state', visibleOn: "record.a == 1" })).not.toThrow();
2559+
});
2560+
2561+
it('rejects a stale `visibility` key on a form field (that is the page-component alias, not a view one)', () => {
2562+
const res = FormFieldSchema.safeParse({ field: 'state', visibility: "record.a == 1" });
2563+
expect(res.success).toBe(false);
2564+
});
2565+
2566+
it('a non-visibility unknown key is rejected without the visibleWhen hint', () => {
2567+
const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true });
2568+
expect(res.success).toBe(false);
2569+
if (!res.success) {
2570+
expect(res.error.issues[0].message).not.toContain('visibleWhen');
2571+
expect(res.error.issues[0].message).toContain('bogusKey');
2572+
}
2573+
});
2574+
2575+
it('a strict form section still accepts canonical + alias keys', () => {
2576+
expect(() => FormSectionSchema.parse({ label: 'S', visibleWhen: 'record.a == 1', fields: [] })).not.toThrow();
2577+
expect(() => FormSectionSchema.parse({ label: 'S', visibleOn: 'record.a == 1', fields: [] })).not.toThrow();
2578+
});
2579+
});

packages/spec/src/ui/view.zod.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ProtectionSchema } from '../shared/protection.zod';
55
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
66
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
77
import { ExpressionInputSchema } from '../shared/expression.zod';
8-
import { normalizeVisibleWhen } from '../shared/visibility';
8+
import { normalizeVisibleWhen, strictVisibilityError } from '../shared/visibility';
99
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
1010
import { SharingConfigSchema } from './sharing.zod';
1111
import { ResponsiveConfigSchema, PerformanceConfigSchema } from './responsive.zod';
@@ -770,7 +770,7 @@ export const FormFieldSchema: z.ZodType<any> = lazySchema(() => z.object({
770770
/** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */
771771
visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'),
772772
disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'),
773-
}).transform(normalizeVisibleWhen));
773+
}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen));
774774

775775
/**
776776
* Form Layout Section
@@ -807,7 +807,7 @@ export const FormSectionSchema = lazySchema(() => z.object({
807807
z.string(), // Legacy: simple field name
808808
FormFieldSchema, // Enhanced: detailed field config
809809
])),
810-
}).transform(normalizeVisibleWhen));
810+
}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen));
811811

812812
/**
813813
* Form View Schema

0 commit comments

Comments
 (0)