diff --git a/.changeset/sdui-parser-inert-expression-lockstep.md b/.changeset/sdui-parser-inert-expression-lockstep.md
new file mode 100644
index 0000000000..597eb2fcd1
--- /dev/null
+++ b/.changeset/sdui-parser-inert-expression-lockstep.md
@@ -0,0 +1,37 @@
+---
+'@objectstack/sdui-parser': minor
+---
+
+html tier: a braced attribute value that is not strict JSON now draws an `inert-expression` warning instead of vanishing silently
+
+`interpretBrace` materializes strict-JSON values only; anything else — the
+single-quoted array every JSX author writes (`columns={['name','amount']}`),
+unquoted object keys, any JS expression — compiles to the deferred `{ $expr }`
+marker, and nothing downstream evaluates that marker: this tier parses, never
+executes (ADR-0080), and no renderer consumes `$expr`. The value reached the
+renderer as an opaque object, defensive non-array/non-object reads degraded it
+to "not declared", and the author's binding vanished with zero diagnostics
+anywhere — a production page's `list-view` rendered its row count and toolbar
+with no data columns, through eight `columns` spellings (objectui#6598). That
+is ADR-0078's prohibited parsed-but-silently-inert state.
+
+`validateTree` now emits a warning-severity `inert-expression` diagnostic when a
+declared input's value is the `$expr` marker, with the fix in the message: write
+the value as JSON (double-quoted strings and keys).
+
+This is the lockstep port of objectui PR #6613 into this repo's hoisted copy of
+the parser. There are two copies, and the invariant is that both agree on the
+accepted grammar **and** on diagnostic codes — if they drift, the save gate and
+the renderer speak different dialects, and a page can save clean and render
+inert. The emitted diagnostic is byte-equal to objectui's.
+
+Warning, not error, per the objectui#5709 posture for inert authored keys: this
+reports an **already**-inert state, so the accept/reject set does not move.
+Pages that compiled before still compile, and a warning is non-gating on every
+consuming surface in this repo (`runtime-gate` files warnings as advisories, not
+as write refusals; `os lint` exits non-zero on error-severity findings only).
+The silence is what changed. Escalating the severity, widening the accepted
+literal grammar (single-quoted strings, unquoted keys), and wiring the registry
+manifest into `validate-jsx-pages` — without which this warning is recorded in
+compile output but displayed by no production surface — are separate decisions
+tracked on objectui#6614 and its follow-ups.
diff --git a/packages/sdui-parser/src/__tests__/inert-expression.test.ts b/packages/sdui-parser/src/__tests__/inert-expression.test.ts
new file mode 100644
index 0000000000..b55b1b913d
--- /dev/null
+++ b/packages/sdui-parser/src/__tests__/inert-expression.test.ts
@@ -0,0 +1,132 @@
+/**
+ * `inert-expression` — the html tier's silent-vanish hole for braced non-JSON
+ * values, ported into this copy in lockstep with objectui PR #6613.
+ *
+ * `interpretBrace` materializes strict-JSON values only; anything else — the
+ * single-quoted array every JSX author writes, unquoted object keys, any JS
+ * expression — becomes the deferred `{ $expr }` marker, and NOTHING downstream
+ * evaluates that marker (this tier parses, never executes — ADR-0080; no
+ * renderer consumes `$expr`). So `columns={['name','amount']}` used to compile
+ * with ZERO diagnostics into a value every renderer's defensive non-array read
+ * degrades to "no columns declared": rows render, the author's whole data
+ * binding is eaten, and no surface ever says why. That is ADR-0078's prohibited
+ * parsed-but-silently-inert state, reported from production as objectui#6598.
+ *
+ * WHY THIS FILE EXISTS HERE AND NOT ONLY THERE. There are two copies of this
+ * parser — objectui's `packages/sdui-parser` and this repo's hoisted
+ * `@objectstack/sdui-parser` — and the invariant is that both copies agree on
+ * the accepted grammar AND on diagnostic codes. If they drift, the save gate
+ * and the renderer speak different dialects: a page can save clean and render
+ * inert, or the reverse — surface-dependent, and therefore intermittent from
+ * the author's point of view. These pins are the objectstack half of that
+ * lockstep; the emitted diagnostic is byte-equal to objectui's.
+ *
+ * Severity is pinned as WARNING deliberately (the objectui#5709 precedent for
+ * inert authored keys), and `ok` is pinned true alongside it: this port reports
+ * an ALREADY-inert state, so it must leave the accept/reject set exactly where
+ * it stood. Escalating to error, widening the accepted literal grammar (single
+ * quotes / unquoted keys — objectui#6614), and base-prop (`style`) coverage are
+ * open contract decisions; a change to any of those should move these pins
+ * consciously, not by accident.
+ */
+import { describe, expect, it } from 'vitest';
+import { compile } from '../index.js';
+import type { Manifest } from '../types.js';
+
+const manifest: Manifest = {
+ components: {
+ 'list-view': {
+ type: 'list-view',
+ namespace: 'plugin-list',
+ inputs: [
+ { name: 'objectName', type: 'string', required: true },
+ { name: 'columns', type: 'array' },
+ { name: 'options', type: 'object' },
+ ],
+ },
+ },
+};
+
+describe('inert-expression: braced non-JSON on a declared input warns instead of vanishing', () => {
+ it('single-quoted array — the JSX habit — draws the warning and stays in the tree as $expr', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics).toEqual([
+ expect.objectContaining({
+ severity: 'warning',
+ code: 'inert-expression',
+ tag: 'list-view',
+ message: expect.stringContaining('"columns"'),
+ }),
+ ]);
+ // The marker itself is unchanged — the tree still carries the deferred
+ // value; only the silence is gone.
+ expect(r.tree?.columns).toEqual({ $expr: "['name','amount']" });
+ });
+
+ it('the message carries the FIX, not merely the complaint', () => {
+ // An arrival pin, not a departure pin: "stopped being silent" is satisfied
+ // by any diagnostic at all. What this port owes the author is the remedy —
+ // name JSON, and show the corrected spelling next to the broken one. A
+ // message rewrite that drops the remedy turns this red.
+ const [d] = compile(
+ ``,
+ manifest,
+ ).diagnostics;
+ expect(d.message).toMatch(/JSON/);
+ expect(d.message).toContain('double-quoted strings and keys');
+ expect(d.message).toContain('columns={["name","amount"]}');
+ expect(d.message).toContain(`columns={['name','amount']}`);
+ });
+
+ it('unquoted object keys draw the same warning', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics).toEqual([
+ expect.objectContaining({ severity: 'warning', code: 'inert-expression' }),
+ ]);
+ });
+
+ it('an $expr on an object-typed input is covered too', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics).toEqual([
+ expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }),
+ ]);
+ });
+
+ it('strict-JSON spellings stay diagnostic-free — the warning cannot fire on a working page', () => {
+ // This half is what stops the port from becoming a grammar change by
+ // accident: everything `interpretBrace` materializes must stay silent.
+ for (const source of [
+ ``,
+ ``,
+ ``,
+ ``,
+ ]) {
+ const r = compile(source, manifest);
+ expect(r.diagnostics).toEqual([]);
+ expect(r.ok).toBe(true);
+ }
+ });
+
+ it('the accept/reject set does not move — every inert spelling still compiles', () => {
+ // The load-bearing property of this port: it reports an ALREADY-inert
+ // state, so `ok` (no error-severity diagnostic — the save gate's pass/fail)
+ // is exactly what it was before the diagnostic existed. Escalating the
+ // severity to error is objectui#6614's Q2 and would land here first.
+ for (const source of [
+ ``,
+ ``,
+ ``,
+ ]) {
+ const r = compile(source, manifest);
+ expect(r.ok).toBe(true);
+ expect(r.diagnostics.every((d) => d.severity === 'warning')).toBe(true);
+ }
+ });
+
+ it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics).toEqual([
+ expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }),
+ ]);
+ });
+});
diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts
index c220cb8777..fd17e94633 100644
--- a/packages/sdui-parser/src/validate.ts
+++ b/packages/sdui-parser/src/validate.ts
@@ -79,7 +79,38 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va
if (input.binding) {
bindings.push({ tag: node.type, input: key, kind: input.binding, value });
}
- if (!isExpr(value)) {
+ if (isExpr(value)) {
+ // A braced value that failed JSON materialization compiled to the
+ // parser's deferred `{ $expr }` marker — and NOTHING downstream
+ // evaluates that marker: this tier parses, never executes
+ // (ADR-0080), and no renderer consumes `$expr`. The value therefore
+ // reaches the renderer as an opaque object, every defensive
+ // non-array/non-object read degrades it to "not declared", and the
+ // author's binding silently vanishes (objectui#6598: eight `columns`
+ // spellings on a data block, all eaten without a single diagnostic —
+ // rows rendered, zero data columns). ADR-0078 prohibits exactly this
+ // parsed-but-silently-inert state, so name it at compile time, with
+ // the fix in the message. Warning, not error, per the objectui#5709
+ // precedent for inert authored keys — escalation to error (and any
+ // widening of the accepted literal grammar, e.g. single-quoted
+ // strings) is a contract decision tracked on objectui#6598.
+ //
+ // LOCKSTEP: this diagnostic is the byte-equal port of objectui's
+ // `packages/sdui-parser` copy (objectui PR #6613). The two copies
+ // must agree on the accepted grammar AND on diagnostic codes — if
+ // they drift, the save gate and the renderer speak different
+ // dialects and a page can save clean and render inert. Change this
+ // block only together with the objectui copy.
+ diagnostics.push({
+ severity: 'warning',
+ code: 'inert-expression',
+ message:
+ `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` +
+ `the value will be silently ignored at render. Write it as JSON ` +
+ `(double-quoted strings and keys), e.g. columns={["name","amount"]} not columns={['name','amount']}`,
+ tag: node.type,
+ });
+ } else {
const typeDiag = checkType(node.type, input, value);
if (typeDiag) diagnostics.push(typeDiag);
}