diff --git a/.changeset/16583-sdui-parser-binding-field-arm-retired.md b/.changeset/16583-sdui-parser-binding-field-arm-retired.md
new file mode 100644
index 00000000000..6deca671bc2
--- /dev/null
+++ b/.changeset/16583-sdui-parser-binding-field-arm-retired.md
@@ -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.
+
+
diff --git a/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts b/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts
new file mode 100644
index 00000000000..a56f3751010
--- /dev/null
+++ b/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts
@@ -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('', smuggled);
+
+ expect(r.ok).toBe(true);
+ expect(r.bindings).toEqual([
+ { tag: 'object-table', input: 'object', kind: 'field', value: 'account' },
+ ]);
+ });
+});
diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts
index 686a280efb5..97b94de7642 100644
--- a/packages/sdui-parser/src/index.ts
+++ b/packages/sdui-parser/src/index.ts
@@ -78,7 +78,24 @@ export interface RegistryConfigLike {
type: string | string[];
required?: boolean;
enum?: Array;
- 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;
}>;
}
diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts
index 4ad6839b178..3a7fc45dac0 100644
--- a/packages/sdui-parser/src/types.ts
+++ b/packages/sdui-parser/src/types.ts
@@ -80,8 +80,57 @@ export interface ManifestInput {
required?: boolean;
/** allowed values for `enum` inputs */
enum?: Array;
- /** 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;
}
@@ -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 }>;
}