Skip to content

Commit 3a3a42c

Browse files
committed
refactor(sdui-parser)!: retire the zero-writer binding: 'field' arm on all three faces
objectui retired the same arm from its copy of this package (maintainer ruling of 2026-09-07 on objectui#6950, director decision batch #69, on the serializer input boundary; objectui#8315 on the two faces in types.ts), citing enforce-or-remove on a zero-writer measurement. Nothing propagates a retirement between the two copies of packages/sdui-parser, so this one kept the arm on all three declarations while the renderer that ships beside it no longer has it. Re-measured on this tree rather than inherited, each reading with its control: binding: 'field' 0 writers (control binding: 'object' = 2) sdui.manifest.json 0 binding keys across 339 inputs readers outside the pkg 0 (the single importer, @objectstack/lint's validate-jsx-pages.ts, destructures { diagnostics } only) branches on either arm 0 Narrowed: RegistryConfigLike.inputs[].binding, ManifestInput.binding and ValidationResult.bindings[].kind. Compile-time only — types are erased and validateTree still forwards whatever the manifest says, which the new pin states outright so the narrowing is not mistaken for a runtime rejection. The pin goes red in both directions: a @ts-expect-error that stops being needed is ts(2578), so widening any declaration back fails the package typecheck. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e496f9 commit 3a3a42c

4 files changed

Lines changed: 240 additions & 5 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/sdui-parser': minor
3+
---
4+
5+
Retire the zero-writer `binding: 'field'` arm from all three of this copy's declarations, so the save gate states the same one-word vocabulary the renderer already does (#16583)
6+
7+
objectui retired the same arm from its copy of this package: the maintainer
8+
ruling of 2026-09-07 on objectui#6950 (director decision batch #69) took the
9+
serializer's input boundary, objectui#8315 took the two faces in `types.ts`,
10+
both citing enforce-or-remove on a zero-writer measurement. That ruling names
11+
coordinates in objectui only, and nothing propagates a retirement across the
12+
two copies of `packages/sdui-parser` — so this one kept the arm on all three
13+
declarations while the renderer that ships beside it no longer has it. This is
14+
that port, measured here rather than inherited.
15+
16+
- `RegistryConfigLike.inputs[].binding` — now `'object'`
17+
- `ManifestInput.binding` — now `'object'`
18+
- `ValidationResult.bindings[].kind` — now `'object'`
19+
20+
**Breaking for TypeScript consumers, deliberately, and compile-time only.** A
21+
registry config, a hand-written `Manifest` literal or a `bindings[]` entry that
22+
spells `'field'` is now a `tsc` error. Runtime behaviour does not move: types
23+
are erased, this package runs no validator over a `Manifest` it is handed, and
24+
`validateTree` still forwards whatever the manifest says. A pin in
25+
`src/__tests__/binding-field-retired.test.ts` states that limit outright, so the
26+
narrowing is not mistaken for a runtime rejection, and it goes red in both
27+
directions — a `@ts-expect-error` that stops being needed is itself `ts(2578)`,
28+
so widening any of the three declarations back fails the package typecheck on
29+
the very line that documents the retirement.
30+
31+
**Nothing measured has to be rewritten, and the key was never author-writable
32+
here.** `binding` is not a spec key, has no Zod schema and no stored
33+
representation; it reaches this package only through the structural
34+
`RegistryConfigLike` boundary, which exists so the package can be fed
35+
objectui's `ComponentRegistry.getAllConfigs()` without depending on it. Four
36+
readings on this tree, each with its control: `binding: 'field'` has zero
37+
writers in this repository against a firing `binding: 'object'` control of 2
38+
(both under `packages/sdui-parser/src/__tests__/`); the tracked
39+
`sdui.manifest.json` — the only manifest this repo produces — carries zero
40+
`binding` keys across all 339 of its inputs; nothing outside the package reads
41+
`binding` or `bindings[].kind` at all, the package's single importer
42+
(`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring `{ diagnostics }`
43+
only; and no arm of the vocabulary is branched on anywhere, so no consumer
44+
loses a case it was handling.
45+
46+
**Why the reader face is narrowed too.** The counter-argument — producer to
47+
reader is a subset relation, so a permissive reader is not wrong — was answered
48+
rather than assumed away. `ManifestInput` is not a pure reader face
49+
(`manifestFromConfigs` returns it), and `bindings[].kind` is a pure **producer**
50+
face where the relation inverts: a wider union there accepts nothing extra, it
51+
obliges every consumer to handle an arm this package cannot emit. The two are
52+
coupled by `validateTree`'s `kind: input.binding` assignment, so narrowing one
53+
alone would need a cast at the only conversion site — the lenient consumer-side
54+
fallback Prime Directive #12 bans. The reasoning now lives on the declarations
55+
themselves, where a later reader lands.
56+
57+
The reopen route is the ruling's own: a measured need for field bindings is
58+
filed as a widening with the vocabulary decided then, not pre-declared here for
59+
a producer that does not exist.
60+
61+
<!-- adr-0087: not-required (no-migration-prescription) The retired arm has no metadata surface for `objectstack migrate meta` to reach: `binding` is not a `packages/spec` key, has no Zod schema and no stored `sys_metadata` representation — it is a member of three published TypeScript interfaces in `packages/sdui-parser`, delivered to the only affected party (a TypeScript consumer) by the compiler at their own call site. There is consequently no stored shape to rewrite and no prescription to ship, which the body states rather than omits: the arm has zero writers in this repository (firing `binding: 'object'` control = 2), zero `binding` keys of any spelling in the tracked `sdui.manifest.json` (339 inputs), and zero readers outside the package. -->
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { compile, manifestFromConfigs, type RegistryConfigLike } from '../index.js';
4+
import type { Manifest, ManifestInput, ValidationResult } from '../types.js';
5+
6+
/**
7+
* The `binding: 'field'` arm is retired on all THREE faces this package
8+
* declares it on, and this file is the pin that keeps it retired.
9+
*
10+
* objectui retired the same arm from its copy of this package — the
11+
* maintainer ruling of 2026-09-07 on objectui#6950 (director decision batch
12+
* #69) on the serializer's input boundary, objectui#8315 on the two faces in
13+
* `types.ts` — citing enforce-or-remove on a zero-writer measurement. Nothing
14+
* carried that across to this copy. The narrowing here is that port.
15+
*
16+
* ## Why the pins are `@ts-expect-error` and not runtime assertions
17+
*
18+
* The retirement is COMPILE-TIME ONLY, deliberately and unavoidably: types are
19+
* erased, this package runs no validator over a `Manifest` it is handed, and
20+
* `validateTree` forwards whatever the manifest says. The last test below
21+
* states that limit on purpose, so a later reader does not mistake the
22+
* narrowing for a runtime rejection and go looking for the refusal path that
23+
* would have to exist for it.
24+
*
25+
* ## Why this pin goes red in BOTH directions
26+
*
27+
* A `@ts-expect-error` that stops being needed is itself a `tsc` error
28+
* (`ts(2578)`, "Unused '@ts-expect-error' directive"). So widening any of the
29+
* three declarations back to `'object' | 'field'` fails `pnpm --filter
30+
* @objectstack/sdui-parser typecheck` on the very line that documents the
31+
* retirement, rather than leaving a green suite behind. The live
32+
* `binding: 'object'` control beside each pin is what proves the declaration
33+
* was not simply deleted: a pin whose positive control does not compile is
34+
* asserting nothing.
35+
*/
36+
describe("binding: 'field' is retired on every face (objectui#6950, objectui#8315)", () => {
37+
it('face 1 — RegistryConfigLike, the serializer input boundary, takes only `object`', () => {
38+
const live: RegistryConfigLike = {
39+
type: 'object-table',
40+
inputs: [{ name: 'object', type: 'string', binding: 'object' }],
41+
};
42+
43+
const retired: RegistryConfigLike = {
44+
type: 'object-table',
45+
// @ts-expect-error — the retired 'field' arm (objectui#6950).
46+
inputs: [{ name: 'object', type: 'string', binding: 'field' }],
47+
};
48+
49+
// The control still serializes, so the key itself is alive, not deleted.
50+
expect(manifestFromConfigs([live]).components['object-table'].inputs[0].binding).toBe('object');
51+
expect(retired.type).toBe('object-table');
52+
});
53+
54+
it('face 2 — ManifestInput, the manifest reader/producer face, takes only `object`', () => {
55+
const live: ManifestInput = { name: 'object', type: 'string', binding: 'object' };
56+
// @ts-expect-error — the retired 'field' arm (objectui#8315).
57+
const retired: ManifestInput = { name: 'object', type: 'string', binding: 'field' };
58+
59+
expect(live.binding).toBe('object');
60+
expect(retired.name).toBe('object');
61+
});
62+
63+
it('face 3 — ValidationResult.bindings[].kind, the producer face, emits only `object`', () => {
64+
const live: ValidationResult['bindings'] = [
65+
{ tag: 'object-table', input: 'object', kind: 'object', value: 'account' },
66+
];
67+
const retired: ValidationResult['bindings'] = [
68+
// @ts-expect-error — the retired 'field' arm (objectui#8315).
69+
{ tag: 'object-table', input: 'object', kind: 'field', value: 'name' },
70+
];
71+
72+
expect(live[0].kind).toBe('object');
73+
expect(retired[0].tag).toBe('object-table');
74+
});
75+
76+
it('the narrowing is compile-time only — a cast-in `field` manifest still round-trips', () => {
77+
// Unrepresentable in the type, so it takes a cast to build at all. That IS
78+
// the limit being pinned: nothing in this package refuses the value at
79+
// runtime, and a consumer handing us one gets it back verbatim.
80+
const smuggled = {
81+
components: {
82+
'object-table': {
83+
type: 'object-table',
84+
namespace: 'plugin-grid',
85+
isContainer: false,
86+
inputs: [{ name: 'object', type: 'string', required: true, binding: 'field' }],
87+
},
88+
},
89+
} as unknown as Manifest;
90+
91+
const r = compile('<object-table object="account" />', smuggled);
92+
93+
expect(r.ok).toBe(true);
94+
expect(r.bindings).toEqual([
95+
{ tag: 'object-table', input: 'object', kind: 'field', value: 'account' },
96+
]);
97+
});
98+
});

packages/sdui-parser/src/index.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,24 @@ export interface RegistryConfigLike {
7878
type: string | string[];
7979
required?: boolean;
8080
enum?: Array<string | { value: unknown; label?: string }>;
81-
binding?: 'object' | 'field';
81+
/**
82+
* The binding marker — exactly `'object'`. This is the SERIALIZER'S INPUT
83+
* BOUNDARY, the narrowest of the three faces the arm stood on, and the one
84+
* objectui's ruling named: a config that feeds `manifestFromConfigs` a
85+
* `binding: 'field'` input is a type error rather than a manifest entry
86+
* the server would never resolve.
87+
*
88+
* Unlike {@link RegistryConfigLike.inputs}`[].type` above — typed loosely
89+
* on purpose so an off-vocabulary value stays representable and is
90+
* normalized on the way in — `binding` is NOT normalized anywhere:
91+
* `manifestFromConfigs` copies it through verbatim. There is no
92+
* canonicalizer to absorb a fourth spelling, so the boundary is where the
93+
* vocabulary has to hold.
94+
*
95+
* The retirement, the answered counter-argument and the measurements are
96+
* recorded once, on {@link ManifestInput.binding} in `types.ts`.
97+
*/
98+
binding?: 'object';
8299
description?: string;
83100
}>;
84101
}

packages/sdui-parser/src/types.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,57 @@ export interface ManifestInput {
8080
required?: boolean;
8181
/** allowed values for `enum` inputs */
8282
enum?: Array<string | { value: unknown; label?: string }>;
83-
/** marks a data-binding input the server must resolve (ADR-0080 §6.3) */
84-
binding?: 'object' | 'field';
83+
/**
84+
* Marks a data-binding input the server must resolve (ADR-0080 §6.3):
85+
* `binding: 'object'` says the input NAMES an object, so the server-side
86+
* binding check knows what to resolve it against. Unrelated to
87+
* `type: 'object'`, the coarse control kind — a record-shaped value and an
88+
* object-naming input are two different facts.
89+
*
90+
* ## The vocabulary is exactly `'object'`
91+
*
92+
* `'field'` stood beside it from the first draft of ADR-0080 and was never
93+
* written. objectui retired it from the same three declarations of its copy
94+
* of this package — the maintainer ruling of 2026-09-07 on objectui#6950
95+
* (director decision batch #69) on the serializer's input boundary, and
96+
* objectui#8315 on the two faces here — citing enforce-or-remove on the
97+
* ground that the arm has zero writers. Nothing propagated that to this
98+
* copy; this declaration is that port.
99+
*
100+
* ⚠️ The argument for leaving THIS face wide was answered, not overlooked.
101+
* It runs: producer → reader is a subset relation, so a reader accepting a
102+
* value no producer emits is permissive rather than wrong. Against it:
103+
*
104+
* 1. **This is not a pure reader face.** `manifestFromConfigs` RETURNS a
105+
* `Manifest`, so `ManifestInput` is also this package's OUTPUT type,
106+
* fed straight from the already-narrowed `RegistryConfigLike` boundary
107+
* in `index.ts`. A union wider than the producer's is imprecision on
108+
* the way out, not permissiveness on the way in.
109+
* 2. **Its sibling is a pure producer face.**
110+
* {@link ValidationResult.bindings}`[].kind` is written by
111+
* `validateTree` by copying this key, so the subset relation runs the
112+
* other way there — see that declaration. The two are COUPLED by that
113+
* assignment: narrowing one alone needs a cast at the only conversion
114+
* site, which is the lenient consumer-side fallback Prime Directive #12
115+
* bans. So "both narrow" and "both wide" were the only self-consistent
116+
* states.
117+
* 3. **The permissiveness protected nothing HERE either.** Re-measured on
118+
* this tree rather than inherited: `binding: 'field'` has zero writers
119+
* in this repository, against a firing `binding: 'object'` control of
120+
* 2 (both under `src/__tests__/`); the tracked `sdui.manifest.json` —
121+
* the only manifest this repo produces, serialized from objectui's live
122+
* registry — carries zero `binding` keys across all 339 of its inputs;
123+
* and nothing outside this package reads `binding` or
124+
* `bindings[].kind` at all, the package's single importer
125+
* (`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring
126+
* `{ diagnostics }` only.
127+
*
128+
* The reopen route is the ruling's own: a MEASURED need for field bindings
129+
* is filed as a widening with the vocabulary decided then — not pre-declared
130+
* here for a producer that does not exist. The refusal is pinned in
131+
* `src/__tests__/binding-field-retired.test.ts`.
132+
*/
133+
binding?: 'object';
85134
description?: string;
86135
}
87136

@@ -103,6 +152,16 @@ export interface ValidationResult {
103152
diagnostics: Diagnostic[];
104153
/** unique plugin namespaces referenced — the page's `requires` */
105154
requires: string[];
106-
/** binding sites (object/field) the server must resolve against object schema */
107-
bindings: Array<{ tag: string; input: string; kind: 'object' | 'field'; value: unknown }>;
155+
/**
156+
* Binding sites the server must resolve against object schema.
157+
*
158+
* `kind` is a PRODUCER face, not a reader face: `validateTree` writes it,
159+
* copying {@link ManifestInput.binding} at the one site that builds this
160+
* array. So the subset relation that licenses a permissive READER runs the
161+
* other way here — a wider union accepts nothing extra, it obliges every
162+
* consumer to handle an arm this package cannot emit. That is why the
163+
* retired `'field'` arm is gone from this end as well; the measurements are
164+
* on {@link ManifestInput.binding}.
165+
*/
166+
bindings: Array<{ tag: string; input: string; kind: 'object'; value: unknown }>;
108167
}

0 commit comments

Comments
 (0)