Skip to content

Commit 1e956d6

Browse files
committed
wip(spec): form-family enumeration ledger, companion pin, D3 entry and rationale fragment (#21464, stage 3)
Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude <noreply@anthropic.com>
1 parent d410d69 commit 1e956d6

5 files changed

Lines changed: 284 additions & 14 deletions

File tree

‎packages/spec/dropped-refinements.baseline.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
"description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.",
33
"measured": {
44
"zod": "4.4.3",
5-
"publishedSchemasWithDroppedRefinements": 217,
6-
"droppedRefinementSites": 653,
5+
"publishedSchemasWithDroppedRefinements": 218,
6+
"droppedRefinementSites": 654,
77
"refinementSitesThatDidProject": 369,
88
"refinementSitesWithNoJsonFormToCompare": 0
99
},
@@ -1364,6 +1364,11 @@
13641364
"filter.element"
13651365
]
13661366
},
1367+
"ui/ObjectFormProps": {
1368+
"sites": [
1369+
"submitBehavior.options[1].url"
1370+
]
1371+
},
13671372
"ui/ObjectGanttProps": {
13681373
"sites": [
13691374
"filter.element",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #21464 — four members of the `object-form` page block were `z.unknown()`
6+
// although the form reads each with a fixed shape, so an off-shape value passed
7+
// the component-props gate and the form fell back or ignored it in silence. The
8+
// row now takes the form view's own `submitBehavior` by reference and the
9+
// measured shape for `contentLayout`, `navigateOnSuccess` and `mobile`. The
10+
// form's `fields` and `sections` and the master-detail form's two are held at
11+
// `z.unknown()` (the form draws a `{ name }` field entry and an inline runtime
12+
// field inside a section, which the typed shapes would refuse), and
13+
// `customFields` waits for the spec to declare objectui's runtime form field.
14+
// D3 only: page-component `properties` is not parsed on the metadata save or
15+
// load path, so a stored page is never refused; an off-shape value has no
16+
// rewrite that says what the author meant; and the authored census found no
17+
// authored value to respell — the refused values are fixtures probing that the
18+
// form refuses them.
19+
export const entry: SemanticMigration = {
20+
id: 'ui-object-form-members-typed',
21+
surface: 'page `object-form` components — `properties.contentLayout`, `.submitBehavior`, '
22+
+ '`.navigateOnSuccess` and `.mobile` (which used to accept any value)',
23+
replacement: 'the shape the form reads: `contentLayout` `\'simple\'` or `\'tabbed\'`; `submitBehavior` the '
24+
+ 'form view\'s own block — `{ kind: \'thank-you\', title?, message? }`, `{ kind: \'redirect\', url, '
25+
+ 'delayMs? }` with a relative `url`, `{ kind: \'continue\' }` or `{ kind: \'next-record\' }`; '
26+
+ '`navigateOnSuccess` a relative path string; `mobile` `{ stickyActions?, stepper?, stepperMinFields?, '
27+
+ 'stepperFieldsPerStep?, fullscreenLongText? }`, with `stepper` `true`, `false` or `\'auto\'` and the two '
28+
+ 'counts positive integers. Write a `submitBehavior` `kind` as one of the four; move a `redirect` '
29+
+ 'destination to a relative path; write `heading` as `title`.',
30+
reason: 'The form reads these members with one shape, and the page-component row declared them '
31+
+ '`z.unknown()`, so any value passed the component-props gate and the form answered an off-shape one '
32+
+ 'with a silent default: a `submitBehavior` `kind` it does not know fell through to the thank-you panel; '
33+
+ 'a misspelled `contentLayout` such as `\'tabs\'` stacked the sections; a `navigateOnSuccess` that is not a '
34+
+ 'string threw after the record was written, so the submit reported a failure; and a `mobile` member it '
35+
+ 'does not read, or a `stepper` outside `true` / `false` / `\'auto\'`, was ignored. The row now takes '
36+
+ 'the form view\'s own `submitBehavior` by reference — the block the renderers already judge a redirect '
37+
+ '`url` through — so one value is judged the same way on the form view and the block, and the measured '
38+
+ 'shape for the other three. The form\'s `fields` and `sections` and the master-detail form\'s two stay '
39+
+ 'open, because the form draws a `{ name }` field entry and an inline runtime field inside a section, '
40+
+ 'which the typed shapes would refuse; and `customFields` stays open until the spec declares the '
41+
+ 'runtime form field its entries are. It is read where every page component\'s props are: the '
42+
+ 'component-props gate reports a refused value as an advisory `component-props-invalid` / '
43+
+ '`component-props-unknown-key` finding on `objectstack validate`, `objectstack build` and '
44+
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s '
45+
+ '`properties` is not parsed on the metadata save or load path. No conversion is registered: nothing '
46+
+ 'on the load path refuses the shape, and an off-shape value has no rewrite that both keeps what the '
47+
+ 'form shows today and honours what the author wrote — which is the judgment this entry leaves to the '
48+
+ 'upgrader. Deployed metadata NOT MEASURED.',
49+
acceptanceCriteria: 'Every `object-form` node validates: `objectstack validate` reports no '
50+
+ '`component-props-invalid` / `component-props-unknown-key` finding under the four members\' paths. '
51+
+ 'Each form that set one of them now shows it: the post-submit behaviour it names, the modal\'s tabbed '
52+
+ 'sections, the navigation after a save, and the phone presentation.',
53+
};

‎packages/spec/src/migrations/registry.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6130,6 +6130,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [
61306130
+ '`columns` untouched) on `object-form` page components, on every form payload a view '
61316131
+ 'carries, and on the assembled-manifest `viewItems` channel.',
61326132
},
6133+
{
6134+
id: 'ui-object-form-members-typed',
6135+
order: 67,
6136+
text:
6137+
'It also types four members of the `object-form` page block (#21464, the third stage of the '
6138+
+ '`ComponentPropsMap` `z.unknown()` close-out): `contentLayout`, `submitBehavior`, '
6139+
+ '`navigateOnSuccess` and `mobile` were `z.unknown()`, although the form reads each with one shape, '
6140+
+ 'so a `submitBehavior` `kind` the form does not know passed every door and fell through to the '
6141+
+ 'thank-you panel. `submitBehavior` takes the form view\'s own block by reference; the other three '
6142+
+ 'take the measured shape. The form\'s `fields` and `sections` and the master-detail form\'s two '
6143+
+ 'stay open — the form draws a `{ name }` field entry and an inline runtime field inside a section, '
6144+
+ 'which the typed shapes would refuse — and `customFields` stays open until the spec declares the '
6145+
+ 'runtime form field its entries are. Read by the component-props gate (advisory); a stored page '
6146+
+ 'still saves and loads, so no conversion is registered. Its D3 record is the semantic entry '
6147+
+ '`ui-object-form-members-typed`.',
6148+
},
61336149
{
61346150
id: 'ui-object-grid-export-options-closed',
61356151
order: 59,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21464, stage 3] Four of the form family's nine `z.unknown()` members are
5+
* typed: `object-form` `contentLayout`, `submitBehavior`, `navigateOnSuccess`
6+
* and `mobile`. The other five stay in the enumeration pin's ledger: the
7+
* form's `fields` and `sections` and the master-detail form's two are held,
8+
* because the form draws a value each typed shape would refuse (a `{ name }`
9+
* field entry; an inline runtime field inside a section), and `customFields`
10+
* waits with the objectui-held contracts (its entries are objectui's runtime
11+
* `FormField`, which the spec has not declared).
12+
*
13+
* ## The defect this file closes
14+
*
15+
* Each of the four is read with one shape (measured at the `.objectui-sha` pin
16+
* `89cad75d55`; the read points are in the members' docblocks), and the row
17+
* declared them `z.unknown()`. So a `submitBehavior` whose `kind` the form does
18+
* not know, a `contentLayout: 'tabs'`, a numeric `navigateOnSuccess` and a
19+
* misspelled `mobile` member all passed the component-props gate, and the form
20+
* fell back to its thank-you panel, stacked the sections, threw after the
21+
* record was written, or ignored the key — with no report.
22+
*
23+
* ## What is pinned, and why each half
24+
*
25+
* - §1 THE DECLARED SHAPES PARSE: every shape a measured writer authors parses
26+
* byte-identical — none of the four carries a default, so the parsed value
27+
* IS the authored one. A refusal pin with no lit control passes just as well
28+
* when the door refuses everything.
29+
* - §2 THE REFUSALS: an off-shape value of each member is refused with the
30+
* code AND the path, so a refusal for the wrong reason reds.
31+
* - §3 ONE SCHEMA: `submitBehavior` holds the form view's own def by identity,
32+
* and the two shapes declared here hold exactly the measured vocabulary.
33+
* - §4 THE REGISTRATION: the ADR-0087 D3 entry step 18 carries.
34+
*
35+
* The enumeration pin (`component-props-unknown-members.pin.test.ts`) holds the
36+
* other half: these four left its ledger, so a member reverted to
37+
* `z.unknown()` reds there.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
import type { z } from 'zod';
42+
43+
import { ComponentPropsMap, ObjectFormPropsSchema } from './component.zod';
44+
import { FormViewSchema } from './view.zod';
45+
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry';
46+
47+
const BASE = { objectName: 'account' } as const;
48+
const parse = (props: Record<string, unknown>) => ComponentPropsMap['object-form'].safeParse({ ...BASE, ...props });
49+
50+
/** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */
51+
function issues(result: z.ZodSafeParseResult<unknown>): { code: string; path: string }[] {
52+
if (result.success) return [];
53+
return result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') }));
54+
}
55+
56+
// ───────────────────────────────────────────────────────────────────────────
57+
// §1 the declared shapes parse
58+
// ───────────────────────────────────────────────────────────────────────────
59+
60+
describe('§1 each member accepts every shape a measured writer authors', () => {
61+
const BYTE_IDENTICAL: ReadonlyArray<readonly [label: string, props: Record<string, unknown>]> = [
62+
['contentLayout \'tabbed\'', { contentLayout: 'tabbed' }],
63+
['contentLayout \'simple\'', { contentLayout: 'simple' }],
64+
// The showcase's own wizard page (`examples/app-showcase/src/ui/pages/new-project-wizard.page.ts`).
65+
['a thank-you panel with a title and a message', {
66+
submitBehavior: { kind: 'thank-you', title: 'Project created', message: 'Your new project is ready.' },
67+
}],
68+
['a bare thank-you panel', { submitBehavior: { kind: 'thank-you' } }],
69+
['a relative redirect with a delay', { submitBehavior: { kind: 'redirect', url: '/apps/x/done', delayMs: 241 } }],
70+
['a redirect interpolating declared record fields', {
71+
submitBehavior: { kind: 'redirect', url: '/t/{{record.slug}}?ref={{record.id}}' },
72+
}],
73+
['continue', { submitBehavior: { kind: 'continue' } }],
74+
['next-record', { submitBehavior: { kind: 'next-record' } }],
75+
['a navigateOnSuccess template', { navigateOnSuccess: '/apps/x/o/record/{id}' }],
76+
['mobile fullscreenLongText', { mobile: { fullscreenLongText: true } }],
77+
['mobile stickyActions', { mobile: { stickyActions: true } }],
78+
['an empty mobile block (its presence marks the wrapper)', { mobile: {} }],
79+
['mobile stepper true with a minimum', { mobile: { stepper: true, stepperMinFields: 99 } }],
80+
['mobile stepper auto with a minimum', { mobile: { stepper: 'auto', stepperMinFields: 3 } }],
81+
['mobile stepper with fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 2 } }],
82+
['mobile stepper false', { mobile: { stepper: false } }],
83+
];
84+
85+
for (const [label, props] of BYTE_IDENTICAL) {
86+
it(`parses ${label}, byte-identical`, () => {
87+
const r = parse(props);
88+
expect(issues(r)).toEqual([]);
89+
expect(r.success && r.data).toStrictEqual({ ...BASE, ...props });
90+
});
91+
}
92+
93+
it('an absent member stays absent', () => {
94+
const r = parse({});
95+
expect(issues(r)).toEqual([]);
96+
expect(r.success && Object.keys(r.data)).toEqual(['objectName']);
97+
});
98+
});
99+
100+
// ───────────────────────────────────────────────────────────────────────────
101+
// §2 the refusals
102+
// ───────────────────────────────────────────────────────────────────────────
103+
104+
describe('§2 each member refuses an off-shape value', () => {
105+
const REFUSED: ReadonlyArray<readonly [label: string, props: Record<string, unknown>, code: string, path: string]> = [
106+
['a contentLayout outside the two', { contentLayout: 'tabs' }, 'invalid_value', 'contentLayout'],
107+
['a contentLayout object', { contentLayout: { mode: 'tabbed' } }, 'invalid_value', 'contentLayout'],
108+
['a submitBehavior kind the form does not know', { submitBehavior: { kind: 'toast' } }, 'invalid_union', 'submitBehavior.kind'],
109+
['a submitBehavior with no kind', { submitBehavior: { title: 'Done' } }, 'invalid_union', 'submitBehavior.kind'],
110+
['a bare-string submitBehavior', { submitBehavior: 'thank-you' }, 'invalid_type', 'submitBehavior'],
111+
['a protocol-relative redirect', { submitBehavior: { kind: 'redirect', url: '//example.com/thanks' } }, 'custom', 'submitBehavior.url'],
112+
['an absolute redirect', { submitBehavior: { kind: 'redirect', url: 'https://app.example.com/thanks' } }, 'custom', 'submitBehavior.url'],
113+
['a negative redirect delay', { submitBehavior: { kind: 'redirect', url: '/done', delayMs: -1 } }, 'too_small', 'submitBehavior.delayMs'],
114+
['a thank-you `heading`', { submitBehavior: { kind: 'thank-you', heading: 'Done' } }, 'unrecognized_keys', 'submitBehavior'],
115+
['options on continue', { submitBehavior: { kind: 'continue', title: 'Again' } }, 'unrecognized_keys', 'submitBehavior'],
116+
['a numeric navigateOnSuccess', { navigateOnSuccess: 42 }, 'invalid_type', 'navigateOnSuccess'],
117+
['an object navigateOnSuccess', { navigateOnSuccess: { url: '/x' } }, 'invalid_type', 'navigateOnSuccess'],
118+
['a stepper outside true / false / auto', { mobile: { stepper: 'yes' } }, 'invalid_union', 'mobile.stepper'],
119+
['a mobile member the form does not read', { mobile: { sticky: true } }, 'unrecognized_keys', 'mobile'],
120+
['zero fields per step', { mobile: { stepper: true, stepperFieldsPerStep: 0 } }, 'too_small', 'mobile.stepperFieldsPerStep'],
121+
['a fractional minimum', { mobile: { stepperMinFields: 2.5 } }, 'invalid_type', 'mobile.stepperMinFields'],
122+
['a boolean mobile', { mobile: true }, 'invalid_type', 'mobile'],
123+
];
124+
125+
for (const [label, props, code, path] of REFUSED) {
126+
it(`refuses ${label} — ${code} at ${path}`, () => {
127+
const r = parse(props);
128+
expect(r.success).toBe(false);
129+
expect(issues(r)).toEqual([{ code, path }]);
130+
});
131+
}
132+
133+
it('says a thank-you `heading` is its `title`', () => {
134+
const r = parse({ submitBehavior: { kind: 'thank-you', heading: 'Done' } });
135+
expect(r.success ? '' : r.error.issues[0]!.message).toMatch(/`heading` → `title`/);
136+
});
137+
138+
it('LIT CONTROL — an unknown top-level key is still refused at the row itself', () => {
139+
expect(issues(parse({ contentLayout: 'tabbed', notAFormKey: 1 }))).toEqual([{ code: 'unrecognized_keys', path: '' }]);
140+
});
141+
});
142+
143+
// ───────────────────────────────────────────────────────────────────────────
144+
// §3 one schema, not a copy of its shape
145+
// ───────────────────────────────────────────────────────────────────────────
146+
147+
describe('§3 the members hold the form view\'s own def, and the measured vocabulary', () => {
148+
const form = () => ObjectFormPropsSchema.shape;
149+
150+
it('submitBehavior is the form view\'s own member — the same def', () => {
151+
expect(form().submitBehavior.unwrap()._zod.def).toBe(FormViewSchema.shape.submitBehavior.unwrap()._zod.def);
152+
});
153+
154+
it('contentLayout declares exactly the read\'s two layouts', () => {
155+
expect([...form().contentLayout.unwrap().options].sort()).toEqual(['simple', 'tabbed']);
156+
});
157+
158+
it('a mobile block declares exactly the five members the form reads', () => {
159+
expect(Object.keys(form().mobile.unwrap().shape).sort())
160+
.toEqual(['fullscreenLongText', 'stepper', 'stepperFieldsPerStep', 'stepperMinFields', 'stickyActions']);
161+
});
162+
});
163+
164+
// ───────────────────────────────────────────────────────────────────────────
165+
// §4 the registration
166+
// ───────────────────────────────────────────────────────────────────────────
167+
168+
describe('§4 the ADR-0087 entry', () => {
169+
it('is registered as the D3 entry step 18 carries, beside the earlier stages\' entries', () => {
170+
const ids = MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id);
171+
expect(ids).toContain('ui-object-form-members-typed');
172+
expect(ids).toContain('ui-object-grid-kanban-calendar-list-members-typed');
173+
expect(ids).toContain('ui-object-map-gantt-tree-navigation-typed');
174+
});
175+
});

0 commit comments

Comments
 (0)