From 40dc9ca7bf13dc48ae2cc843651f30cff8ecebb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:19:29 +0000 Subject: [PATCH] fix(spec): the authoring side of every recursive schema was `unknown`, so nothing checked what an author wrote (#4195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4171 fixed what a consumer READS from these schemas. This is the other half: what an author WRITES into them. Zod 4's `ZodType` is `ZodType`, and every recursive schema supplied only the first argument: export const NavigationItemSchema: z.ZodType = z.lazy(() => …); // ^ Output only — Input stayed `unknown` That `unknown` propagated to every schema embedding one. Measured before: `AppInput['navigation']` was `unknown[]`, `QueryInput['joins']`/`['fields']` were `unknown[]`, `z.input` was `unknown`. So `defineApp` accepted any array of anything for `navigation` — the densest hand-authored surface on the platform, and the one #4001 made `.strict()` precisely because an author is most likely to write a key from memory there. The strictness was real at parse time and absent at authoring time. Seven schemas now carry both type arguments. Where input and output genuinely differ, the input shape is derived the way #4171 derived the output — `z.input` of the non-recursive half, recursive knot tied by hand. New exported types: `NavigationItemInput`, `NavigationContributionInput`, `FormFieldInput`, `JoinNodeInput`. `FilterCondition`, `NormalizedFilter` and `FieldNode` carry no `.default()` or `.transform()`, so their input is their output. 26 hand-written defaults deleted. #4171 had to spell out `expanded: false` (x16) and `target: '_self'` (x10) because those artifacts are annotated with the PARSED type, where a `.default()`ed key is required — and retyping them to the input surface would have traded eight loud errors for no checking at all. With the input types real they are annotated `AppInput` / `NavigationContributionInput`, the defaults are defaults again, and the literals are checked for the first time. Verified live, not nominal: a literal omitting `expanded`/`target` compiles, and one writing `defaultOpen` — the non-spec key #4171 found in `account.app.ts` — is now a compile error naming `expanded` in its suggestions. Two schemas are typed with a documented caveat: `StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for both arguments, exact on the input side and loose on the output side (`StateNodeConfig` marks `type` optional though `.default('atomic')` makes it always present; `BaseValidationRuleShape` carries a `[key: string]: unknown` index signature). Both were already that loose; making them exact means deriving those types from their schemas rather than maintaining them beside one — separate work. Two remain `unknown` on the input side, correctly: `TranslationItemSchema` and `InlineActionSchema` are `z.preprocess(...)`, where an `unknown` input is zod's semantics, not a missing annotation. That is also why this did not land as a CI gate — "output precise but input `unknown`" cannot be separated from legitimate `preprocess` mechanically, and a gate with false positives is worse than none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XY3swmCsdYx6AMGiRmFt1H --- .changeset/recursive-schema-input-types.md | 78 +++++++++++++++++++ .../platform-objects/src/apps/account.app.ts | 13 ++-- .../src/apps/setup-nav.contributions.ts | 29 +++---- .../platform-objects/src/apps/setup.app.ts | 17 +--- .../platform-objects/src/apps/studio.app.ts | 16 +--- packages/spec/api-surface.json | 4 + .../spec/src/automation/state-machine.zod.ts | 14 +++- packages/spec/src/data/filter.zod.ts | 16 +++- packages/spec/src/data/query.zod.ts | 19 ++++- packages/spec/src/data/validation.zod.ts | 14 +++- packages/spec/src/ui/app.zod.ts | 37 ++++++++- packages/spec/src/ui/view.zod.ts | 19 ++++- 12 files changed, 212 insertions(+), 64 deletions(-) create mode 100644 .changeset/recursive-schema-input-types.md diff --git a/.changeset/recursive-schema-input-types.md b/.changeset/recursive-schema-input-types.md new file mode 100644 index 0000000000..387fd714fb --- /dev/null +++ b/.changeset/recursive-schema-input-types.md @@ -0,0 +1,78 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +--- + +fix(spec): the authoring side of every recursive schema was `unknown`, so nothing checked what an author wrote (#4195) + +#4171 fixed what a consumer **reads** from these schemas. This is the other +half: what an author **writes** into them. + +Zod 4's `ZodType` is `ZodType`, and +every recursive schema in the spec supplied only the first argument: + +```ts +export const NavigationItemSchema: z.ZodType = z.lazy(() => …); +// ^ Output only — Input stayed `unknown` +``` + +That `unknown` propagated to every schema embedding one. Measured before the +fix: + +| | was | now | +|---|---|---| +| `AppInput['navigation']` | `unknown[]` | `NavigationItemInput[]` | +| `QueryInput['joins']` / `['fields']` | `unknown[]` | `JoinNodeInput[]` / `FieldNode[]` | +| `z.input` | `unknown` | `FormFieldInput` | + +So `defineApp` accepted **any array of anything** for `navigation` — the densest +hand-authored surface on the platform, and the one #4001 made `.strict()` +precisely because an author is most likely to write a key from memory there. +The strictness was real at parse time and absent at authoring time. + +**Seven schemas now carry both type arguments.** Where input and output genuinely +differ, the input shape is derived the same way #4171 derived the output — +`z.input` of the non-recursive half, with the recursive knot tied by hand: + +```ts +export type NavigationItemInput = + | (z.input & { children?: NavigationItemInput[] }) + | … + | (z.input & { children: NavigationItemInput[] }); +``` + +New exported types: `NavigationItemInput`, `NavigationContributionInput`, +`FormFieldInput`, `JoinNodeInput`. `FilterCondition`, `NormalizedFilter` and +`FieldNode` carry no `.default()` or `.transform()`, so their input is their +output and the second argument is simply the first. + +**26 hand-written defaults deleted.** #4171 had to spell out `expanded: false` +(×16), `target: '_self'` (×10) in `setup.app.ts` / `studio.app.ts` / +`setup-nav.contributions.ts`, because those artifacts are annotated with the +PARSED type where a `.default()`ed key is required — and retyping them to the +input surface would have traded eight loud errors for no checking at all. With +the input types real, they are now annotated `AppInput` / +`NavigationContributionInput`, the defaults are back to being defaults, and the +literals are checked for the first time. + +Verified the check is live, not nominal: an authoring literal that omits +`expanded` / `target` compiles, and one that writes `defaultOpen` — the +non-spec key #4171 found in `account.app.ts` — is now a **compile error** that +names `expanded` in its suggestion list. Previously that key was invisible until +parse. + +Two schemas are typed with a deliberate caveat, documented at each declaration: +`StateNodeSchema` and `ValidationRuleSchema` reuse their hand-written type for +both arguments. That is exact on the input side and loose on the output side — +`StateNodeConfig` marks `type` optional though `.default('atomic')` makes it +always present, and `BaseValidationRuleShape` carries a `[key: string]: unknown` +index signature. Both were already that loose before this change; making them +exact means deriving those types from their schemas instead of maintaining them +beside one, which is separate work. + +Two remain `unknown` on the input side and correctly so: `TranslationItemSchema` +and `InlineActionSchema` are `z.preprocess(...)`, where an `unknown` input is +zod's semantics rather than a missing annotation. That is also why this did not +land as a CI gate — "output precise but input `unknown`" cannot be separated +from legitimate `preprocess` mechanically, and a gate with false positives is +worse than none. diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index 408976f539..6edf6277c3 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -27,9 +27,9 @@ * self-service view, platform admins get the browsable tables. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const ACCOUNT_APP: App = { +export const ACCOUNT_APP: AppInput = { name: 'account', label: 'Account', description: 'Personal security and identity settings', @@ -55,12 +55,9 @@ export const ACCOUNT_APP: App = { // manage their own linked accounts / personal OAuth apps. RLS on each // object scopes rows to the caller. // - // Group open-state is `expanded` — the spec key. These entries said - // `defaultOpen`, objectui's legacy alias, which the spec has never declared - // and `.strict()` would reject; it worked only because objectui's - // NavigationRenderer still falls back to it, and because `NavigationItem` - // resolved to `any` so nothing checked the key here (#4171). Per Prime - // Directive #12 the producer is the place to fix that, not the renderer. + // Group open-state is `expanded` — the spec key. These said `defaultOpen`, + // objectui's legacy alias the spec never declared; #4171 fixed that at the + // producer per Prime Directive #12. navigation: [ // Profile is the canonical landing — a hand-written React settings card // (Vercel/Linear style) registered in the Console SPA as diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 7d7b3b50d8..5871922c2b 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -22,19 +22,14 @@ * contributions in the same group (mirrors object owner priority). */ -import type { NavigationContribution } from '@objectstack/spec/ui'; +import type { NavigationContributionInput } from '@objectstack/spec/ui'; const BASE_PRIORITY = 100; -// `target: '_self'` on every `url` entry is the schema's own default, spelled -// out: this array is annotated with the PARSED type, where a `.default()`ed key -// is required. Omitting it was only possible while `NavigationItem` resolved to -// `any` and nothing checked these literals at all (#4171). - // Marketplace entries (browse / installed) moved to // @objectstack/cloud-connection's marketplace plugins (cloud ADR-0009: // the nav lives and dies with the capability — no plugin, no entry). -export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ +export const SETUP_NAV_CONTRIBUTIONS: NavigationContributionInput[] = [ { app: 'setup', group: 'group_overview', @@ -102,7 +97,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ group: 'group_configuration', priority: BASE_PRIORITY, items: [ - { id: 'nav_settings_hub', type: 'url', target: '_self', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, // Workspace identity first — Localization (order 2) and Company (order 3) // are the lowest-`order` settings manifests and the first thing a new // company admin configures. They ship as `service-settings` manifests @@ -111,15 +106,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // admin consoles (Salesforce "Company Information", ServiceNow) surface // both directly. No `requiredPermissions` — matches Branding (read perm is // the app's base `setup.access`). - { id: 'nav_settings_localization', type: 'url', target: '_self', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, - { id: 'nav_settings_company', type: 'url', target: '_self', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, - { id: 'nav_settings_branding', type: 'url', target: '_self', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, - { id: 'nav_settings_auth', type: 'url', target: '_self', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_mail', type: 'url', target: '_self', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_storage', type: 'url', target: '_self', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_ai', type: 'url', target: '_self', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_knowledge', type: 'url', target: '_self', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_feature_flags', type: 'url', target: '_self', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, + { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, + { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, + { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, + { id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, ], }, { diff --git a/packages/platform-objects/src/apps/setup.app.ts b/packages/platform-objects/src/apps/setup.app.ts index 6de9acdeb0..4e44a7f64f 100644 --- a/packages/platform-objects/src/apps/setup.app.ts +++ b/packages/platform-objects/src/apps/setup.app.ts @@ -25,9 +25,9 @@ * matching the convention used by the HotCRM reference app. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const SETUP_APP: App = { +export const SETUP_APP: AppInput = { name: 'setup', label: 'Setup', description: 'Platform settings and administration', @@ -47,18 +47,12 @@ export const SETUP_APP: App = { requiredPermissions: ['setup.access'], // Shell only — the stable group anchors. Children are supplied by // `navigationContributions` from the packages that own the objects. - // - // `expanded: false` is the schema's own default, spelled out: this literal is - // annotated with the PARSED `App` type, where a `.default()`ed key is - // required. Omitting it was only possible while `NavigationItem` resolved to - // `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -67,7 +61,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Apps', icon: 'package', - expanded: false, children: [], }, { @@ -75,7 +68,6 @@ export const SETUP_APP: App = { type: 'group', label: 'People & Organization', icon: 'users', - expanded: false, children: [], }, { @@ -83,7 +75,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Access Control', icon: 'shield', - expanded: false, children: [], }, { @@ -91,7 +82,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Approvals', icon: 'check-circle', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -100,7 +90,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Configuration', icon: 'sliders-horizontal', - expanded: false, children: [], }, { @@ -108,7 +97,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Diagnostics', icon: 'stethoscope', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -117,7 +105,6 @@ export const SETUP_APP: App = { type: 'group', label: 'Integrations', icon: 'plug', - expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index 1743fddfc1..08bfd8fc32 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -25,9 +25,9 @@ * the box whenever the auth/security trio is loaded. */ -import type { App } from '@objectstack/spec/ui'; +import type { AppInput } from '@objectstack/spec/ui'; -export const STUDIO_APP: App = { +export const STUDIO_APP: AppInput = { name: 'studio', label: 'Studio', description: 'Metadata workbench for developers, analysts, and implementers', @@ -77,17 +77,12 @@ export const STUDIO_APP: App = { placement: 'sidebar_header', }, ], - // `expanded: false` on each group is the schema's own default, spelled out: - // this literal is annotated with the PARSED `App` type, where a `.default()`ed - // key is required. Omitting it was only possible while `NavigationItem` - // resolved to `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', - expanded: false, children: [ { // The application builder's front door (ADR-0080/0084): pick or @@ -126,7 +121,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Data Model', icon: 'database', - expanded: false, children: [ { id: 'nav_objects', @@ -152,7 +146,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'User Experience', icon: 'layout', - expanded: false, children: [ { id: 'nav_apps', @@ -211,7 +204,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Logic', icon: 'function-square', - expanded: false, children: [ { id: 'nav_actions', @@ -237,7 +229,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Automation', icon: 'workflow', - expanded: false, children: [ { id: 'nav_flows', @@ -262,7 +253,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'AI', icon: 'sparkles', - expanded: false, children: [ { id: 'nav_agents', @@ -299,7 +289,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Developer', icon: 'terminal', - expanded: false, children: [ { id: 'nav_api_console', @@ -333,7 +322,6 @@ export const STUDIO_APP: App = { type: 'group', label: 'Integration', icon: 'plug', - expanded: false, children: [ { id: 'nav_email_templates', diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 6273ce33c4..3556a44297 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -394,6 +394,7 @@ "JSONValidation (type)", "JSONValidationSchema (const)", "JoinNode (type)", + "JoinNodeInput (type)", "JoinNodeSchema (const)", "JoinStrategy (const)", "JoinType (const)", @@ -3275,6 +3276,7 @@ "FormButtonConfig (type)", "FormButtonConfigSchema (const)", "FormField (type)", + "FormFieldInput (type)", "FormFieldSchema (const)", "FormSection (type)", "FormSectionSchema (const)", @@ -3335,8 +3337,10 @@ "NavigationConfig (type)", "NavigationConfigSchema (const)", "NavigationContribution (type)", + "NavigationContributionInput (type)", "NavigationContributionSchema (const)", "NavigationItem (type)", + "NavigationItemInput (type)", "NavigationItemSchema (const)", "NavigationModeSchema (const)", "Notification (type)", diff --git a/packages/spec/src/automation/state-machine.zod.ts b/packages/spec/src/automation/state-machine.zod.ts index 8ae59ac0d2..857305cdd7 100644 --- a/packages/spec/src/automation/state-machine.zod.ts +++ b/packages/spec/src/automation/state-machine.zod.ts @@ -79,8 +79,20 @@ export type StateNodeConfig = { /** * State Node Definition + * + * Both type arguments are given (#4195) so `z.input` is not `unknown` — a state + * machine is hand-authored, and an `unknown` input type means nothing checks + * what an author writes into `states`. + * + * Caveat worth knowing before trusting it: {@link StateNodeConfig} is written by + * hand in the AUTHORING shape (`type?` is optional), while `type` is + * `.default('atomic')` and so always present once parsed. Using it for both + * arguments is therefore exact on the input side and slightly loose on the + * output side — which is what this schema already claimed before, so nothing + * regressed. Making the output exact means re-deriving `StateNodeConfig` from + * the schema rather than maintaining it beside one; that is a separate change. */ -export const StateNodeSchema: z.ZodType = z.lazy(() => z.object({ +export const StateNodeSchema: z.ZodType = z.lazy(() => z.object({ /** Type of state */ type: z.enum(['atomic', 'compound', 'parallel', 'final', 'history']).default('atomic'), diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 2e021e7f59..9aa0a53cde 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -206,8 +206,15 @@ export type FilterCondition = { /** * Zod schema for recursive filter validation. * Uses z.lazy() to handle recursive structure. + * + * Annotated with BOTH type arguments (#4195). `z.ZodType` leaves `Input` + * at its `unknown` default, and that `unknown` propagates: every schema that + * embeds this one reports `unknown` for the corresponding key of its own + * `z.input`, so the authoring surface underneath goes unchecked. Nothing here + * carries a `.default()` or a `.transform()`, so input and output are the same + * type and the second argument is simply the first. */ -export const FilterConditionSchema: z.ZodType = z.lazy(() => +export const FilterConditionSchema: z.ZodType = z.lazy(() => z.record(z.string(), z.unknown()).and( z.object({ $and: z.array(FilterConditionSchema).optional(), @@ -331,8 +338,13 @@ export type NormalizedFilter = { * annotation. Annotating with `z.ZodType` (as this did before #4171) made * the exported `NormalizedFilter` resolve to `any`, so the adapters that walk * this AST were writing against a type that constrained nothing. + * + * Both type arguments are given (#4195): this AST is produced by the normalizer + * rather than authored, and carries no `.default()` or `.transform()`, so input + * and output are the same type. Leaving `Input` at its `unknown` default would + * make `z.input` of anything embedding it `unknown` for no reason. */ -export const NormalizedFilterSchema: z.ZodType = z.lazy(() => +export const NormalizedFilterSchema: z.ZodType = z.lazy(() => z.object({ $and: z.array( z.union([ diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index 75257e8309..0be048b7da 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -203,6 +203,19 @@ export type JoinNode = z.infer & { subquery?: QueryAST; }; +/** + * The authoring shape of a join — the INPUT half of {@link JoinNodeSchema} + * (#4195), the same relationship {@link QueryInput} has to {@link QueryAST}. + * + * Kept as its own type rather than reusing `JoinNode` because the recursive + * knot differs: a nested `subquery` is authored, so it is a `QueryInput`, not + * the parsed `QueryAST`. + */ +export type JoinNodeInput = z.input & { + /** Join against a derived dataset instead of a plain object/table. */ + subquery?: QueryInput; +}; + /** * Join Node * Represents table joins for combining data from multiple objects. @@ -281,7 +294,7 @@ export type JoinNode = z.infer & { * ] * } */ -export const JoinNodeSchema: z.ZodType = z.lazy(() => +export const JoinNodeSchema: z.ZodType = z.lazy(() => JoinNodeBaseSchema.extend({ subquery: z.lazy(() => QuerySchema).optional().describe('Subquery instead of object'), }) @@ -459,7 +472,7 @@ export type FieldNode = * Field Selection Node * Represents "Select" attributes, including joins. */ -export const FieldNodeSchema: z.ZodType = z.lazy(() => +export const FieldNodeSchema: z.ZodType = z.lazy(() => z.union([ z.string(), // Primitive field: "name" z.object({ @@ -620,7 +633,7 @@ export type QueryInput = z.input & { expand?: Record; }; -export const QuerySchema: z.ZodType = lazySchema(() => BaseQuerySchema.extend({ +export const QuerySchema: z.ZodType = lazySchema(() => BaseQuerySchema.extend({ expand: z.lazy(() => z.record(z.string(), QuerySchema)).optional().describe( 'Recursive relation loading map. Keys are lookup/master_detail field names; ' + 'values are nested QueryAST objects that control select (`fields`) and filter ' diff --git a/packages/spec/src/data/validation.zod.ts b/packages/spec/src/data/validation.zod.ts index c3fa424b58..683c200903 100644 --- a/packages/spec/src/data/validation.zod.ts +++ b/packages/spec/src/data/validation.zod.ts @@ -251,7 +251,19 @@ export interface BaseValidationRuleShape { [key: string]: unknown; } -export const ValidationRuleSchema: z.ZodType = z.lazy(() => +/** + * Both type arguments are given (#4195) so `z.input` is not `unknown` — + * validation rules are hand-authored, and an `unknown` input type means nothing + * checks what an author writes. + * + * Read {@link BaseValidationRuleShape} before trusting the result: it carries a + * `[key: string]: unknown` index signature, so it types the KNOWN keys and + * accepts any others. That is a real improvement over `unknown` (which types + * nothing) but it is not strictness — the discriminated union below is what + * actually rejects a malformed rule, at parse time. Removing the index + * signature is the #4075 family of work, not this change. + */ +export const ValidationRuleSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ ScriptValidationSchema, StateMachineValidationSchema, diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 3a56de4941..1af888a767 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -442,6 +442,33 @@ export type NavigationItem = | SeparatorNavItem | GroupNavItem; +/** + * The authoring shape of a navigation item — the INPUT half of + * {@link NavigationItemSchema} (#4195). + * + * Two variants carry a `.default()`, which is the whole reason this has to be + * a separate type: `expanded` on a group and `target` on a url entry are + * OPTIONAL to write and REQUIRED once parsed. Typing an authoring artifact as + * the parsed {@link NavigationItem} therefore demands both — which is exactly + * what #4171 hit, and why `setup.app.ts` / `studio.app.ts` / + * `setup-nav.contributions.ts` briefly had to spell out defaults they were + * happy to rely on. + * + * Before this, `z.input` was `unknown`, so + * `AppInput['navigation']` was `unknown[]` and `defineApp` accepted any array + * of anything for the densest hand-authored surface on the platform. + */ +export type NavigationItemInput = + | (z.input & { children?: NavigationItemInput[] }) + | z.input + | z.input + | z.input + | z.input + | z.input + | z.input + | z.input + | (z.input & { children: NavigationItemInput[] }); + /** * Recursive Union of all navigation item types. * Allows constructing an unlimited-depth navigation tree. @@ -460,7 +487,7 @@ export type NavigationItem = * type, or the reverse — which app.test.ts covers at runtime by parsing all nine * ("accepts every variant with its full declared payload"). */ -export const NavigationItemSchema: z.ZodType = z.lazy(() => +export const NavigationItemSchema: z.ZodType = z.lazy(() => // DISCRIMINATED on `type` (#4001 PR B). With `.strict()` members a plain // union would answer one unknown key with an `invalid_union` aggregate // listing all nine branches' failures; discriminating on `type` first means @@ -484,7 +511,7 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => // The members are lazySchema Proxies and a superRefine-wrapped variant, so // the array is widened for the discriminator-typed overload; runtime // discrimination works on all of them (asserted in app.test.ts). - ] as unknown as readonly [z.ZodObject, ...z.ZodObject[]]) as unknown as z.ZodType + ] as unknown as readonly [z.ZodObject, ...z.ZodObject[]]) as unknown as z.ZodType ); /** @@ -529,6 +556,12 @@ export const NavigationContributionSchema = lazySchema(() => z.object({ }), }).strict().describe('A navigation contribution: a package injecting nav items into an app it does not own (ADR-0029 D7)')); export type NavigationContribution = z.infer; +/** + * The authoring shape of a contribution (#4195) — `priority` is `.default(200)` + * and each item is a {@link NavigationItemInput}, so this is what a package + * declaring its menu entries actually writes. + */ +export type NavigationContributionInput = z.input; /** * App Branding Configuration diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 251086064b..b4a5efa348 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -913,6 +913,23 @@ export type FormField = Omit, 'visibleOn'> & fields?: FormField[]; }; +/** + * The authoring shape of a form field — the INPUT half of + * {@link FormFieldSchema} (#4195). + * + * Differs from {@link FormField} in both directions, which is exactly why it + * has to be declared: `span` is `.default('auto')` so it is optional here and + * required there, and the deprecated `visibleOn` is accepted here and stripped + * by the ADR-0089 D2 transform before it reaches the output. + * + * Without this, `z.input` was `unknown` — so a form + * section's `fields` were unchecked at every authoring site. + */ +export type FormFieldInput = z.input & { + /** Sub-fields for composite/repeater/record types. Recursive. */ + fields?: FormFieldInput[]; +}; + /** * Form Field Configuration Schema * Detailed configuration for individual form fields. @@ -930,7 +947,7 @@ export type FormField = Omit, 'visibleOn'> & * @example Custom widget override * { field: 'filter', widget: 'filter-builder' } */ -export const FormFieldSchema: z.ZodType = lazySchema(() => +export const FormFieldSchema: z.ZodType = lazySchema(() => FormFieldBaseSchema.extend({ fields: z.array(z.lazy(() => FormFieldSchema)).optional() .describe('Sub-fields for composite/repeater/record types'),