Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/16583-sdui-parser-binding-field-arm-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@objectstack/sdui-parser': minor
---

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)

objectui retired the same arm from its copy of this package: the maintainer
ruling of 2026-09-07 on objectui#6950 (director decision batch #69) took the
serializer's input boundary, objectui#8315 took the two faces in `types.ts`,
both citing enforce-or-remove on a zero-writer measurement. That ruling names
coordinates in objectui only, and nothing propagates a retirement across 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. This is
that port, measured here rather than inherited.

- `RegistryConfigLike.inputs[].binding` — now `'object'`
- `ManifestInput.binding` — now `'object'`
- `ValidationResult.bindings[].kind` — now `'object'`

**Breaking for TypeScript consumers, deliberately, and compile-time only.** A
registry config, a hand-written `Manifest` literal or a `bindings[]` entry that
spells `'field'` is now a `tsc` error. Runtime behaviour does not move: types
are erased, this package runs no validator over a `Manifest` it is handed, and
`validateTree` still forwards whatever the manifest says. A pin in
`src/__tests__/binding-field-retired.test.ts` states that limit outright, so the
narrowing is not mistaken for a runtime rejection, and it goes red in both
directions — a `@ts-expect-error` that stops being needed is itself `ts(2578)`,
so widening any of the three declarations back fails the package typecheck on
the very line that documents the retirement.

**Nothing measured has to be rewritten, and the key was never author-writable
here.** `binding` is not a spec key, has no Zod schema and no stored
representation; it reaches this package only through the structural
`RegistryConfigLike` boundary, which exists so the package can be fed
objectui's `ComponentRegistry.getAllConfigs()` without depending on it. Four
readings on this tree, each with its control: `binding: 'field'` has zero
writers in this repository against a firing `binding: 'object'` control of 2
(both under `packages/sdui-parser/src/__tests__/`); the tracked
`sdui.manifest.json` — the only manifest this repo produces — carries zero
`binding` keys across all 339 of its inputs; nothing outside the package reads
`binding` or `bindings[].kind` at all, the package's single importer
(`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring `{ diagnostics }`
only; and no arm of the vocabulary is branched on anywhere, so no consumer
loses a case it was handling.

**Why the reader face is narrowed too.** The counter-argument — producer to
reader is a subset relation, so a permissive reader is not wrong — was answered
rather than assumed away. `ManifestInput` is not a pure reader face
(`manifestFromConfigs` returns it), and `bindings[].kind` is a pure **producer**
face where the relation inverts: a wider union there accepts nothing extra, it
obliges every consumer to handle an arm this package cannot emit. The two are
coupled by `validateTree`'s `kind: input.binding` assignment, so narrowing one
alone would need a cast at the only conversion site — the lenient consumer-side
fallback Prime Directive #12 bans. The reasoning now lives on the declarations
themselves, where a later reader lands.

The reopen route is the ruling's own: a measured need for field bindings is
filed as a widening with the vocabulary decided then, not pre-declared here for
a producer that does not exist.

<!-- 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. -->
98 changes: 98 additions & 0 deletions packages/sdui-parser/src/__tests__/binding-field-retired.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';

import { compile, manifestFromConfigs, type RegistryConfigLike } from '../index.js';
import type { Manifest, ManifestInput, ValidationResult } from '../types.js';

/**
* The `binding: 'field'` arm is retired on all THREE faces this package
* declares it on, and this file is the pin that keeps it retired.
*
* objectui retired the same arm from its copy of this package — the
* maintainer ruling of 2026-09-07 on objectui#6950 (director decision batch
* #69) on the serializer's input boundary, objectui#8315 on the two faces in
* `types.ts` — citing enforce-or-remove on a zero-writer measurement. Nothing
* carried that across to this copy. The narrowing here is that port.
*
* ## Why the pins are `@ts-expect-error` and not runtime assertions
*
* The retirement is COMPILE-TIME ONLY, deliberately and unavoidably: types are
* erased, this package runs no validator over a `Manifest` it is handed, and
* `validateTree` forwards whatever the manifest says. The last test below
* states that limit on purpose, so a later reader does not mistake the
* narrowing for a runtime rejection and go looking for the refusal path that
* would have to exist for it.
*
* ## Why this pin goes red in BOTH directions
*
* A `@ts-expect-error` that stops being needed is itself a `tsc` error
* (`ts(2578)`, "Unused '@ts-expect-error' directive"). So widening any of the
* three declarations back to `'object' | 'field'` fails `pnpm --filter
* @objectstack/sdui-parser typecheck` on the very line that documents the
* retirement, rather than leaving a green suite behind. The live
* `binding: 'object'` control beside each pin is what proves the declaration
* was not simply deleted: a pin whose positive control does not compile is
* asserting nothing.
*/
describe("binding: 'field' is retired on every face (objectui#6950, objectui#8315)", () => {
it('face 1 — RegistryConfigLike, the serializer input boundary, takes only `object`', () => {
const live: RegistryConfigLike = {
type: 'object-table',
inputs: [{ name: 'object', type: 'string', binding: 'object' }],
};

const retired: RegistryConfigLike = {
type: 'object-table',
// @ts-expect-error — the retired 'field' arm (objectui#6950).
inputs: [{ name: 'object', type: 'string', binding: 'field' }],
};

// The control still serializes, so the key itself is alive, not deleted.
expect(manifestFromConfigs([live]).components['object-table'].inputs[0].binding).toBe('object');
expect(retired.type).toBe('object-table');
});

it('face 2 — ManifestInput, the manifest reader/producer face, takes only `object`', () => {
const live: ManifestInput = { name: 'object', type: 'string', binding: 'object' };
// @ts-expect-error — the retired 'field' arm (objectui#8315).
const retired: ManifestInput = { name: 'object', type: 'string', binding: 'field' };

expect(live.binding).toBe('object');
expect(retired.name).toBe('object');
});

it('face 3 — ValidationResult.bindings[].kind, the producer face, emits only `object`', () => {
const live: ValidationResult['bindings'] = [
{ tag: 'object-table', input: 'object', kind: 'object', value: 'account' },
];
const retired: ValidationResult['bindings'] = [
// @ts-expect-error — the retired 'field' arm (objectui#8315).
{ tag: 'object-table', input: 'object', kind: 'field', value: 'name' },
];

expect(live[0].kind).toBe('object');
expect(retired[0].tag).toBe('object-table');
});

it('the narrowing is compile-time only — a cast-in `field` manifest still round-trips', () => {
// Unrepresentable in the type, so it takes a cast to build at all. That IS
// the limit being pinned: nothing in this package refuses the value at
// runtime, and a consumer handing us one gets it back verbatim.
const smuggled = {
components: {
'object-table': {
type: 'object-table',
namespace: 'plugin-grid',
isContainer: false,
inputs: [{ name: 'object', type: 'string', required: true, binding: 'field' }],
},
},
} as unknown as Manifest;

const r = compile('<object-table object="account" />', smuggled);

expect(r.ok).toBe(true);
expect(r.bindings).toEqual([
{ tag: 'object-table', input: 'object', kind: 'field', value: 'account' },
]);
});
});
19 changes: 18 additions & 1 deletion packages/sdui-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,24 @@ export interface RegistryConfigLike {
type: string | string[];
required?: boolean;
enum?: Array<string | { value: unknown; label?: string }>;
binding?: 'object' | 'field';
/**
* The binding marker — exactly `'object'`. This is the SERIALIZER'S INPUT
* BOUNDARY, the narrowest of the three faces the arm stood on, and the one
* objectui's ruling named: a config that feeds `manifestFromConfigs` a
* `binding: 'field'` input is a type error rather than a manifest entry
* the server would never resolve.
*
* Unlike {@link RegistryConfigLike.inputs}`[].type` above — typed loosely
* on purpose so an off-vocabulary value stays representable and is
* normalized on the way in — `binding` is NOT normalized anywhere:
* `manifestFromConfigs` copies it through verbatim. There is no
* canonicalizer to absorb a fourth spelling, so the boundary is where the
* vocabulary has to hold.
*
* The retirement, the answered counter-argument and the measurements are
* recorded once, on {@link ManifestInput.binding} in `types.ts`.
*/
binding?: 'object';
description?: string;
}>;
}
Expand Down
67 changes: 63 additions & 4 deletions packages/sdui-parser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,57 @@ export interface ManifestInput {
required?: boolean;
/** allowed values for `enum` inputs */
enum?: Array<string | { value: unknown; label?: string }>;
/** marks a data-binding input the server must resolve (ADR-0080 §6.3) */
binding?: 'object' | 'field';
/**
* Marks a data-binding input the server must resolve (ADR-0080 §6.3):
* `binding: 'object'` says the input NAMES an object, so the server-side
* binding check knows what to resolve it against. Unrelated to
* `type: 'object'`, the coarse control kind — a record-shaped value and an
* object-naming input are two different facts.
*
* ## The vocabulary is exactly `'object'`
*
* `'field'` stood beside it from the first draft of ADR-0080 and was never
* written. objectui retired it from the same three declarations of its copy
* of this package — the maintainer ruling of 2026-09-07 on objectui#6950
* (director decision batch #69) on the serializer's input boundary, and
* objectui#8315 on the two faces here — citing enforce-or-remove on the
* ground that the arm has zero writers. Nothing propagated that to this
* copy; this declaration is that port.
*
* ⚠️ The argument for leaving THIS face wide was answered, not overlooked.
* It runs: producer → reader is a subset relation, so a reader accepting a
* value no producer emits is permissive rather than wrong. Against it:
*
* 1. **This is not a pure reader face.** `manifestFromConfigs` RETURNS a
* `Manifest`, so `ManifestInput` is also this package's OUTPUT type,
* fed straight from the already-narrowed `RegistryConfigLike` boundary
* in `index.ts`. A union wider than the producer's is imprecision on
* the way out, not permissiveness on the way in.
* 2. **Its sibling is a pure producer face.**
* {@link ValidationResult.bindings}`[].kind` is written by
* `validateTree` by copying this key, so the subset relation runs the
* other way there — see that declaration. The two are COUPLED by that
* assignment: narrowing one alone needs a cast at the only conversion
* site, which is the lenient consumer-side fallback Prime Directive #12
* bans. So "both narrow" and "both wide" were the only self-consistent
* states.
* 3. **The permissiveness protected nothing HERE either.** Re-measured on
* this tree rather than inherited: `binding: 'field'` has zero writers
* in this repository, against a firing `binding: 'object'` control of
* 2 (both under `src/__tests__/`); the tracked `sdui.manifest.json` —
* the only manifest this repo produces, serialized from objectui's live
* registry — carries zero `binding` keys across all 339 of its inputs;
* and nothing outside this package reads `binding` or
* `bindings[].kind` at all, the package's single importer
* (`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring
* `{ diagnostics }` only.
*
* The reopen route is the ruling's own: a MEASURED need for field bindings
* is filed as a widening with the vocabulary decided then — not pre-declared
* here for a producer that does not exist. The refusal is pinned in
* `src/__tests__/binding-field-retired.test.ts`.
*/
binding?: 'object';
description?: string;
}

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