From 6c6e5ddb690687aa8fc14d018dec5f2f5dfd26f4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:17:25 +0800 Subject: [PATCH] =?UTF-8?q?docs(adr):=20ADR-0080=20=E2=80=94=20AI-authored?= =?UTF-8?q?=20UI=20pages=20via=20JSX=20source=20compiled=20to=20the=20SDUI?= =?UTF-8?q?=20tree=20(parse=20!=3D=20execute);=20registry=20is=20the=20con?= =?UTF-8?q?tract;=20capability=20!=3D=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/adr/0080-ai-authored-ui-jsx-source.md | 146 +++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/adr/0080-ai-authored-ui-jsx-source.md diff --git a/docs/adr/0080-ai-authored-ui-jsx-source.md b/docs/adr/0080-ai-authored-ui-jsx-source.md new file mode 100644 index 0000000000..8f4b2be214 --- /dev/null +++ b/docs/adr/0080-ai-authored-ui-jsx-source.md @@ -0,0 +1,146 @@ +# ADR-0080: AI-authored UI pages — JSX source compiled to the SDUI tree (parse ≠ execute); the component registry is the contract; capability ≠ contract + +**Status**: Proposed (2026-06-29) +**Deciders**: ObjectStack Protocol Architects +**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) +**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) + +**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*. + +> **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). + +--- + +## TL;DR + +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.) +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. +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.) +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. +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. +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. + +--- + +## Context + +### What the renderer already is (and already has) + +The SDUI runtime is **not** the thing that needs to change: + +| Capability | Where it already lives | +|---|---| +| Recursive tree interpreter (`type` → component, children recurse) | `@object-ui/react` `SchemaRenderer` → `ComponentRegistry.get()` → `React.createElement` | +| Recursive node schema (`{ type, props, children }`, `className`/`style`, `visibleOn`) | `@object-ui/types` `BaseSchema` / discriminated `LayoutSchema` (`packages/types/src/layout.ts:639`, literal `type` discriminants) | +| Per-component prop declaration | `ComponentInput[]` on `register()` (`packages/core/src/registry/Registry.ts:13`) | +| 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`) | +| Enumerable catalog across ~40 plugins | `ComponentRegistry.getAllConfigs()` / `getAllTypes()` | +| An HTML escape hatch (today unsafe) | `renderers/basic/html.tsx` — raw `dangerouslySetInnerHTML`, no sanitize | + +The gap is the **authoring format** and the **contract projection**, not the renderer. + +### The three richer surfaces, and why each fails alone + +| Surface | Fails because | +|---|---| +| **Extend the slotted page schema** | more fixed slots; AI's freedom is bounded by the schema shape — never enough | +| **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) | +| **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 | + +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. + +### Why the JSON tree is the wrong *edit* surface (even though it is the right *render* target) + +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. + +### Why under AI authoring the contract must be *loud and projectable* (ADR-0077/0078 lineage) + +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. + +--- + +## Decision + +### 1. AI authors JSX text; the tree is derived + +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. `` ⇒ `{type:'flex',gap:4,children:[{type:'object-table',object:'account'}]}` — tag → `type`, attrs → props, nesting → `children`. + +### 2. parse ≠ execute — the safety model + +| Same input syntax | React source | This ADR (JSX-source page) | +|---|---|---| +| How it is consumed | `import` / bundle / `eval` → **runs JS** | `parse` → AST → walk → map tags to registry → **runs no JS** | +| Trust | untrusted code in host context | sanitized data; whitelist = registry `type` set | +| Build/deploy | required | none | +| Multi-tenant | per-tenant build artifact | per-tenant **data** row | + +The parser whitelists tags to the registry type set; strips `