From 2ca5489488341af64cb3de792a8269f1aafba763 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:44:32 +0000 Subject: [PATCH 1/6] fix(types): arm the ADR-0080 public blocks that carry a spec ComponentPropsMap row Twenty namespaced public blocks (the page: structure blocks, the record: blocks with a row, element:text, element:button, element:divider) get an arm in AnyComponentSchema. Each arm declares `properties` as the spec's ComponentPropsMap row, by reference through stripImportedDefaults. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../__tests__/public-block-arms-10872.test.ts | 246 ++++++++++++++ packages/types/src/zod/index.zod.ts | 30 ++ packages/types/src/zod/public-blocks.zod.ts | 302 ++++++++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 packages/types/src/__tests__/public-block-arms-10872.test.ts create mode 100644 packages/types/src/zod/public-blocks.zod.ts diff --git a/packages/types/src/__tests__/public-block-arms-10872.test.ts b/packages/types/src/__tests__/public-block-arms-10872.test.ts new file mode 100644 index 0000000000..028c2acf6b --- /dev/null +++ b/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -0,0 +1,246 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The ADR-0080 public blocks that `@objectstack/spec` declares a + * `ComponentPropsMap` row for have a zod arm in `AnyComponentSchema` + * (objectui#10872, batch 1). + * + * ## The defect these pin + * + * `page:header`, `record:details`, `element:text` and the rest of the curated + * public vocabulary (`PUBLIC_BLOCKS`, `@object-ui/core`) are registered, + * documented and declared by the spec — and `AnyComponentSchema` carried no arm + * for any of them, so `safeValidateSchema` refused every document naming one + * with `invalid_union` at `type`, and `objectui validate` could not pass a page + * built from the platform's own public blocks. + * + * ## What "matching its declared props" means here + * + * A `ComponentPropsMap` row declares a page component's props BAG — the + * `properties` member of the spec's `PageComponentSchema` — so each arm + * declares `properties` as that row, by reference (`../zod/public-blocks.zod.ts` + * says why). The rows below therefore measure the arm against the INSTALLED + * spec on every run rather than against a transcribed key list: a row the spec + * widens or narrows moves these readings with it. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ComponentPropsMap, PageComponentSchema } from '@objectstack/spec/ui'; + +import { + PublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import * as zodBarrel from '../zod/index.zod.js'; +import { stripImportedDefaults } from '../zod/imported-defaults.js'; + +/** + * A spec-valid props bag per armed block — each one is parsed by the spec's own + * row below before it is used, so a fixture that stops being spec-valid fails + * as a FIXTURE, not as the arm. + */ +const VALID_BAG: Readonly>> = { + 'page:header': { title: 'Account', subtitle: 'Customer' }, + 'page:tabs': { items: [{ label: 'Details', children: [] }] }, + 'page:card': { title: 'Summary', bordered: false }, + 'page:accordion': { items: [{ label: 'More', children: [] }] }, + 'page:section': { children: [] }, + 'page:footer': { children: [] }, + 'page:sidebar': { children: [] }, + 'record:details': { columns: '2', sections: [{ label: 'Contact', fields: ['email'] }] }, + 'record:highlights': { fields: ['name', 'status'] }, + 'record:related_list': { objectName: 'task', relationshipField: 'account', columns: ['subject'] }, + 'record:path': { statusField: 'status' }, + 'record:activity': { limit: 10 }, + 'record:discussion': { collapsible: true }, + 'record:history': { limit: 20 }, + 'record:quick_actions': { actionNames: ['edit'] }, + 'record:reference_rail': { entries: [{ objectName: 'contact', relationshipField: 'account' }] }, + 'record:alert': { severity: 'warning', title: 'Overdue' }, + 'element:text': { content: 'Hello' }, + 'element:button': { label: 'Go' }, + 'element:divider': {}, +}; + +/** The armed literals, in the order `VALID_BAG` lists them. */ +const ARMED = Object.keys(VALID_BAG); + +/** The spec's row for one block, as the published map carries it. */ +const rowOf = (type: string): z.ZodType => + (ComponentPropsMap as unknown as Record)[type]; + +/** The arm `PublicBlockComponentSchema` selects for one literal. */ +function armOf(type: string): z.ZodObject { + const options = (PublicBlockComponentSchema as unknown as { options: z.ZodObject[] }).options; + const arm = options.find((option) => (option.shape.type as z.ZodLiteral).value === type); + if (!arm) throw new Error(`no public-block arm for ${type}`); + return arm; +} + +/** The arm's `properties` member with its `.optional()` peeled off. */ +function bagOf(type: string): z.ZodType { + return (armOf(type).shape.properties as z.ZodOptional).unwrap() as z.ZodType; +} + +const keysOf = (schema: z.ZodType): string[] => + Object.keys((schema as unknown as { shape?: Record }).shape ?? {}).sort(); + +/** + * The keys an `unrecognized_keys` issue names, wherever it sits — including + * inside the per-arm issue lists of an `invalid_union` (a child slot is a + * plain union, so a refused child reports one level down). + */ +function refusedKeys(result: { success: boolean; error?: { issues: z.core.$ZodIssue[] } }): string[] { + if (result.success) return []; + const collect = (issues: readonly z.core.$ZodIssue[]): string[] => + issues.flatMap((issue) => { + if (issue.code === 'unrecognized_keys') return issue.keys; + if (issue.code === 'invalid_union') return issue.errors.flatMap((arm) => collect(arm)); + return []; + }); + return collect(result.error!.issues); +} + +describe('the public blocks with a spec row validate (objectui#10872)', () => { + it('the union arms exactly the blocks this file measures — no arm without a row here', () => { + const literals = (PublicBlockComponentSchema as unknown as { options: z.ZodObject[] }).options + .map((option) => (option.shape.type as z.ZodLiteral).value as string) + .sort(); + expect(literals).toEqual([...ARMED].sort()); + }); + + it.each(ARMED)('%s: the minimal document is accepted on both faces', (type) => { + expect(safeValidateSchema({ type }).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse({ type }).success).toBe(true); + // The spec's own parse face takes a node with no bag too — `properties` is + // optional there, which is what the arm's optional bag restates. + expect(PageComponentSchema.safeParse({ type }).success).toBe(true); + }); + + it.each(ARMED)('%s: a spec-valid props bag is accepted on both faces', (type) => { + const properties = VALID_BAG[type]; + // Fixture control: the bag is valid by the spec's own row. + expect(rowOf(type).safeParse(properties).success, `${type} fixture is not spec-valid`).toBe(true); + expect(safeValidateSchema({ type, properties }).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse({ type, properties }).success).toBe(true); + }); + + it('a page built from `page:header`, `record:details` and `element:text` validates', () => { + const page = { + type: 'page', + title: 'Account', + children: [ + { type: 'page:header', properties: { title: 'Account' } }, + { type: 'record:details' }, + { type: 'element:text', properties: { content: 'Hello' } }, + ], + }; + expect(safeValidateSchema(page).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(page).success).toBe(true); + }); + + it('each arm is a named export of the barrel', () => { + const barrel = zodBarrel as unknown as Record; + for (const type of ARMED) { + expect(Object.values(barrel), type).toContain(armOf(type)); + } + }); +}); + +describe('the bag is the spec row, read by reference (objectui#10872)', () => { + it.each(ARMED)('%s: the bag declares exactly the row\'s members', (type) => { + expect(rowOf(type), `${type} has no ComponentPropsMap row`).toBeDefined(); + expect(keysOf(bagOf(type))).toEqual(keysOf(rowOf(type))); + }); + + it.each(ARMED.filter((type) => type !== 'element:divider'))( + '%s: the bag is the spec\'s own row object wherever the import boundary has nothing to strip', + (type) => { + const row = rowOf(type); + if (stripImportedDefaults(row) === row) { + expect(bagOf(type)).toBe(row); + } else { + // The row carries a spec default the boundary removes (objectui#8317): + // the bag is the stripped clone, which answers the fixture as the row does. + expect(bagOf(type)).not.toBe(row); + expect(bagOf(type).safeParse(VALID_BAG[type]).success).toBe(true); + } + }, + ); + + it('an undeclared member inside the bag is refused on the TOLERANT face, by name', () => { + const result = safeValidateSchema({ type: 'record:details', properties: { inventedKey10872: true } }); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toContain('inventedKey10872'); + expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))).toContain('properties'); + // Control: the spec's own row refuses the same key. + expect(rowOf('record:details').safeParse({ inventedKey10872: true }).success).toBe(false); + }); + + it('an undeclared member on the node is refused on the STRICT face, by name', () => { + const document = { type: 'record:details', inventedFlat10872: 1 }; + const result = StrictAnyComponentSchema.safeParse(document); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toEqual(['inventedFlat10872']); + // Control: the tolerant face passes an undeclared node key through, as it + // does on every arm — the refusal above is the strict face's. + expect(safeValidateSchema(document).success).toBe(true); + }); + + it('a value the spec row refuses is refused at the member, on both faces', () => { + const document = { type: 'element:text', properties: { content: 'Hi', variant: 'shout' } }; + const result = safeValidateSchema(document); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))) + .toContain('properties.variant'); + expect(StrictAnyComponentSchema.safeParse(document).success).toBe(false); + // Control: the fixed value parses. + expect(safeValidateSchema({ type: 'element:text', properties: { content: 'Hi', variant: 'body' } }).success) + .toBe(true); + }); + + it('a key the spec retired travels with the row — refused at the member', () => { + const document = { type: 'page:card', properties: { title: 'Summary', actions: ['edit'] } }; + const result = safeValidateSchema(document); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => issue.path.join('.'))) + .toContain('properties.actions'); + // Control: the spec's own row refuses it too. + expect(rowOf('page:card').safeParse({ title: 'Summary', actions: ['edit'] }).success).toBe(false); + }); + + it('a public block nested in a page is judged by its own arm, not by the base keys', () => { + const page = { + type: 'page', + children: [{ type: 'record:highlights', properties: { fields: ['name'], inventedNested10872: 1 } }], + }; + const result = safeValidateSchema(page); + expect(result.success).toBe(false); + expect(refusedKeys(result)).toContain('inventedNested10872'); + }); +}); + +describe('`element:divider` — the one row restated, pinned to the spec (objectui#10872)', () => { + it('declares no member, as the spec row declares none', () => { + expect(keysOf(rowOf('element:divider'))).toEqual([]); + expect(keysOf(bagOf('element:divider'))).toEqual([]); + }); + + it('accepts and refuses exactly what the spec row does', () => { + for (const probe of [{}, { spacing: 'lg' }, { className: 'my-8' }, null, 'x']) { + expect(bagOf('element:divider').safeParse(probe).success, JSON.stringify(probe)) + .toBe(rowOf('element:divider').safeParse(probe).success); + } + // Non-vacuity: the probes above include both verdicts. + expect(rowOf('element:divider').safeParse({}).success).toBe(true); + expect(rowOf('element:divider').safeParse({ spacing: 'lg' }).success).toBe(false); + }); +}); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 981b81c455..0a44662875 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -407,6 +407,34 @@ export { AIComponentSchema, } from './ai.zod.js'; +// ============================================================================ +// ADR-0080 Public Blocks - the spec-row `page:` / `record:` / `element:` +// blocks (objectui#10872) +// ============================================================================ +export { + PageHeaderBlockSchema, + PageTabsBlockSchema, + PageCardBlockSchema, + PageAccordionBlockSchema, + PageSectionBlockSchema, + PageFooterBlockSchema, + PageSidebarBlockSchema, + RecordDetailsBlockSchema, + RecordHighlightsBlockSchema, + RecordRelatedListBlockSchema, + RecordPathBlockSchema, + RecordActivityBlockSchema, + RecordDiscussionBlockSchema, + RecordHistoryBlockSchema, + RecordQuickActionsBlockSchema, + RecordReferenceRailBlockSchema, + RecordAlertBlockSchema, + ElementTextBlockSchema, + ElementButtonBlockSchema, + ElementDividerBlockSchema, + PublicBlockComponentSchema, +} from './public-blocks.zod.js'; + // ============================================================================ // Union Types - All Component Schemas // ============================================================================ @@ -427,6 +455,7 @@ import { CRUDComponentSchema } from './crud.zod.js'; import { ReportUnionSchema } from './reports.zod.js'; import { ViewComponentSchema } from './views.zod.js'; import { AIComponentSchema } from './ai.zod.js'; +import { PublicBlockComponentSchema } from './public-blocks.zod.js'; /** * Union of all component schemas. @@ -493,6 +522,7 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( ReportUnionSchema, ViewComponentSchema, AIComponentSchema, + PublicBlockComponentSchema, ], { // Zod's default message for a missed discriminator spells out EVERY accepted // literal — measured, 1,462 chars naming all 107. That is the "print every diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts new file mode 100644 index 0000000000..dc28096fe1 --- /dev/null +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -0,0 +1,302 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types/zod - ADR-0080 Public Block Zod Validators + * + * Arms for the namespaced ADR-0080 PUBLIC blocks — the curated AI-authoring + * vocabulary `PUBLIC_BLOCKS` in `@object-ui/core` + * (`packages/core/src/registry/public-blocks.ts`) — whose props + * `@objectstack/spec` declares as a `ComponentPropsMap` row: the `page:` + * structure blocks, the `record:` blocks that carry a row, and `element:text`, + * `element:button` and `element:divider`. + * + * ## Why this module exists (objectui#10872) + * + * Every one of these types is REGISTERED (`@object-ui/components`, + * `@object-ui/plugin-detail`), CURATED as platform contract by ADR-0080 and + * DECLARED by the spec, while `AnyComponentSchema` carried no arm for any of + * them — so `safeValidateSchema` and `objectui validate` refused every document + * naming one with `invalid_union` at `type`, and a page built from the public + * vocabulary could not validate at all. The count of registered namespaced + * types still refused there is ratcheted, and printed, by + * `packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts`. + * + * ## Where the props live: `properties`, by reference + * + * A `ComponentPropsMap` row is the spec's declaration of a page component's + * props BAG — the `properties` member of `PageComponentSchema` ("Component + * props passed to the widget") — not of the node's own keys. So each arm + * declares `properties` as that row, BY REFERENCE, crossing the objectui#8317 + * import boundary (`stripImportedDefaults`) like every other spec read in this + * directory: the row's members, value types, `.describe()` text, strictness, + * refinements and retired-key tombstones all arrive from the spec, and a row + * the spec changes changes the arm the same day. No member is restated here, + * so none can drift — except the one row the spec does not export by name + * (`element:divider`, below). + * + * The bag is the one spelling every block here is read through at runtime: + * `SchemaRenderer` hoists each `properties` key onto the node before the + * renderer runs (`type` / `id` excepted), and the `element:*` renderers read + * ONLY the bag (`readProps` in `@object-ui/components`). It is also the + * spelling the platform's own producers write — the default record-page + * synthesizer (`buildDefaultPageSchema`'s `componentNode`) and the page + * designer both emit `{ type, properties }`. + * + * ⚠️ A key written FLAT on the node (`{ "type": "record:details", "columns": + * "2" }`) is NOT declared here. The tolerant face passes it through unjudged, + * exactly as it passes every undeclared key of every arm; the strict authoring + * face refuses it. Whether the flat spelling is ALSO an authoring channel for + * the `page:` / `record:` families (their renderers read the hoisted node + * keys) is left open on objectui#10872 rather than decided by this module — + * declaring it later is additive, and it must never extend to `element:*`, + * whose renderers do not read a flat key at all. + * + * `properties` is optional on every arm, as it is on `PageComponentSchema`: + * the spec's own props gate (`validateComponentProps`, `@objectstack/lint`) + * judges a bag only when the node carries one, and so does this face. + * + * ## The public blocks NOT armed here, and why + * + * Held by objectui#10872 batch 1 with the evidence on that card — each is a + * reading, not an oversight: + * + * - `element:number` — its row requires `object`, which the spec's props gate + * waives when the node binds through `dataSource` (`DATASOURCE_SUPPLIED_PROP` + * in `@objectstack/lint`). The row alone would refuse that spec-valid node. + * - `record:line_items` — the spec carries no row, on purpose: its + * `STRING_ARM_REGISTERED_TYPES` ledger records the row as still to be + * measured from the renderer's read points. + * - `element:definition-list`, `element:repeater` — the spec's `element:` + * namespace is a closed vocabulary at author time and does not declare + * either type. + * - `action:button`, `action:group`, `action:menu`, `action:icon` — no spec + * row, and the two objectui declarations disagree (the renderers' + * `UIActionSchema`-based schema types against the registrations' `inputs`). + * + * ⛔ No `.default()` anywhere in this module — see the "authors no default" + * note in `index.zod.ts`. + * + * @module zod/public-blocks + * @packageDocumentation + */ + +import { z } from 'zod'; +import { + PageHeaderProps as SpecPageHeaderProps, + PageTabsProps as SpecPageTabsProps, + PageCardProps as SpecPageCardProps, + PageAccordionProps as SpecPageAccordionProps, + PageContainerProps as SpecPageContainerProps, + RecordDetailsProps as SpecRecordDetailsProps, + RecordHighlightsProps as SpecRecordHighlightsProps, + RecordRelatedListProps as SpecRecordRelatedListProps, + RecordPathProps as SpecRecordPathProps, + RecordActivityProps as SpecRecordActivityProps, + RecordChatterProps as SpecRecordChatterProps, + RecordHistoryProps as SpecRecordHistoryProps, + RecordQuickActionsProps as SpecRecordQuickActionsProps, + RecordReferenceRailProps as SpecRecordReferenceRailProps, + RecordAlertProps as SpecRecordAlertProps, + ElementTextPropsSchema as SpecElementTextPropsSchema, + ElementButtonPropsSchema as SpecElementButtonPropsSchema, +} from '@objectstack/spec/ui'; +import { BaseSchema } from './base.zod.js'; +import { stripImportedDefaults } from './imported-defaults.js'; + +/** + * The `properties` member of one public block: the spec row, optional, with + * the provenance spelled into its description. The row is passed in already + * through the import boundary, so this helper never touches a spec binding. + */ +function propsBag(type: string, row: T) { + return row + .optional() + .describe( + `The \`${type}\` props bag — \`@objectstack/spec\` \`ComponentPropsMap['${type}']\`, by reference. ` + + 'Judged only when present, as the spec\'s props gate judges it.', + ); +} + +/* ── page: — structure ──────────────────────────────────────────────────── */ + +/** `page:header` — `ComponentPropsMap['page:header']`. */ +export const PageHeaderBlockSchema = BaseSchema.extend({ + type: z.literal('page:header'), + properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), +}); + +/** `page:tabs` — `ComponentPropsMap['page:tabs']`. */ +export const PageTabsBlockSchema = BaseSchema.extend({ + type: z.literal('page:tabs'), + properties: propsBag('page:tabs', stripImportedDefaults(SpecPageTabsProps)), +}); + +/** `page:card` — `ComponentPropsMap['page:card']`. */ +export const PageCardBlockSchema = BaseSchema.extend({ + type: z.literal('page:card'), + properties: propsBag('page:card', stripImportedDefaults(SpecPageCardProps)), +}); + +/** `page:accordion` — `ComponentPropsMap['page:accordion']`. */ +export const PageAccordionBlockSchema = BaseSchema.extend({ + type: z.literal('page:accordion'), + properties: propsBag('page:accordion', stripImportedDefaults(SpecPageAccordionProps)), +}); + +/** + * `page:section` — `ComponentPropsMap['page:section']`, the spec's shared + * thin-container row (`PageContainerProps`), as for `page:footer` and + * `page:sidebar`. + */ +export const PageSectionBlockSchema = BaseSchema.extend({ + type: z.literal('page:section'), + properties: propsBag('page:section', stripImportedDefaults(SpecPageContainerProps)), +}); + +/** `page:footer` — `ComponentPropsMap['page:footer']` (`PageContainerProps`). */ +export const PageFooterBlockSchema = BaseSchema.extend({ + type: z.literal('page:footer'), + properties: propsBag('page:footer', stripImportedDefaults(SpecPageContainerProps)), +}); + +/** `page:sidebar` — `ComponentPropsMap['page:sidebar']` (`PageContainerProps`). */ +export const PageSidebarBlockSchema = BaseSchema.extend({ + type: z.literal('page:sidebar'), + properties: propsBag('page:sidebar', stripImportedDefaults(SpecPageContainerProps)), +}); + +/* ── record: — record-context blocks ────────────────────────────────────── */ + +/** `record:details` — `ComponentPropsMap['record:details']`. */ +export const RecordDetailsBlockSchema = BaseSchema.extend({ + type: z.literal('record:details'), + properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), +}); + +/** `record:highlights` — `ComponentPropsMap['record:highlights']`. */ +export const RecordHighlightsBlockSchema = BaseSchema.extend({ + type: z.literal('record:highlights'), + properties: propsBag('record:highlights', stripImportedDefaults(SpecRecordHighlightsProps)), +}); + +/** `record:related_list` — `ComponentPropsMap['record:related_list']`. */ +export const RecordRelatedListBlockSchema = BaseSchema.extend({ + type: z.literal('record:related_list'), + properties: propsBag('record:related_list', stripImportedDefaults(SpecRecordRelatedListProps)), +}); + +/** `record:path` — `ComponentPropsMap['record:path']`. */ +export const RecordPathBlockSchema = BaseSchema.extend({ + type: z.literal('record:path'), + properties: propsBag('record:path', stripImportedDefaults(SpecRecordPathProps)), +}); + +/** `record:activity` — `ComponentPropsMap['record:activity']`. */ +export const RecordActivityBlockSchema = BaseSchema.extend({ + type: z.literal('record:activity'), + properties: propsBag('record:activity', stripImportedDefaults(SpecRecordActivityProps)), +}); + +/** + * `record:discussion` — `ComponentPropsMap['record:discussion']`, which the + * spec binds to the SAME row object as `record:chatter` (one renderer, one + * accept face). `record:chatter` is not a public block and is not armed here. + */ +export const RecordDiscussionBlockSchema = BaseSchema.extend({ + type: z.literal('record:discussion'), + properties: propsBag('record:discussion', stripImportedDefaults(SpecRecordChatterProps)), +}); + +/** `record:history` — `ComponentPropsMap['record:history']`. */ +export const RecordHistoryBlockSchema = BaseSchema.extend({ + type: z.literal('record:history'), + properties: propsBag('record:history', stripImportedDefaults(SpecRecordHistoryProps)), +}); + +/** `record:quick_actions` — `ComponentPropsMap['record:quick_actions']`. */ +export const RecordQuickActionsBlockSchema = BaseSchema.extend({ + type: z.literal('record:quick_actions'), + properties: propsBag('record:quick_actions', stripImportedDefaults(SpecRecordQuickActionsProps)), +}); + +/** `record:reference_rail` — `ComponentPropsMap['record:reference_rail']`. */ +export const RecordReferenceRailBlockSchema = BaseSchema.extend({ + type: z.literal('record:reference_rail'), + properties: propsBag('record:reference_rail', stripImportedDefaults(SpecRecordReferenceRailProps)), +}); + +/** `record:alert` — `ComponentPropsMap['record:alert']`. */ +export const RecordAlertBlockSchema = BaseSchema.extend({ + type: z.literal('record:alert'), + properties: propsBag('record:alert', stripImportedDefaults(SpecRecordAlertProps)), +}); + +/* ── element: — content blocks ──────────────────────────────────────────── */ + +/** `element:text` — `ComponentPropsMap['element:text']`. */ +export const ElementTextBlockSchema = BaseSchema.extend({ + type: z.literal('element:text'), + properties: propsBag('element:text', stripImportedDefaults(SpecElementTextPropsSchema)), +}); + +/** `element:button` — `ComponentPropsMap['element:button']`. */ +export const ElementButtonBlockSchema = BaseSchema.extend({ + type: z.literal('element:button'), + properties: propsBag('element:button', stripImportedDefaults(SpecElementButtonPropsSchema)), +}); + +/** + * `element:divider` — the ONE row restated rather than read by reference. + * + * `ComponentPropsMap['element:divider']` is built inline by the spec + * (`emptyProps('element:divider')`) and `@objectstack/spec/ui` exports it under + * no name, so it cannot cross the objectui#8317 boundary the way the rows + * above do — the boundary census admits only a spec binding passed straight + * to `stripImportedDefaults`. The row declares no member and is strict, so + * the restatement is a closed object with no member: it accepts exactly `{}`. + * `../__tests__/public-block-arms-10872.test.ts` pins its key set and its + * accept set to the row's, so a prop the spec later adds reddens there. + */ +export const ElementDividerBlockSchema = BaseSchema.extend({ + type: z.literal('element:divider'), + properties: z + .strictObject({}) + .optional() + .describe( + 'The `element:divider` props bag — `@objectstack/spec` `ComponentPropsMap[\'element:divider\']` declares ' + + 'no prop, so the only bag it accepts is `{}`. The divider takes its styling from `className`.', + ), +}); + +/** + * Union of the public-block arms — the category member `AnyComponentSchema` + * lists (objectui#10872). + */ +export const PublicBlockComponentSchema = z.discriminatedUnion('type', [ + PageHeaderBlockSchema, + PageTabsBlockSchema, + PageCardBlockSchema, + PageAccordionBlockSchema, + PageSectionBlockSchema, + PageFooterBlockSchema, + PageSidebarBlockSchema, + RecordDetailsBlockSchema, + RecordHighlightsBlockSchema, + RecordRelatedListBlockSchema, + RecordPathBlockSchema, + RecordActivityBlockSchema, + RecordDiscussionBlockSchema, + RecordHistoryBlockSchema, + RecordQuickActionsBlockSchema, + RecordReferenceRailBlockSchema, + RecordAlertBlockSchema, + ElementTextBlockSchema, + ElementButtonBlockSchema, + ElementDividerBlockSchema, +]); From c7dff5506f0690e557f939aae0e0f6877536290d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:49:59 +0000 Subject: [PATCH 2/6] test(cli): the refused-at-type ratchet counts the namespaced half, pinned at 398 objectui#10872: a second pin, NAMESPACED_REFUSED_AT_TYPE, over the namespaced keys of KNOWN_SCHEMA_TYPES, with the same lower-it / never-raise messages, plus an in-process `objectui validate` run on a page built from public blocks and its refusal control. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...tered-types-validate-ratchet-10859.test.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index eacb0591ee..f3212bf59e 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -41,6 +41,16 @@ * with the refused keys listed, which is where the next author finds out. * ⛔ Do not raise the constant to make it pass — arm the type, or take the * registration's authorability to the card. + * + * ## The namespaced half (objectui#10872) + * + * The same list carries the NAMESPACED keys (`view:grid`, `record:details`, + * `action:button`), and the same union refuses them the same way. They are + * counted separately, against `NAMESPACED_REFUSED_AT_TYPE`, because they are a + * separate family card with its own batches — objectui#10872 — and a single + * total would let a batch on one card mask a regression on the other. Same + * rule, same direction: the pin is the head's count, it falls with each batch, + * and ⛔ it never rises. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -60,9 +70,31 @@ import { validate } from '../commands/validate.js'; */ const REFUSED_AT_TYPE = 76; +/** + * The head's refused count over the NAMESPACED keys (objectui#10872 batch 1: + * 418 on `main` before it, minus the twenty ADR-0080 public blocks armed from + * their `@objectstack/spec` `ComponentPropsMap` rows). LOWER it when a batch + * arms more keys; never raise it. + */ +const NAMESPACED_REFUSED_AT_TYPE = 398; + /** The bare registry keys — the population the card measured. */ const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); +/** The namespaced registry keys — objectui#10872's population. */ +const NAMESPACED_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => key.includes(':')); + +/** + * The public blocks objectui#10872 batch 1 armed — named, so the row below + * says WHICH keys left the refused set rather than only that the count fell. + */ +const ARMED_PUBLIC_BLOCKS_10872 = [ + 'page:header', 'page:tabs', 'page:card', 'page:accordion', 'page:section', 'page:footer', 'page:sidebar', + 'record:details', 'record:highlights', 'record:related_list', 'record:path', 'record:activity', + 'record:discussion', 'record:history', 'record:quick_actions', 'record:reference_rail', 'record:alert', + 'element:text', 'element:button', 'element:divider', +] as const; + /** Is `type` unclaimed by every arm of the validator's root union? */ function refusedAtType(type: string): boolean { const result = safeValidateSchema({ type }); @@ -114,6 +146,39 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 }); }); +describe('registered NAMESPACED component types refused at `type` — a ratchet (objectui#10872)', () => { + it('the refused count equals the pin, and only ever falls', () => { + const refused = NAMESPACED_KEYS.filter(refusedAtType); + expect( + refused.length, + [ + `\`objectui validate\` refuses ${refused.length} registered namespaced key(s) at \`type\`; the pin is ${NAMESPACED_REFUSED_AT_TYPE}.`, + refused.length < NAMESPACED_REFUSED_AT_TYPE + ? `Fewer than the pin — an arm landed. LOWER \`NAMESPACED_REFUSED_AT_TYPE\` to ${refused.length} in this same change (objectui#10872).` + : 'MORE than the pin — a registered key lost its arm or a registration landed without one. ' + + 'Arm it in `@object-ui/types/zod` (or take its authorability to objectui#10872); ⛔ never raise the pin.', + `Refused: ${refused.join(', ')}`, + ].join('\n'), + ).toBe(NAMESPACED_REFUSED_AT_TYPE); + }); + + it('reads the whole generated namespaced population (non-vacuity)', () => { + expect(NAMESPACED_KEYS.length).toBeGreaterThan(400); + // Lit control: the one namespaced key armed before objectui#10872. + expect(NAMESPACED_KEYS).toContain('ui:calendar'); + expect(refusedAtType('ui:calendar')).toBe(false); + // And a namespaced key no arm claims IS refused there. + expect(refusedAtType('no-such-namespace:component-10872')).toBe(true); + }); + + it('counts the public blocks objectui#10872 batch 1 armed', () => { + for (const key of ARMED_PUBLIC_BLOCKS_10872) { + expect(NAMESPACED_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + }); +}); + /* ── End to end: the README document through `objectui validate` ─────────── */ /** Rooted on this file, never on `process.cwd()`. */ @@ -174,3 +239,50 @@ describe('objectui validate — the plugin-ai README document (objectui#10859)', expect(exitCodes).toEqual([0]); }); }); + +/* ── End to end: a page of public blocks through `objectui validate` ─────── */ + +describe('objectui validate — a page built from ADR-0080 public blocks (objectui#10872)', () => { + it('validates a page of `page:header`, `record:details` and `element:text`', async () => { + // Each block in the spelling the platform's own producers write — the + // spec's `{ type, properties }` bag (`buildDefaultPageSchema`'s + // `componentNode`, the page designer) — with one bare node, which is the + // synthesizer's shape for a block it configures nothing on. + const page = { + type: 'page', + title: 'Account', + children: [ + { type: 'page:header', properties: { title: 'Account', subtitle: 'Customer' } }, + { type: 'record:details' }, + { type: 'element:text', properties: { content: 'Recent activity' } }, + ], + }; + const file = join(dir, 'public-blocks-page.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); + + it('still judges a public block\'s bag — an undeclared prop is refused and named', async () => { + // The control that keeps the row above from passing for the wrong reason: + // the same page with one invented prop on a nested block fails. + const page = { + type: 'page', + children: [{ type: 'page:header', properties: { title: 'Account', inventedProp10872: true } }], + }; + const file = join(dir, 'public-blocks-page-refused.json'); + writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).toContain('Schema validation failed'); + expect(text).toContain('inventedProp10872'); + expect(exitCodes).toEqual([1]); + }); +}); From 9ec737d3db3e37be2cbf8704a081097987688651 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:57:26 +0000 Subject: [PATCH 3/6] test(types): census rows for the public-block arms, and their spec rows in the import-boundary differential objectui#10872: twenty-one EXCLUSIONS rows in zod-mirror-parity (spec-owned by reference, element:divider restated, the union) and the seventeen ComponentPropsMap rows the arms read added to imported-defaults-8317's IMPORTED list. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../__tests__/imported-defaults-8317.test.ts | 40 +++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index f5321e93bf..f7ffc33a61 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -80,6 +80,24 @@ import { ChartDrillDownSchema as SpecChartDrillDownSchema, UserFilterFieldSchema as SpecUserFilterFieldSchema, ViewFilterRuleSchema as SpecViewFilterRuleSchema, + // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. + PageHeaderProps as SpecPageHeaderProps, + PageTabsProps as SpecPageTabsProps, + PageCardProps as SpecPageCardProps, + PageAccordionProps as SpecPageAccordionProps, + PageContainerProps as SpecPageContainerProps, + RecordDetailsProps as SpecRecordDetailsProps, + RecordHighlightsProps as SpecRecordHighlightsProps, + RecordRelatedListProps as SpecRecordRelatedListProps, + RecordPathProps as SpecRecordPathProps, + RecordActivityProps as SpecRecordActivityProps, + RecordChatterProps as SpecRecordChatterProps, + RecordHistoryProps as SpecRecordHistoryProps, + RecordQuickActionsProps as SpecRecordQuickActionsProps, + RecordReferenceRailProps as SpecRecordReferenceRailProps, + RecordAlertProps as SpecRecordAlertProps, + ElementTextPropsSchema as SpecElementTextPropsSchema, + ElementButtonPropsSchema as SpecElementButtonPropsSchema, objectNavTargetExclusivity, checkListViewCalendarVisualization, checkPageSourceCompleteness, @@ -210,6 +228,28 @@ const IMPORTED: Array = [ // `listViews` record, crossed through this boundary, so the container is // measured here like every other crossing. ['ViewSchema', SpecViewSchema], + // objectui#10872: each ADR-0080 public-block arm's `properties` is the + // block's `ComponentPropsMap` row, crossed through this boundary, so every + // row is measured here like every other crossing (`page:section`, + // `page:footer` and `page:sidebar` share `PageContainerProps`, and + // `record:discussion` reads the `record:chatter` row, so one entry each). + ['PageHeaderProps', SpecPageHeaderProps], + ['PageTabsProps', SpecPageTabsProps], + ['PageCardProps', SpecPageCardProps], + ['PageAccordionProps', SpecPageAccordionProps], + ['PageContainerProps', SpecPageContainerProps], + ['RecordDetailsProps', SpecRecordDetailsProps], + ['RecordHighlightsProps', SpecRecordHighlightsProps], + ['RecordRelatedListProps', SpecRecordRelatedListProps], + ['RecordPathProps', SpecRecordPathProps], + ['RecordActivityProps', SpecRecordActivityProps], + ['RecordChatterProps', SpecRecordChatterProps], + ['RecordHistoryProps', SpecRecordHistoryProps], + ['RecordQuickActionsProps', SpecRecordQuickActionsProps], + ['RecordReferenceRailProps', SpecRecordReferenceRailProps], + ['RecordAlertProps', SpecRecordAlertProps], + ['ElementTextPropsSchema', SpecElementTextPropsSchema], + ['ElementButtonPropsSchema', SpecElementButtonPropsSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 0c9d3a9552..3a9c9a0d63 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4025,6 +4025,56 @@ const EXCLUSIONS: Readonly> = { 'a RETIRED node type refusal arm (objectui#8802), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an arm in `ComplexSchema`', 'ai.zod.ts#AIComponentSchema': "a union OVER the mirrors, not an object of its own — its members are checked individually above", + // objectui#10872 — the ADR-0080 public-block arms. Each is `BaseSchema` plus a + // `type` literal plus ONE member, `properties`, which is the block's + // `@objectstack/spec` `ComponentPropsMap` row by reference (through the + // objectui#8317 boundary). No TS declaration in this package restates any of + // these nodes — the spec's row types are re-exported under `Spec…` names by + // `../index.ts`, which is the spec's declaration, not a second one — so there + // is no pair to register, and the arm-to-row agreement is measured against + // the installed spec by `./public-block-arms-10872.test.ts` instead. + 'public-blocks.zod.ts#PageHeaderBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageTabsBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageCardBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageAccordionBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:accordion` literal + `properties`, which IS `ComponentPropsMap['page:accordion']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageSectionBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:section` literal + `properties`, which IS `ComponentPropsMap['page:section']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageFooterBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:footer` literal + `properties`, which IS `ComponentPropsMap['page:footer']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PageSidebarBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `page:sidebar` literal + `properties`, which IS `ComponentPropsMap['page:sidebar']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordDetailsBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:details` literal + `properties`, which IS `ComponentPropsMap['record:details']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordHighlightsBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:highlights` literal + `properties`, which IS `ComponentPropsMap['record:highlights']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordRelatedListBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:related_list` literal + `properties`, which IS `ComponentPropsMap['record:related_list']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordPathBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:path` literal + `properties`, which IS `ComponentPropsMap['record:path']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordActivityBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:activity` literal + `properties`, which IS `ComponentPropsMap['record:activity']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordDiscussionBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:discussion` literal + `properties`, which IS `ComponentPropsMap['record:discussion']` (the spec's shared `record:chatter` row); no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordHistoryBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:history` literal + `properties`, which IS `ComponentPropsMap['record:history']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordQuickActionsBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:quick_actions` literal + `properties`, which IS `ComponentPropsMap['record:quick_actions']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordReferenceRailBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:reference_rail` literal + `properties`, which IS `ComponentPropsMap['record:reference_rail']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#RecordAlertBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `record:alert` literal + `properties`, which IS `ComponentPropsMap['record:alert']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ElementTextBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `element:text` literal + `properties`, which IS `ComponentPropsMap['element:text']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ElementButtonBlockSchema': + "spec-owned BY REFERENCE — `BaseSchema` + the `element:button` literal + `properties`, which IS `ComponentPropsMap['element:button']`; no TS declaration in this package restates the node", + 'public-blocks.zod.ts#ElementDividerBlockSchema': + "spec-owned, RESTATED — `BaseSchema` + the `element:divider` literal + a member-less strict `properties`, because `@objectstack/spec/ui` exports `ComponentPropsMap['element:divider']` under no name to read by reference; its key set and accept set are pinned to the row's in `./public-block-arms-10872.test.ts`, and no TS declaration in this package restates the node", + 'public-blocks.zod.ts#PublicBlockComponentSchema': + "a union OVER the public-block arms, not an object of its own — its members are accounted for individually above", // Renamed from `StylePropsSchema` by objectui#5928. Under the old name the // like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing // ZERO keys with this `{ className, style }` object — read as its declaration, and From 4679a312096390e41c6ac47b2e0dcda3bee7d3c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:07:10 +0000 Subject: [PATCH 4/6] fix(types): page:tabs refuses an authored onTabChange; changeset, README and a dated note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#10872: the page:tabs renderer calls a host-injected onTabChange, so check:handler-key-reads requires the arm to declare it — a runtime-slot handlerKeyRefusal. Adds the 10872 changeset (@object-ui/types minor), the zod README group, and a dated note on the 8344 entry whose objectui#10859 note this change falsifies. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10872-public-block-zod-arms.md | 17 +++++++++++++++++ .../8344-node-recursion-point-redirect.md | 6 ++++++ .../__tests__/public-block-arms-10872.test.ts | 10 ++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 2 +- packages/types/src/zod/README.md | 9 +++++++++ packages/types/src/zod/public-blocks.zod.ts | 19 +++++++++++++++++-- 6 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 .changeset/10872-public-block-zod-arms.md diff --git a/.changeset/10872-public-block-zod-arms.md b/.changeset/10872-public-block-zod-arms.md new file mode 100644 index 0000000000..1abbd674b7 --- /dev/null +++ b/.changeset/10872-public-block-zod-arms.md @@ -0,0 +1,17 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts the ADR-0080 public blocks whose props `@objectstack/spec` declares as a `ComponentPropsMap` row: `page:header`, `page:tabs`, `page:card`, `page:accordion`, `page:section`, `page:footer`, `page:sidebar`, `record:details`, `record:highlights`, `record:related_list`, `record:path`, `record:activity`, `record:discussion`, `record:history`, `record:quick_actions`, `record:reference_rail`, `record:alert`, `element:text`, `element:button` and `element:divider` (objectui#10872, batch 1). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by twenty `type` literals, and `@object-ui/types/zod` exports twenty-one new schemas. Nothing that parsed before is refused now. + +**What it was.** Every one of these types is registered, curated as platform contract by ADR-0080 (`PUBLIC_BLOCKS` in `@object-ui/core`) and declared by the spec, while `AnyComponentSchema` carried no arm for any of them — so every document naming one was refused with `invalid_union` at `type`, and `objectui validate` reported "Schema validation failed" for any page built from the public vocabulary, down to `{ "type": "record:details" }`. + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports one arm per block — `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema`, `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema`, `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema`, `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema`, `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema`, `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` — and the category union `PublicBlockComponentSchema`, which `AnyComponentSchema` now lists. The strict authoring face (`StrictAnyComponentSchema`) derives from `AnyComponentSchema` and accepts the same blocks, closed to undeclared keys like every other arm. +- Each arm declares the block's props where the spec puts them: `properties`, whose value is the block's `ComponentPropsMap` row, read by reference. A bag is judged only when present, as the spec's own props gate judges it, and it is judged by the spec's row — its members, value types, retired-key refusals and strictness — so `{ "type": "page:header", "properties": { "title": "Account", "inventedProp": true } }` is refused, naming `inventedProp`, on both faces. `element:divider` is the one row restated rather than read, because the spec exports it under no name; it declares no prop, as the row does. +- A prop written directly on the node rather than inside `properties` is not judged by these arms: the tolerant face passes it through as it does any undeclared key, and the strict face refuses it. +- `page:tabs` also refuses an authored `onTabChange`, by name: it is the callback `@object-ui/app-shell` injects onto the node to write `?tab=` back, a runtime slot a React host fills (objectui#6124), and a JSON value there was never a working handler — delete the key. +- Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row, and their two objectui declarations disagree). diff --git a/.changeset/8344-node-recursion-point-redirect.md b/.changeset/8344-node-recursion-point-redirect.md index decd16bd80..e9864d7856 100644 --- a/.changeset/8344-node-recursion-point-redirect.md +++ b/.changeset/8344-node-recursion-point-redirect.md @@ -132,3 +132,9 @@ make the redirect apply. release `zod/ai.zod.ts` joins the category modules `AnyComponentSchema` is built from and imports `zod/base.zod.ts` too, so both counts in the mechanical note above are one higher; the reason the arm cannot be an import is unchanged. The rest of this entry is kept as the reading of this change. + +⚠️ **Dated note, 2026-09-28 — a second category module — objectui#10872.** Later still in this same +release `zod/public-blocks.zod.ts` joins them as well and imports `zod/base.zod.ts` too, so both counts +in the mechanical note above are two higher, not the one the objectui#10859 note says; the reason the +arm cannot be an import is unchanged. The rest of this entry, and that note, are kept as their +readings. diff --git a/packages/types/src/__tests__/public-block-arms-10872.test.ts b/packages/types/src/__tests__/public-block-arms-10872.test.ts index 028c2acf6b..56dab6a53d 100644 --- a/packages/types/src/__tests__/public-block-arms-10872.test.ts +++ b/packages/types/src/__tests__/public-block-arms-10872.test.ts @@ -217,6 +217,16 @@ describe('the bag is the spec row, read by reference (objectui#10872)', () => { expect(rowOf('page:card').safeParse({ title: 'Summary', actions: ['edit'] }).success).toBe(false); }); + it('`page:tabs` refuses an authored `onTabChange` by name — a runtime slot, not a prop (objectui#6124)', () => { + const result = safeValidateSchema({ type: 'page:tabs', onTabChange: { action: 'toast' } }); + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === 'onTabChange'); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toContain('RUNTIME SLOT'); + // Control: the same node without the key parses. + expect(safeValidateSchema({ type: 'page:tabs' }).success).toBe(true); + }); + it('a public block nested in a page is judged by its own arm, not by the base keys', () => { const page = { type: 'page', diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 3a9c9a0d63..377e2568bd 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4036,7 +4036,7 @@ const EXCLUSIONS: Readonly> = { 'public-blocks.zod.ts#PageHeaderBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:header` literal + `properties`, which IS `ComponentPropsMap['page:header']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageTabsBlockSchema': - "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`; no TS declaration in this package restates the node", + "spec-owned BY REFERENCE — `BaseSchema` + the `page:tabs` literal + `properties`, which IS `ComponentPropsMap['page:tabs']`, + the `onTabChange` runtime-slot refusal (objectui#6124) its renderer's read requires; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageCardBlockSchema': "spec-owned BY REFERENCE — `BaseSchema` + the `page:card` literal + `properties`, which IS `ComponentPropsMap['page:card']`; no TS declaration in this package restates the node", 'public-blocks.zod.ts#PageAccordionBlockSchema': diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 88c0577046..77768c3838 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -234,6 +234,15 @@ function validateComponent(config: unknown) { ### AI Components (3) - `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` +### ADR-0080 Public Blocks (20) +Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. +- `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` +- `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` +- `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema` +- `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema` +- `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema` +- `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` + ## Schema Structure All component schemas follow the @objectstack/spec UI specification format: diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index dc28096fe1..33a2d8a269 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -38,7 +38,10 @@ * refinements and retired-key tombstones all arrive from the spec, and a row * the spec changes changes the arm the same day. No member is restated here, * so none can drift — except the one row the spec does not export by name - * (`element:divider`, below). + * (`element:divider`, below). The only node-level member any arm adds is an + * `on*` key a renderer reads off the node (`page:tabs`'s `onTabChange`), + * refused by name with `handlerKeyRefusal` as `check:handler-key-reads` + * requires of every such read. * * The bag is the one spelling every block here is read through at runtime: * `SchemaRenderer` hoists each `properties` key onto the node before the @@ -108,6 +111,7 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; +import { handlerKeyRefusal } from './tombstone.zod.js'; /** * The `properties` member of one public block: the spec row, optional, with @@ -131,10 +135,21 @@ export const PageHeaderBlockSchema = BaseSchema.extend({ properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), }); -/** `page:tabs` — `ComponentPropsMap['page:tabs']`. */ +/** + * `page:tabs` — `ComponentPropsMap['page:tabs']`, plus the one handler key its + * renderer reads off the node. + * + * `onTabChange` is not a prop the spec declares: it is the host callback + * `@object-ui/app-shell` injects onto the node (`withPageTabsUrlSync`, which + * writes `?tab=` back), and the `page:tabs` renderer CALLS it on every switch. + * So it is a RUNTIME SLOT (objectui#6124): refused by name when authored, + * because JSON has no function value, rather than left to `.passthrough()` to + * keep an authored value and hand it to a call site. + */ export const PageTabsBlockSchema = BaseSchema.extend({ type: z.literal('page:tabs'), properties: propsBag('page:tabs', stripImportedDefaults(SpecPageTabsProps)), + onTabChange: handlerKeyRefusal('onTabChange', 'runtime-slot', 'Tab switch callback'), }); /** `page:card` — `ComponentPropsMap['page:card']`. */ From 42f1cd1d2c79f3be6f05a4f2f7a7e06cd9c7c421 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:40:02 +0000 Subject: [PATCH 5/6] test(app-shell,scripts): the node oracle judges a public-block arm at its properties bag; page:tabs is an armed read, not an alias objectui#10872 fix round 1. block-config-schema-parity-8216's node oracle now judges an arm whose props live in a `properties` object member at that member (derived from the arm), so the designer's fields meet the spec row instead of the node's top level. check-handler-key-read-sites' objectui#9573 alias census drops page:tabs, which has its own arm now; its onTabChange read is pinned as judged there through the runtime-slot refusal. The checker's boundary-3 prose is amended where page:tabs was its example. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../block-config-schema-parity-8216.test.ts | 51 ++++++++++++++++-- .../check-handler-key-read-sites.test.ts | 53 ++++++++++++------- scripts/check-handler-key-read-sites.mjs | 9 +++- 3 files changed, 88 insertions(+), 25 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index b04ed37cac..c8cb98fa2a 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -52,10 +52,21 @@ * `retirementTombstone()`, which is why the retired direction below * cannot be measured without it. * - * A block may resolve on both (`object-grid`, `object-form`, `object-kanban`), - * on one (`grid` — node only; every `page:*` / `record:*` / `element:*` spec - * type — spec only), or on neither. Neither is an EXPLICIT exemption with a - * reason and a card, never a silent skip — the idiom is + * ⚠️ An arm is judged at the LEVEL its block's props live on. Most + * arms declare them at the node's top level; the ADR-0080 public-block + * arms (objectui#10872) declare them the spec's way, as one + * `properties` object member whose value is the block's + * `ComponentPropsMap` row. The designer edits a block's `properties` + * (`block-config.ts`'s header), so such an arm is judged against that + * member's shape — {@link propsLevelOf} derives the level from the arm + * itself, ⛔ never from a list of types. + * + * A block may resolve on both (`object-grid`, `object-form`, `object-kanban`, + * and, since objectui#10872, the `page:*` / `record:*` / `element:*` public + * blocks that carry a spec row — their node face judged at `properties`), on + * one (`grid` — node only; a spec type no arm declares yet, such as + * `element:number` — spec only), or on neither. Neither is an EXPLICIT exemption + * with a reason and a card, never a silent skip — the idiom is * `check-designer-field-key-parity.mjs`'s, copied rather than reinvented. * * ## Both directions, because "is it declared?" is not the question @@ -119,11 +130,26 @@ function nodeOracles(): Record { (typeMember?._def?.values as unknown[] | undefined)?.[0] ?? typeMember?._def?.value ?? typeMember?.value; - if (typeof literal === 'string' && !(literal in out)) out[literal] = exported; + if (typeof literal === 'string' && !(literal in out)) out[literal] = propsLevelOf(exported); } return out; } +/** + * The schema a designer field of this arm is judged against: the arm's + * `properties` member when that member unwraps to an OBJECT shape — the + * ADR-0080 public-block arms (objectui#10872), whose props live in the spec's + * bag — and the arm itself otherwise. Read off the arm, so an arm that adopts + * the bag later is judged at the bag without an edit here, and an arm without + * one (or with a non-object `properties`) keeps its top-level reading. + */ +function propsLevelOf(arm: unknown): unknown { + const member = resolvePropsShape(arm)?.properties as { unwrap?: () => unknown } | undefined; + if (!member) return arm; + const inner = typeof member.unwrap === 'function' ? member.unwrap() : member; + return resolvePropsShape(inner) ? inner : arm; +} + const NODE_ORACLES = nodeOracles(); const SPEC_ORACLES = ComponentPropsMap as unknown as Record; @@ -273,6 +299,21 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the instruments (objectui#8216 } }); + it('judges a bag-carrying arm AT its `properties` bag, and every other arm at its top level (objectui#10872)', () => { + // The descent is derived, so its non-vacuity is measured on both sides: a + // public-block arm resolves to its bag — the designer's `title` control is + // a declared member there, and the bag still carries the spec's tombstone + // for `icon` — while a top-level arm is untouched by it. + const header = NODE_ORACLES['page:header']; + expect(header, 'no node arm resolved for page:header').toBeTruthy(); + expect(listedShapeKeys(header)).toContain('title'); + expect(listedShapeKeys(header), 'judged at the node, not at its bag').not.toContain('properties'); + expect(judge(header, 'title')).toBeUndefined(); + expect(judge(header, 'icon')?.kind).toBe('RETIRED'); + expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('groupBy'); + expect(listedShapeKeys(NODE_ORACLES['object-kanban'])).toContain('type'); + }); + it('the MISSING probe can say no — and yes', () => { expect(judge(SPEC_ORACLES['page:header'], 'zzzNotAKey')?.kind).toBe('MISSING'); expect(judge(SPEC_ORACLES['page:header'], 'title')).toBeUndefined(); diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index f9c04813d5..2f229824c9 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -983,27 +983,38 @@ describe('check-handler-key-read-sites — this repository', () => { const census = (type: string, key: string) => result.census.find((c) => c.type === type && c.key === key); // Both are SEEN by the census — the state before objectui#9344 was absence, - // which no assertion about declaration could have caught. ⚠️ They are seen in - // two DIFFERENT states, and objectui#9573 is why: the `containers` read - // belongs to `PageTabsRenderer`, registered `{ namespace: 'page', - // skipFallback: true }`, so it claims `page:tabs` and no mirror carries that - // arm; the `DetailView` read belongs to a registration that omits - // `skipFallback`, so it DOES claim the bare `detail` key and `DetailSchema` - // IS its arm. The cast-visibility this leg exists for is unchanged — remove - // the receiver peeling and both rows vanish from the census. + // which no assertion about declaration could have caught. The cast-visibility + // this leg exists for is unchanged — remove the receiver peeling and both rows + // vanish from the census. The `containers` read belongs to + // `PageTabsRenderer`, registered `{ namespace: 'page', skipFallback: true }`, + // so it claims `page:tabs`; the `DetailView` read belongs to a registration + // that omits `skipFallback`, so it claims the bare `detail` key and + // `DetailSchema` is its arm. expect(census('page:tabs', 'onTabChange')?.file).toBe('packages/components/src/renderers/layout/containers.tsx'); expect(census('detail', 'onTabChange')?.file).toBe('packages/plugin-detail/src/DetailView.tsx'); - // ⛔ The `page:tabs` read is NOT judged against `TabsSchema` any more, and - // that is the whole of objectui#9573: `TabsSchema` is the `ui:tabs` arm, a - // different component's, and the row it produced could never be drained - // without publishing a page-tab handler on it. - expect(census('page:tabs', 'onTabChange')?.unmirroredAlias).toEqual({ - bareType: 'tabs', - bareSchema: 'TabsSchema', - }); + // ⭐ `page:tabs` is judged against its OWN arm now (objectui#10872), and + // declared there. It sat in the objectui#9573 alias population until then: + // no mirror carried a `page:tabs` arm, and scoring the read against the bare + // `tabs` key's `TabsSchema` — the `ui:tabs` primitive's arm, a DIFFERENT + // component's — produced a row that could never be drained without + // publishing a page-tab handler on the wrong schema, so objectui#9573 keyed + // it as an unmirrored alias instead. objectui#10872 gave the ADR-0080 public + // block its own arm, `PageTabsBlockSchema` (`public-blocks.zod.ts`), and + // declared the host-injected callback there as an objectui#6124 RUNTIME + // SLOT — so the read is judged where it lives, through that refusal. + const pageTabs = census('page:tabs', 'onTabChange'); + expect(pageTabs?.unmirroredAlias, 'page:tabs is an armed type, not an alias').toBeUndefined(); + expect(pageTabs?.schema).toBe('PageTabsBlockSchema'); + expect(pageTabs?.declared).toBe(true); + expect(pageTabs?.disposition).toBe('runtime-slot'); + // Un-vacuous from the arm side too: the resolver READ the member and its + // disposition off the arm, rather than defaulting a row to green. + expect(collectArms(repoRoot).arms.get('page:tabs')?.members.get('onTabChange')).toBe('runtime-slot'); + // …and nothing re-keys it onto the bare `tabs` arm. expect(census('tabs', 'onTabChange')).toBeUndefined(); expect(KNOWN_UNDECLARED_READS.has('tabs::TabsSchema.onTabChange')).toBe(false); + expect(KNOWN_UNDECLARED_READS.has('page:tabs::PageTabsBlockSchema.onTabChange')).toBe(false); // `detail` keeps its row. ⚠️ This leg deliberately asserts nothing about // which disposition it should get. ⛔ That decision is NOT objectui#9344's — @@ -1029,14 +1040,18 @@ describe('check-handler-key-read-sites — this repository', () => { * * ⚠️ The floor matters more here than anywhere else in this file: a census * that collapsed would report an empty alias population and read as a clean - * tree. So the six registrations are NAMED, not counted. + * tree. So the registrations are NAMED, not counted. + * + * `page:tabs` was the sixth until objectui#10872 gave it its own arm, which + * takes it out of this population by construction — an armed type is not an + * alias. Where its read is judged now is pinned in the objectui#9344 leg above. */ - it('keys the six namespaced-only aliases on what they claim, and scores their reads once', () => { + it('keys the five namespaced-only aliases on what they claim, and scores their reads once', () => { const aliasRows = result.census.filter((c) => c.unmirroredAlias); expect( [...new Set(aliasRows.map((c) => c.type))].sort(), 'the alias census is the objectui#9573 population — an empty one is a collapsed scan, not a clean tree', - ).toEqual(['action:button', 'action:icon', 'page:tabs', 'view:form', 'view:grid', 'view:list']); + ).toEqual(['action:button', 'action:icon', 'view:form', 'view:grid', 'view:list']); expect(result.counters.aliasReads).toBe(aliasRows.length); expect(aliasRows.length).toBeGreaterThan(10); diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index fb8841cef4..a0fc5a7621 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -112,7 +112,8 @@ * are registered without a mirror. An arm that does not exist cannot have * lost a member, and inventing an obligation there would be a different * card. ⚠️ A namespaced-only alias over a type whose BARE key does have an - * arm falls here too — `view:list`, `action:button`, `page:tabs` — and + * arm falls here too — `view:list`, `action:button` (and `page:tabs` until + * objectui#10872 gave it its own arm) — and * because that is the shape objectui#9573 was mis-judging, its reads are * still REPORTED, as UNMIRRORED-ALIAS census rows carrying the bare arm * they are not. They are countable and `--list`-able; they are ⛔ not @@ -215,6 +216,12 @@ export const KNOWN_UNDECLARED_READS = new Map([ // rows: real reads under a type key no mirror carries an arm for, which is // boundary 3 above and ⛔ not a finding this gate can make. // + // ⚠️ AMENDED (objectui#10872) — `page:tabs` has since LEFT that trio the way + // the paragraph above says an alias should: the namespaced key was mirrored. + // `PageTabsBlockSchema` (`public-blocks.zod.ts`) is its own arm and declares + // `onTabChange` as an objectui#6124 runtime slot, so its read is judged there + // like any armed read. `action:button` and `action:icon` are still aliases. + // // ⚠️ And the same re-keying removed a FALSE GREEN the ledger could never have // shown, because a wrongly-passing read leaves no row: `'form' // FormSchema.onCancel` passed only because `FormSchema` — the `ui:form` From 0212421eda08feb4dc991b61f8a776e8bca8f536 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:11:31 +0000 Subject: [PATCH 6/6] docs(types,changeset): the 10872 entry says twenty blocks and what a flat key meets; action:* held for an upstream row objectui#10872 fix round 2, text only. The changeset lead names twenty of the public blocks; its flat-props bullet separates keys BaseSchema does not declare (tolerant passes, strict refuses) from the ones it does (children, label, disabled, visible judged by the base; body refused on both faces by objectui#6771). The action:* reason in the changeset and the module docblock is the seat's: no spec row yet, one being measured upstream (objectstack-ai/objectstack#20371). The checker amendment points at boundary 3. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10872-public-block-zod-arms.md | 6 +++--- packages/types/src/zod/public-blocks.zod.ts | 15 +++++++++------ scripts/check-handler-key-read-sites.mjs | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.changeset/10872-public-block-zod-arms.md b/.changeset/10872-public-block-zod-arms.md index 1abbd674b7..6d69f2ef9f 100644 --- a/.changeset/10872-public-block-zod-arms.md +++ b/.changeset/10872-public-block-zod-arms.md @@ -2,7 +2,7 @@ '@object-ui/types': minor --- -`safeValidateSchema` — and so `objectui validate` — accepts the ADR-0080 public blocks whose props `@objectstack/spec` declares as a `ComponentPropsMap` row: `page:header`, `page:tabs`, `page:card`, `page:accordion`, `page:section`, `page:footer`, `page:sidebar`, `record:details`, `record:highlights`, `record:related_list`, `record:path`, `record:activity`, `record:discussion`, `record:history`, `record:quick_actions`, `record:reference_rail`, `record:alert`, `element:text`, `element:button` and `element:divider` (objectui#10872, batch 1). +`safeValidateSchema` — and so `objectui validate` — accepts twenty of the ADR-0080 public blocks, the ones whose props `@objectstack/spec` declares as a `ComponentPropsMap` row less `element:number` (below): `page:header`, `page:tabs`, `page:card`, `page:accordion`, `page:section`, `page:footer`, `page:sidebar`, `record:details`, `record:highlights`, `record:related_list`, `record:path`, `record:activity`, `record:discussion`, `record:history`, `record:quick_actions`, `record:reference_rail`, `record:alert`, `element:text`, `element:button` and `element:divider` (objectui#10872, batch 1). **Clause-②: yes** — the accept set of `AnyComponentSchema` widens by twenty `type` literals, and `@object-ui/types/zod` exports twenty-one new schemas. Nothing that parsed before is refused now. @@ -12,6 +12,6 @@ - `@object-ui/types/zod` exports one arm per block — `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema`, `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema`, `RecordDetailsBlockSchema`, `RecordHighlightsBlockSchema`, `RecordRelatedListBlockSchema`, `RecordPathBlockSchema`, `RecordActivityBlockSchema`, `RecordDiscussionBlockSchema`, `RecordHistoryBlockSchema`, `RecordQuickActionsBlockSchema`, `RecordReferenceRailBlockSchema`, `RecordAlertBlockSchema`, `ElementTextBlockSchema`, `ElementButtonBlockSchema`, `ElementDividerBlockSchema` — and the category union `PublicBlockComponentSchema`, which `AnyComponentSchema` now lists. The strict authoring face (`StrictAnyComponentSchema`) derives from `AnyComponentSchema` and accepts the same blocks, closed to undeclared keys like every other arm. - Each arm declares the block's props where the spec puts them: `properties`, whose value is the block's `ComponentPropsMap` row, read by reference. A bag is judged only when present, as the spec's own props gate judges it, and it is judged by the spec's row — its members, value types, retired-key refusals and strictness — so `{ "type": "page:header", "properties": { "title": "Account", "inventedProp": true } }` is refused, naming `inventedProp`, on both faces. `element:divider` is the one row restated rather than read, because the spec exports it under no name; it declares no prop, as the row does. -- A prop written directly on the node rather than inside `properties` is not judged by these arms: the tolerant face passes it through as it does any undeclared key, and the strict face refuses it. +- A prop written directly on the node rather than inside `properties` is not judged against the block's row. A key the shared node base (`BaseSchema`) does not declare, such as `{ "type": "record:details", "columns": "2" }`, passes the tolerant face and is refused by the strict face. A key the base does declare is judged by the base instead. `children` (`page:card`, `page:section`, `page:footer`, `page:sidebar`), `label` and `disabled` (`element:button`) and `visible` (`record:alert`) are judged against the base's own types. `body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement: write a `record:alert` banner's `body` inside `properties`. - `page:tabs` also refuses an authored `onTabChange`, by name: it is the callback `@object-ui/app-shell` injects onto the node to write `?tab=` back, a runtime slot a React host fills (objectui#6124), and a JSON value there was never a working handler — delete the key. -- Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row, and their two objectui declarations disagree). +- Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row yet; one is being measured from the renderers' read points upstream, objectstack-ai/objectstack#20371). diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index 33a2d8a269..ac2871ffb6 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -51,10 +51,13 @@ * synthesizer (`buildDefaultPageSchema`'s `componentNode`) and the page * designer both emit `{ type, properties }`. * - * ⚠️ A key written FLAT on the node (`{ "type": "record:details", "columns": - * "2" }`) is NOT declared here. The tolerant face passes it through unjudged, - * exactly as it passes every undeclared key of every arm; the strict authoring - * face refuses it. Whether the flat spelling is ALSO an authoring channel for + * ⚠️ A key written FLAT on the node is not judged against the block's row. One + * the shared node base does not declare (`{ "type": "record:details", + * "columns": "2" }`) passes the tolerant face unjudged, exactly as every + * undeclared key of every arm does, and the strict authoring face refuses it; + * one `BaseSchema` does declare (`children`, `label`, `disabled`, `visible`) is + * judged by the base's own type, and a flat `body` is refused on both faces by + * the base's objectui#6771 retirement. Whether the flat spelling is ALSO an authoring channel for * the `page:` / `record:` families (their renderers read the hoisted node * keys) is left open on objectui#10872 rather than decided by this module — * declaring it later is additive, and it must never extend to `element:*`, @@ -79,8 +82,8 @@ * namespace is a closed vocabulary at author time and does not declare * either type. * - `action:button`, `action:group`, `action:menu`, `action:icon` — no spec - * row, and the two objectui declarations disagree (the renderers' - * `UIActionSchema`-based schema types against the registrations' `inputs`). + * row yet; one is being measured from the renderers' read points upstream + * (objectstack-ai/objectstack#20371). * * ⛔ No `.default()` anywhere in this module — see the "authors no default" * note in `index.zod.ts`. diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index a0fc5a7621..45ec5ebf98 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -217,7 +217,7 @@ export const KNOWN_UNDECLARED_READS = new Map([ // boundary 3 above and ⛔ not a finding this gate can make. // // ⚠️ AMENDED (objectui#10872) — `page:tabs` has since LEFT that trio the way - // the paragraph above says an alias should: the namespaced key was mirrored. + // boundary 3 above says an alias should: the namespaced key was mirrored. // `PageTabsBlockSchema` (`public-blocks.zod.ts`) is its own arm and declares // `onTabChange` as an objectui#6124 runtime slot, so its read is judged there // like any armed read. `action:button` and `action:icon` are still aliases.