Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit dacb402

Browse files
os-warrenclaude
andauthored
feat(types): derive and export the strict authoring twin of the node face (#8642)
* feat(types): derive and export the strict authoring twin of the node face Publish `StrictAnyComponentSchema`, `StrictSchemaNodeSchema` and `deriveStrictAuthoringSchema` from `@object-ui/types/zod` — a derived twin of the node face that closes every declared object at every depth. Additive: the rendering face keeps its `.passthrough()`, no accept set moves, and no consumer is wired to the new face. The twins are derived, never hand-written. Objects are cloned by patching a copy of their own def so `.refine()` / `.superRefine()` checks survive; a twin rebuilt with `z.object(shape)` would drop them and under-report red. Both sides of a pipe are walked, so a `z.preprocess`'s real schema is closed too. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w * docs(types): import ButtonSchema in the strict-face README example The snippet used `ButtonSchema` without importing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w * fix(types): close the strict twin over callable zod nodes, and pin the population The walker's type guard was `typeof value === 'object'`. Zod 4.4.3 builds some objects through `$ZodObjectJIT`, whose instances are CALLABLE — 20 of them are reachable on this face, all through `@objectstack/spec`-derived subtrees. The guard handed each one back untouched, so the subtree beneath went unwalked and 6 objects stayed open on the twin: an invented key inside one of them was accepted and silently dropped, while the same key at the root was refused and named. Nothing could catch that. No document in the 556-document corpus carries an undeclared key inside those 6 objects, and the pin file's own census began `typeof node !== 'object'` — it shared the blind spot with what it measured. Both guards now admit callable nodes, and the pin that was missing is here: a POPULATION pin requiring every object reachable on the twin to carry `catchall: never`, with the function-typed count asserted non-zero so the control cannot silently regress. REPRO-A is pinned as a document too, and the cycle's real invariant — the barrel is the sole entry — is pinned in both of its halves. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w * docs(types): re-derive the pipe census the blind guard had understated The `pipe` arm's comment said "one pipe reachable, `in` an array and `out` the transform". That reading was taken through the object-only guard. With callable nodes admitted, four pipes are reachable — including a preprocessor under `page.interfaceConfig.filterBy[]` that the old walker could not see at all. No code changes; the comment now states what the tree measures. Part of #8345 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2609812 commit dacb402

5 files changed

Lines changed: 980 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
Publish the derived strict authoring face from `@object-ui/types/zod`
6+
(objectui#8345, under the objectui#5250 ruling — maintainer 2026-09-04,
7+
decision batch #25, option 2: "each node schema gets a derived strict variant;
8+
`objectui validate` and the doc-snippet gates run strict; renderer props keep
9+
the tolerant face unchanged").
10+
11+
**Additive. No existing accept set moves.** `BaseSchema` keeps its
12+
`.passthrough()`, every published mirror keeps the documents it accepts today,
13+
and no consumer in this repository is wired to the new face — wiring
14+
`objectui validate` and the JSON-fence gate is a separate card. What is new:
15+
16+
- `StrictAnyComponentSchema` — the document-root twin of `AnyComponentSchema`,
17+
refusing any undeclared key at any depth with an `unrecognized_keys` issue
18+
that names it.
19+
- `StrictSchemaNodeSchema` — the child-slot twin of `SchemaNodeSchema`.
20+
- `deriveStrictAuthoringSchema(schema, options)` — the derivation itself, so a
21+
consumer can take the strict twin of any schema on the face rather than
22+
writing a second walker.
23+
24+
The twins are **derived**, never hand-written: every reachable object is closed
25+
through unions, discriminated unions, arrays, tuples, records, intersections,
26+
optionals, nullables, defaults, both sides of a pipe, and `z.lazy`. Objects are
27+
cloned by patching a copy of their own def, so `.refine()` and `.superRefine()`
28+
checks survive — a twin rebuilt with `z.object(shape)` would drop them and
29+
under-report. Opaque `custom` / `function` / `transform` validators have no
30+
shape to close and are reported through `onOpaqueShape` rather than skipped.

‎packages/types/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,42 @@ function renderComponent(schema: AnySchema) {
121121
type ButtonSchema = SchemaByType<'button'>;
122122
```
123123

124+
### The strict authoring face
125+
126+
`@object-ui/types/zod` publishes **two** faces over the same declarations.
127+
128+
- The **rendering face** (`AnyComponentSchema`, `SchemaNodeSchema`, every named
129+
mirror) is tolerant: a node may carry keys the schema does not declare, because
130+
renderer props ride through it.
131+
- The **strict authoring face** is a derived twin that closes every declared
132+
object, at every depth. It is meant for authoring-time checking — validating a
133+
document a person or an agent just wrote — where an undeclared key is far more
134+
likely to be a typo than a renderer prop.
135+
136+
```typescript
137+
import {
138+
AnyComponentSchema,
139+
ButtonSchema,
140+
StrictAnyComponentSchema,
141+
deriveStrictAuthoringSchema,
142+
} from '@object-ui/types/zod';
143+
144+
const document = { type: 'card', childrn: [] }; // note the typo
145+
146+
AnyComponentSchema.safeParse(document).success; // true — the tolerant face
147+
StrictAnyComponentSchema.safeParse(document).success; // false — `unrecognized_keys: ["childrn"]`
148+
149+
// Take the strict twin of any schema on the face:
150+
const StrictButton = deriveStrictAuthoringSchema(ButtonSchema);
151+
```
152+
153+
The twins are derived from the mirrors, never hand-written, so they cannot drift
154+
from them. Strictness here is a property of the parse, not of the declaration:
155+
the derived schema carries the same TypeScript type as the schema it came from.
156+
Opaque `custom` / `function` / `transform` validators have no shape to close;
157+
`deriveStrictAuthoringSchema` reports each one it meets through the optional
158+
`onOpaqueShape` callback.
159+
124160
## Type Categories
125161

126162
### Base Types

0 commit comments

Comments
 (0)