|
| 1 | +# ADR-0080: AI-authored UI pages — JSX source compiled to the SDUI tree (parse ≠ execute); the component registry is the contract; capability ≠ contract |
| 2 | + |
| 3 | +**Status**: Proposed (2026-06-29) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI writes metadata via draft-gated `saveMetaItem`), [ADR-0038](./0038-build-verification-loop.md) (agent builds → verifies → self-corrects), [ADR-0046](./0046-package-docs-as-metadata.md) (generated, not hand-maintained, surfaces), [ADR-0048](./0048-cross-package-metadata-collision.md) (package id is the addressing unit; `requires`), [ADR-0058](./0058-expression-and-predicate-surface.md) (one expression language, two backends), [ADR-0063](./0063-two-kernel-agents-skills-are-the-extension-primitive.md) (build agent owns metadata authoring), [ADR-0064](./0064-tool-scoping-to-agent.md) (tool scope by surface), [ADR-0065](./0065-sdui-styling-model.md) (scoped styles — the conflict-free styling channel), [ADR-0067](./0067-commit-history-and-rollback-for-ai-authoring.md) (versioned AI-authored metadata), [ADR-0077](./0077-authoring-surface-boundary-hook-flow-validation.md) (route by intent; verifiability tier = friction tier; loud-not-silent; code is the flagged escape hatch), [ADR-0078](./0078-no-silently-inert-metadata.md) (the completeness gate — valid-but-inert fails loudly) |
| 6 | +**Consumers**: `@objectstack/spec` (`packages/spec/src/ui/page.zod.ts` — gains a JSX-source page kind + the tree *envelope*; **does not** gain per-component prop schemas), `@objectstack/cli` (`os build` / `os validate` — the save-time parse + validate gate), a **new shared `@objectstack/sdui-parser`** (isomorphic JSX-text → SchemaNode-tree compiler, run server-side authoritative and client-side for preview), `../objectui` `@object-ui/core` (the `ComponentRegistry` `inputs` become the authoritative contract; `getAllConfigs()` is serialized to a manifest) and `@object-ui/react` (`SchemaRenderer` renders the compiled tree, unchanged), the `objectstack-ui` skill (the routing + the curated public block list the build agent composes from) |
| 7 | + |
| 8 | +**Premise**: the current SDUI page metadata is a fixed-shape, slotted schema. To let AI customize pages with real layout freedom we need a richer authoring surface — but the three obvious richer surfaces each fail a hard constraint, and the failure modes are exactly the ones ADR-0077/0078 warn about (ambiguous surface + silent wrongness). This ADR pins the surface, the contract, and where compilation runs, **before** the AI authors a large body of pages against an ambiguous one. It adds almost no runtime to the renderer: it adds a *source format* and a *compiler in front of the existing pipeline*. |
| 9 | + |
| 10 | +> **Trigger**: a design review of "the page metadata can't express AI-flexible layouts; HTML can't reach our component library; React needs a build/deploy and is unsafe to execute." The investigation found that the renderer is **already** a recursive tree interpreter over a registered component set (`@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get(type)` → `React.createElement`, children recurse), and that the registry **already declares component props** at registration (`ComponentInput` in `packages/core/src/registry/Registry.ts:13`). So the missing piece is not the runtime — it is the *authoring format* (AI is fluent in JSX/Tailwind, not in hand-written JSON trees) and the *contract projection* that makes AI output checkable. The wrong fix — store the JSON tree as the thing AI edits, or lift every component's props into the framework Zod spec — was considered and rejected (see Decision §3, §5). |
| 11 | +
|
| 12 | +--- |
| 13 | + |
| 14 | +## TL;DR |
| 15 | + |
| 16 | +1. **[model] AI authors a constrained *JSX text*, not a JSON tree.** The JSON `SchemaNode` tree is a fine *compile target* and a terrible *edit surface* — verbose, out-of-distribution for the model, noisy to diff, and it loses manual tweaks on every regeneration. AI reads/edits JSX+Tailwind (its strength); the tree is **derived**, never hand-edited. (This is how v0/Markdoc/MDX keep source = code, output = compiled.) |
| 17 | +2. **[ruled] parse ≠ execute (the Markdoc model).** The JSX text is **parsed** to an AST and **interpreted** by mapping tags to registered components — no JS is ever run. This is categorically different from real React (`import`/bundle/eval): it keeps multi-tenant-as-data, needs no build/deploy, and never executes untrusted code. A stored string that is *parsed* is still data; a stored module that is *executed* is not. Looks like React, is not React. |
| 18 | +3. **[ruled] The component registry is the authoritative contract — do not duplicate it into the framework Zod spec.** `ComponentInput` already carries `name / type / required / enum / description / defaultValue / isContainer / slot`. That is enough to (a) codegen the JSX type surface (`.d.ts` → `JSX.IntrinsicElements`) and (b) validate AI output at the level that catches the real errors (unknown component, unknown/missing prop, wrong coarse type, illegal enum value). The framework spec owns only the **tree envelope** (page/region/children recursion) and **consumes** a serialized manifest of the registry — it must **not** re-declare per-component props. (`page.zod.ts:85` is today `properties: z.record(z.unknown())` — ungated, the same `z.record` escape as the flow-node-config debt; the fix is the registry manifest, not a parallel hand-authored Zod copy.) |
| 19 | +4. **[model] capability ≠ contract.** All ~244 registered types remain a rendering *capability*. Only a **curated public tier (~35 blocks)** — shaped like Salesforce's App Builder standard components, object-centric, generated from a `tier:'public'` flag — is what gets type-checking, the api-surface ratchet, customer docs, and the AI vocabulary. Do not freeze 244 internal/admin/studio components into a versioned contract. |
| 20 | +5. **[ruled] Authoritative parse + sanitize + validate + compile runs *server-side at save*; the client renders the *compiled tree*, not the JSX.** Sanitization cannot be client-trusted; binding-correctness needs server-only object schemas; the completeness gate (ADR-0078) and draft-gating (ADR-0033) are already server-side. Store `{ source, compiledTree, requires }` — **source is truth, tree is derived cache.** The client ships **no parser on the render path** (web/mobile render the same pre-validated tree); a client-side copy of the shared parser exists **only** for the live edit/preview loop and is re-validated server-side at save — it is never the trust boundary. |
| 21 | +6. **[staging] Two coupled deliverables, neither blocking the other.** (a) *Complete the registry `inputs`* for the ~35 public blocks (closes existing designer-contract gaps too); (b) *add a JSX-source page kind* (additive — the existing tree page stays; designer round-trip to JSX is deferred). They meet through a codegen'd `.d.ts` (author-time type-check) and a serialized manifest (save-time validate). v1 ships with shallow inputs-validation; depth (nested shapes, binding-against-object-schema) is added incrementally on the public tier only. |
| 22 | + |
| 23 | +--- |
| 24 | + |
| 25 | +## Context |
| 26 | + |
| 27 | +### What the renderer already is (and already has) |
| 28 | + |
| 29 | +The SDUI runtime is **not** the thing that needs to change: |
| 30 | + |
| 31 | +| Capability | Where it already lives | |
| 32 | +|---|---| |
| 33 | +| Recursive tree interpreter (`type` → component, children recurse) | `@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get()` → `React.createElement` | |
| 34 | +| Recursive node schema (`{ type, props, children }`, `className`/`style`, `visibleOn`) | `@object-ui/types` `BaseSchema` / discriminated `LayoutSchema` (`packages/types/src/layout.ts:639`, literal `type` discriminants) | |
| 35 | +| Per-component prop declaration | `ComponentInput[]` on `register()` (`packages/core/src/registry/Registry.ts:13`) | |
| 36 | +| Conflict-free styling channels | typed primitive props → safelisted class map (`ResponsiveGrid.tsx`), and **scoped per-node CSS** (ADR-0065, `packages/core/src/styling/scoped-styles.ts`) | |
| 37 | +| Enumerable catalog across ~40 plugins | `ComponentRegistry.getAllConfigs()` / `getAllTypes()` | |
| 38 | +| An HTML escape hatch (today unsafe) | `renderers/basic/html.tsx` — raw `dangerouslySetInnerHTML`, no sanitize | |
| 39 | + |
| 40 | +The gap is the **authoring format** and the **contract projection**, not the renderer. |
| 41 | + |
| 42 | +### The three richer surfaces, and why each fails alone |
| 43 | + |
| 44 | +| Surface | Fails because | |
| 45 | +|---|---| |
| 46 | +| **Extend the slotted page schema** | more fixed slots; AI's freedom is bounded by the schema shape — never enough | |
| 47 | +| **Raw HTML + Tailwind** | loses the design system, data binding, a11y, theming, i18n; XSS surface; **cannot compose registered controls**; and arbitrary runtime Tailwind classes are purged at build time → silent no-style (ADR-0065 territory) | |
| 48 | +| **Real React/JSX (import + bundle/eval)** | needs a per-page build+deploy (per-tenant builds), or runtime `eval` of untrusted JS in the host context (XSS-equivalent, host-global access, version skew). Sandboxing it = iframe = the Tier-C escape hatch, not the default | |
| 49 | + |
| 50 | +The synthesis: **AI authors the format it is strongest in (JSX+Tailwind), but it is *parsed*, not *executed*, and lands in the existing tree** — keeping every property the React-source path would forfeit. |
| 51 | + |
| 52 | +### Why the JSON tree is the wrong *edit* surface (even though it is the right *render* target) |
| 53 | + |
| 54 | +Handing the model a verbose `{type,props,children}` tree to iteratively edit forces read-whole-tree / locate-node / rewrite-whole-tree: it burns tokens, regenerates (losing manual tweaks), and reasons spatially in an unnatural form. v0/Lovable keep **code** as the source-of-truth for exactly this reason. Source = JSX text; tree = compiled. |
| 55 | + |
| 56 | +### Why under AI authoring the contract must be *loud and projectable* (ADR-0077/0078 lineage) |
| 57 | + |
| 58 | +AI rarely emits non-compiling output; it emits output that is **subtly wrong** — a prop that does not exist, an `objectName` that is not a real object, a node that is structurally valid but **inert** (ADR-0078). The defenses are the same three ADR-0077 names: shrink the expressible-but-wrong space (curated tier, §4), make every real constraint loud at author time (codegen'd type-check + the completeness gate, §6), and prefer machine-checkable intent (registry-typed props, §3). A pile of AI-written raw HTML is unanalyzable; a tree of contract-typed nodes is queryable, diffable, gateable. |
| 59 | + |
| 60 | +--- |
| 61 | + |
| 62 | +## Decision |
| 63 | + |
| 64 | +### 1. AI authors JSX text; the tree is derived |
| 65 | + |
| 66 | +The page's source-of-truth is a constrained JSX/HTML+Tailwind **text** field. The `SchemaNode` tree is the compiler output, not persisted as the editable artifact. `<flex gap={4}><object-table object="account"/></flex>` ⇒ `{type:'flex',gap:4,children:[{type:'object-table',object:'account'}]}` — tag → `type`, attrs → props, nesting → `children`. |
| 67 | + |
| 68 | +### 2. parse ≠ execute — the safety model |
| 69 | + |
| 70 | +| Same input syntax | React source | This ADR (JSX-source page) | |
| 71 | +|---|---|---| |
| 72 | +| How it is consumed | `import` / bundle / `eval` → **runs JS** | `parse` → AST → walk → map tags to registry → **runs no JS** | |
| 73 | +| Trust | untrusted code in host context | sanitized data; whitelist = registry `type` set | |
| 74 | +| Build/deploy | required | none | |
| 75 | +| Multi-tenant | per-tenant build artifact | per-tenant **data** row | |
| 76 | + |
| 77 | +The parser whitelists tags to the registry type set; strips `<script>`, `dangerouslySetInnerHTML`, event handlers (except bound named actions), and arbitrary JS expressions (expressions stay in the constrained language — ADR-0058 CEL — typed but not executed). This **replaces** today's unsafe `html.tsx` escape hatch with a sanitizing, component-aware interpreter. |
| 78 | + |
| 79 | +### 3. The registry is the contract — the spec owns only the envelope |
| 80 | + |
| 81 | +- **`ComponentInput` is the authoritative per-component contract.** Serialize `getAllConfigs()` (the public tier) to a static **manifest** at build. From it: codegen `.d.ts`/`JSX.IntrinsicElements` (author-time type-check) and feed the save-time validator. |
| 82 | +- **Do not lift per-component props into `@objectstack/spec`.** A parallel hand-authored Zod copy is a second source of truth that will drift (the same class of debt as the `${}`-vs-CEL split and the `z.record` flow-node-config escape). `page.zod.ts` changes from `properties: z.record(z.unknown())` to: own the **tree envelope** (recursion, region rules, the JSX-source field) and **reference** the manifest for prop validation. |
| 83 | +- **The object-binding check lives framework-side regardless** — only the framework knows whether `object="account"`/`field="revenue"` resolve. This is the highest-value check and is inherently server-side. |
| 84 | + |
| 85 | +For **core** components with literal-discriminant schemas (`LayoutSchema`, `layout.ts:639`), the `JSX.IntrinsicElements` table can be **derived by a TS mapped type** with zero codegen (`{ [S in Concrete as S['type']]: Omit<S,'type'> }`); **exclude `BaseSchema`** (its `type: string` would emit an index signature that makes every tag valid). Plugin components codegen into the **same** intrinsic surface from the manifest. Tag name === `type` discriminant === registry key. |
| 86 | + |
| 87 | +### 4. capability ≠ contract — the curated public tier |
| 88 | + |
| 89 | +Only the curated set is contract-bearing (type-checked, ratcheted, documented, AI vocabulary). Shape = Salesforce App Builder standard components (small, object-centric). The list is **generated from a `tier:'public'` flag** on registration — not frozen in this ADR (ADR-0046). |
| 90 | + |
| 91 | +| Tier | ~count | Examples (registry `type`) | |
| 92 | +|---|---|---| |
| 93 | +| **A — object-aware blocks** (the contract core; deep validation + binding-check + ratchet focus here) | ~16 | `object-grid`/`list-view`, `object-form`, `record:details`/`highlights`/`related_list`/`path`/`line_items`, `object-kanban`/`calendar`/`gantt`/`timeline`/`map`, `object-metric`, `object-chart`, `dashboard`/`object-pivot` | |
| 94 | +| **B — layout/content primitives** | ~16 | `flex`,`grid`,`stack`,`card`,`tabs`,`accordion`,`section`,`page:header` + `text`,`heading`,`image`,`icon`,`markdown`,`divider`,`badge`,`alert`,`button` | |
| 95 | +| **C — escape hatch** (flagged, second-class — the ADR-0077 "code is the escape hatch" idiom) | ~3 | sanitized rich-text, custom component, embedded flow | |
| 96 | + |
| 97 | +Curation rules: **collapse variant families** (one `Chart` block + `chartType` enum, not 14 sibling chart blocks); **drop unused/inert catalog types** from the public tier (`tree`, `modal` form, `gauge`/`bullet`, `kpi`); the platform's **differentiators over Salesforce** (native Kanban/Gantt/Calendar/Map/Metric/Pivot) sit in Tier A as first-class. |
| 98 | + |
| 99 | +### 5. Where compilation runs |
| 100 | + |
| 101 | +| Operation | Location | Why | |
| 102 | +|---|---|---| |
| 103 | +| Authoritative parse + sanitize + prop-validate + completeness + binding-check + compile | **server, at save** (`os build`/save gate) | sanitization can't be client-trusted; binding needs server-only object schemas; gate + draft are already server-side; parse once not per-load | |
| 104 | +| Author-time type-check (`.d.ts`/tsc over generated decls) | **server / build-agent loop** (ADR-0063/0064) | browser can't run tsc over the manifest efficiently; the loop is server-side | |
| 105 | +| Render | **client**, of the **compiled tree** | no parser on the render path; web/mobile render the same pre-validated tree | |
| 106 | +| Live edit preview | **client**, shared `@objectstack/sdui-parser` | UX convenience; **re-validated server-side at save** — not the trust boundary | |
| 107 | + |
| 108 | +Stored shape: `{ source: "<jsx>", compiledTree: {…}, requires: ["plugin-grid", …] }`. **Source wins; tree is a derived cache** (recompile on mismatch; may invalidate by source hash). `requires` is inferred at parse and validated at save **and** load (plugin presence — ADR-0048 package provenance). |
| 109 | + |
| 110 | +### 6. The two coupled deliverables |
| 111 | + |
| 112 | +1. **Complete the registry `inputs`** for the ~35 public blocks: ① fill coverage (closes existing designer-contract gaps); ② for Tier A, enrich `object`/`array` inputs with item/field shape; ③ mark binding inputs (`objectName`/`field`) so the server binding-check knows what to resolve. One contract feeds three consumers: designer property panel, AI `.d.ts`/vocabulary, save-time validation. |
| 113 | +2. **Add a JSX-source page kind** (additive to the tree page): source field + the `@objectstack/sdui-parser` pipeline (parse → whitelist → prop-validate → completeness → `requires`) → existing `SchemaNode` tree → existing `SchemaRenderer`. |
| 114 | + |
| 115 | +Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialized manifest (save-time). Neither blocks the other; v1 = JSX page kind on **shallow** inputs-validation, depth added incrementally on the public tier. |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +## Non-goals |
| 120 | + |
| 121 | +- **Do not store the JSON tree as the AI-edited artifact** (§1) — source is JSX text. |
| 122 | +- **Do not allow real `import` / module resolution / bundling / `eval`** (§2). If import ergonomics are wanted later, only a **virtual, sealed** specifier resolved against the registry — never the filesystem/npm. |
| 123 | +- **Do not re-declare component props in `@objectstack/spec`** (§3) — the registry manifest is the single source. |
| 124 | +- **Do not promote all ~244 types into the contract / AI vocabulary** (§4). |
| 125 | +- **Do not parse-and-sanitize only on the client** (§5) — the client parser is preview-only. |
| 126 | +- **Do not block the JSX page kind on 100% `inputs` completion or on designer↔JSX round-trip** (§6) — both are deferred/incremental. |
| 127 | + |
| 128 | +## Staging |
| 129 | + |
| 130 | +- **Now (v1):** the shared parser (parse → whitelist → tree → render) proven end-to-end on 3 blocks (`object-grid`/`flex`/`object-form`); codegen `.d.ts` from `getAllConfigs()`; JSX-source page kind with shallow save-validation; `tier:'public'` flag + generated block list. |
| 131 | +- **Next:** complete Tier A `inputs` + enrich depth; server binding-against-object-schema check; completeness-gate (ADR-0078) extension to UI nodes; api-surface ratchet on the public tier. |
| 132 | +- **Deferred, gated on proven need (ADR-0077 idiom):** designer↔JSX round-trip; virtual sealed imports; the Tier-C custom-component sandbox (iframe + bridge). |
| 133 | + |
| 134 | +## Consequences & risks |
| 135 | + |
| 136 | +- **Two parsers, one grammar.** Server (authoritative) and client (preview) must agree → one isomorphic package; the client never decides trust. |
| 137 | +- **`inputs` were built for the designer**, so some are incomplete (the known renderer-registry gaps). Relying on them for validation forces an audit — but that audit is independently valuable (better designer) and lands on one shared contract. |
| 138 | +- **JSX children typing is weak** in TS (it special-cases `children`). Container/child legality (e.g. "only columns nest in a table") is enforced **structurally by the parser + manifest**, not by TS children types. |
| 139 | +- **The `.d.ts`/`JSX.IntrinsicElements` augmentation is a type-checking fiction** — no real React intrinsic named `flex` runs; the interpreter fulfills it via the registry. The temptation it invites ("it type-checks like JSX, so just let React render it") is exactly what §2 forbids. |
| 140 | +- **Per-deployment variability**: the valid block set is per-installed-plugins; the manifest and `requires` validation are deployment-scoped (a page referencing an absent plugin's block fails the gate, not the render — loud-not-silent). |
| 141 | + |
| 142 | +## Open questions |
| 143 | + |
| 144 | +1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)? |
| 145 | +2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred? |
| 146 | +3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache? |
0 commit comments