Skip to content

Commit 2e0401a

Browse files
refactor(sdui-parser)!: retire the zero-writer binding: 'field' arm on all three faces (#18537)
Fixes #16583 Clause-②: no ## What this delivers, in dispatch order **1. The consumer axis, answered with controls — the first deliverable.** Does anything in this repository read `sdui-parser`'s `binding` / `bindings[].kind` and do something on the `'field'` branch? **No. Zero.** The triage seat recorded that its own `=== 'field'` query was wrong and refused to publish the number it produced. That query is reproduced below and separated from a discriminating one, so the next reader can re-run both. ``` # NAIVE (what triage ran, and correctly refused to report) — 11 files git grep -lF "=== 'field'" -- '*.ts' '*.tsx' '*.mts' '*.mjs' # DISCRIMINATING, stage 1 — the set that can reach the parser's public surface # at all. The package publishes exactly one entry point, so an importer is the # only way in. git grep -lE "@objectstack/sdui-parser|packages/sdui-parser/(dist|src)" \ -- '*.ts' '*.tsx' '*.mts' '*.mjs' '*.js' '*.cjs' # 14 files # DISCRIMINATING, stage 2 — binding-kind reads inside stage 1 plus the package git grep -nE "\.bindings\b|\.binding\b|\bbinding\??:|\bkind\b *[:=]{1,3} *'(object|field)'" \ -- $(stage 1 files) packages/sdui-parser/src # 12 lines, all in-package ``` **Proof the discriminating query separates the two things** — `comm -12` of the naive file set and the stage-1 file set is **empty**. Not one of the 11 naive hits (`cel-to-filter`, `sharing-rule-service`, `autonumber-format`, `approvals`, `team-graph`, …) can reach this parser at all. - **Negative control** — the 11 naive files: all excluded, intersection size 0. - **Positive control** — the stage-2 query does match the genuine reads it must: `src/codegen.ts:151` (`i.binding`), `src/validate.ts:82-83` (`kind: input.binding`), `src/index.ts:108`. **Consumer count: 0.** Of the 14 stage-1 files, 11 are the package itself or prose (a `describe()` string in `page.zod.ts`, a file path in the dispatcher vocabulary, gate/self-test rosters). The only real importer outside the package is `packages/lint/src/validate-jsx-pages.ts`, and it destructures `{ diagnostics }` only — it never touches `bindings`. Nothing in the tree branches on either arm of the vocabulary, so nothing loses a case it was handling. **2. The writer axis, re-measured on today's tree — not inherited.** Measured at `1e496f9796126b0398c026c96fb8d240f1982e91`, the branch point: | reading | count | where | |---|---|---| | `binding: 'field'` | **0** | — | | `binding: 'object'` (control, fires) | **2** | `src/__tests__/compile.test.ts:15`, `src/__tests__/type-attribute-collision.test.ts:23` | | `binding` keys of any spelling in the tracked `sdui.manifest.json` | **0** | across 339 inputs / 57 components | The third row is new and is the one that matters most: `sdui.manifest.json` is the only manifest this repository produces, serialized from objectui's live `ComponentRegistry` at the pinned sha. Not even the surviving `'object'` arm has a production writer here. **3. Which way the gate runs — answered, not "unmeasured".** The card's premise was that objectui's `check:upstream-port-parity` keeps the two `packages/sdui-parser` copies in step. **It does not.** That gate pins objectstack *tooling scripts* ported into objectui (`scripts/pm/check-half-states.mjs`, `invoked-as.mjs`); its roster, `scripts/upstream-port-pin.json`, contains **zero** `sdui-parser` entries, and its direction is objectstack-as-upstream — the opposite relationship. Nothing in this change can red it. The parser copies are kept in step by **this** repo's `check:sdui-lockstep`, which vendors a ONE-SIDED record of objectui's copy and checks this tree against it. So this repo is the follower for that gate — but `judge()` compares exactly three things: the `.objectui-sha` pin, the byte blob of the ported grammar region of `parse.ts`, and the diagnostic-code set. The `binding` declarations live in `types.ts` and `index.ts` and are on none of the three, so they are outside that gate's field of view in both directions. And objectui has **already landed both halves**: `binding?: 'object'` at `index.ts:120` and `types.ts:143`, `kind: 'object'` at `types.ts:192`. So this change is categorically **following**, not leading. ## The change Three declarations narrow from `'object' | 'field'` to `'object'`: - `RegistryConfigLike.inputs[].binding` — the serializer's input boundary - `ManifestInput.binding` — the manifest reader/producer face - `ValidationResult.bindings[].kind` — the pure producer face Plus `src/__tests__/binding-field-retired.test.ts`, which pins the refusal on each face against a live `binding: 'object'` control, and pins the limit of the narrowing: it is compile-time only, so a cast-in `'field'` manifest still round-trips at runtime and the test asserts that it does. No one should read the narrowing as a runtime rejection and go hunting for a refusal path that does not exist. **The pin goes red in both directions, by construction.** A `@ts-expect-error` that stops being needed is itself `ts(2578)`. Widening any of the three declarations back fails `pnpm --filter @objectstack/sdui-parser typecheck` on the very line that documents the retirement. Evidence of both legs is in the reverse-verification section below. **Why the reader face is narrowed too.** The counter-argument — producer to reader is a subset relation, so a permissive reader is not wrong — is answered rather than assumed away. `ManifestInput` is not a pure reader face, because `manifestFromConfigs` returns it; and `bindings[].kind` is a pure producer face where the relation inverts, so 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. ## Reverse verification — run from the committed state, both legs, all three faces Each leg: widen one declaration back on disk, prove the widening landed by anchor counts and by `git hash-object`, run `tsc --noEmit`, then restore with `git checkout HEAD -- path` and prove restoration by blob hash and an empty `git diff HEAD` — never by a cleanup step's exit code. Both scripts carry `trap restore EXIT INT TERM` with absolute paths. No build or `dist` is involved: the pin imports `../types.js` and `../index.js` as relative sources inside the package, so nothing resolves through `dist`. **Leg A — widen `types.ts` (faces 2 and 3).** ``` HEAD blob for packages/sdui-parser/src/types.ts = 3a7fc45 post-mutation anchor counts: wide binding = 1, wide kind = 1, narrow binding = 0 mutated blob = 26288c715e292bb2b474a80e91344fba1c0bd21c (differs: not a no-op) MUTATED tsc EXIT=1 src/__tests__/binding-field-retired.test.ts(56,5): error TS2578: Unused '@ts-expect-error' directive. src/__tests__/binding-field-retired.test.ts(68,7): error TS2578: Unused '@ts-expect-error' directive. restored blob = 3a7fc45 (equal to HEAD) git diff HEAD -- that path: empty RESTORED tsc EXIT=0 ``` Two directives fired, not three — correctly. Face 1 lives in `index.ts`, which leg A does not touch, so its pin stayed needed. That is leg B: **Leg B — widen `index.ts` (face 1).** ``` HEAD blob for packages/sdui-parser/src/index.ts = 97b94de post-mutation anchor counts: narrow = 0, wide = 1 mutated blob = 2716c592aa4aa85dc90b9533dfa3ddd945b303c4 (differs: not a no-op) MUTATED tsc EXIT=1 src/__tests__/binding-field-retired.test.ts(45,7): error TS2578: Unused '@ts-expect-error' directive. restored blob = 97b94de (equal to HEAD) git diff HEAD -- that path: empty RESTORED tsc EXIT=0 ``` Three faces, three directives, each firing only when its own declaration is widened. The pin is not one blanket assertion that would survive two of the three being reverted. ## Changeset `minor`, declared breaking for TypeScript consumers, compile-time only, with an ADR-0087 disposition of `not-required (no-migration-prescription)`: `binding` is not a spec key, has no Zod schema and no stored `sys_metadata` representation, so `objectstack migrate meta` has nothing to reach. There is no rewrite to prescribe, and the changeset argues that absence from the measurements rather than omitting it. ## Acceptance notes - **Boundary crossing, declared.** The dispatch scoped this card's file face to `packages/sdui-parser/`. The changeset lives at `.changeset/16583-sdui-parser-binding-field-arm-retired.md`, outside it. It is a mandatory companion for a published package, not scope creep; flagging it rather than landing it silently. - Noted, not filed — `objectui-lockstep.json` records objectui at `53ded82bf7a4` (2026-09-05), which predates both halves of the objectui retirement. The record is refreshed by `pnpm gen:sdui-lockstep` at pin-bump time and this change does not move the pin, so nothing here is stale in a way a gate can see. Carrier: whoever next bumps `.objectui-sha`. - Noted, not filed — ADR-0080's status line still describes the parser as consumed only by `@objectstack/lint`, and the save-time compile-to-`compiledTree` pipeline plus registry-manifest binding validation as not wired. Both readings are still accurate at this head; recording it because it is the reason the `binding` vocabulary has no production writer on either arm. Carrier: none today. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 777d0c2 commit 2e0401a

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)