Skip to content

Commit 6b366e0

Browse files
os-zhuangclaude
andcommitted
feat(spec,lint): ADR-0081 — kind:'html' (rename) + trusted kind:'react' tier
- spec: PageSchema.kind gains 'html' (the renamed constrained tier; 'jsx' kept as a deprecated alias) and 'react' (trusted real-React, executed at render, host-capability gated). Completeness gate requires source for all three. - lint: validate-jsx-pages lints html/jsx; skips react (real JS, not constrained JSX). +tests. - examples: command-center page moves to kind:'html'. - docs: ADR-0081. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1c11473 commit 6b366e0

6 files changed

Lines changed: 117 additions & 15 deletions

File tree

.changeset/sdui-react-page-tier.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
ADR-0081: split the AI page-authoring surface into honest tiers.
7+
8+
- `PageSchema.kind` gains `'html'` and `'react'`. `'html'` is the constrained
9+
parse-never-execute tier (the renamed `'jsx'`, kept as a deprecated alias);
10+
`'react'` is the trusted real-React tier (executed at render by
11+
`@object-ui/react-runtime`, gated behind a host capability, enterprise/private
12+
only). The completeness gate now requires `source` for all three.
13+
- `validate-jsx-pages` lints `html`/`jsx` (constrained parse) and intentionally
14+
skips `react` (real JS, not constrained JSX).
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# ADR-0081: A trusted `kind:'react'` page tier — real React executed in the main tree, gated by a host capability; and renaming `kind:'jsx'``kind:'html'`
2+
3+
**Status**: Proposed (2026-06-30)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0080](./0080-ai-authored-ui-jsx-source.md) (AI authors a *constrained* JSX text, parsed-never-executed, into the SDUI tree; the component registry is the contract; capability ≠ contract), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI writes metadata via draft-gated `saveMetaItem` — the human-review boundary), [ADR-0077](./0077-authoring-surface-boundary-hook-flow-validation.md) (route by intent; verifiability tier = friction tier; **code is the flagged escape hatch**), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata).
6+
**Consumers**: `@objectstack/spec` (`packages/spec/src/ui/page.zod.ts``kind` gains `'html'` and `'react'`; `'jsx'` kept as a deprecated alias), `@objectstack/lint` (`validate-jsx-pages` — lints `html`/`jsx`, **skips** `react`), `../objectui` `@object-ui/react-runtime` (**new** — the trusted runtime-React executor) + `@object-ui/core` (the host capability gate) + `@object-ui/components` (PageRenderer routing + the full HTML tag set).
7+
8+
**Premise**: ADR-0080 shipped `kind:'jsx'` — a *constrained* JSX dialect parsed (never executed) into the SDUI tree. In practice that name oversold it: authors expected "JSX" to mean *any* HTML and *any* JavaScript (`useState`, `.map`, `onClick`), and the constrained, no-execution model categorically cannot do that. Two distinct things were conflated under one name. This ADR splits them: it makes the safe constrained tier **live up to a narrower, honest name (`html`)**, and adds a separate, explicitly-trusted **`react`** tier for the full-power case — available only where the operator accepts running author code.
9+
10+
> **Trigger**: testing the merged `kind:'jsx'` feature, the author found it "名不副实" (misnamed) — basic HTML tags didn't render and scripts were impossible — and recognized that the real-React capability belongs behind a switch, enabled only in a trusted private/enterprise deployment where "running unsandboxed author code in the main React app" is an acceptable, deliberate trust decision.
11+
12+
---
13+
14+
## TL;DR
15+
16+
1. **[rename] `kind:'jsx'``kind:'html'`.** The constrained, parse-never-execute tier (ADR-0080) is renamed to match what it is: author-written **HTML + Tailwind** (expressed as constrained JSX) compiled to the SDUI tree. `'jsx'` stays as a **deprecated alias** (already-saved pages keep loading); all authored examples/docs move to `'html'`.
17+
2. **[capability] The `html` tier now resolves the full safe native HTML tag set.** Previously only `div/span/table/code/label` + semantic sectioning tags were registered, so `<h1>/<p>/<a>/<ul>/<li>/<img>/<blockquote>…` failed as `unknown-component`. They are now registered as passthrough renderers — the tier is finally honest about "HTML".
18+
3. **[new tier] `kind:'react'` executes real React.** Its `source` is real JS/JSX (hooks, `.map`, event handlers, expressions), transpiled (Sucrase) and evaluated **in the main React tree — no sandbox** — by the new `@object-ui/react-runtime`. This is `parse = execute`: the categorical opposite of the `html` tier.
19+
4. **[security = trust, not sandbox] `react` is gated by a host capability, default OFF.** `CAP_REACT_PAGES` is flipped only by the *host process* (`enableCapability('react-pages')`), never by authored metadata. Open-source / multi-tenant builds ship it **off**; enterprise / private deployments — where authors are trusted and changes pass human review (draft-gating, ADR-0033) — opt in. A sandbox (iframe/worker) is the tool for *untrusted* execution; we deliberately choose **trust + review** instead, which keeps the author's code a first-class citizen of the app (shared React tree, real components, real data) at the cost of requiring the operator's explicit consent.
20+
5. **[runtime] Vendored, not depended.** `@object-ui/react-runtime` inlines the ~150-LOC react-runner core (MIT) so we fully own the injected **scope/imports surface** (the actual capability ceiling) and can **lazy-load** it — the transpiler ships in a separate chunk fetched only when a `react` page renders *with the capability on*.
21+
6. **[scope] Inject data blocks + React; leave layout to HTML.** The runtime scope exposes `React`, the curated **public data blocks** (`<ObjectTable>`, `<ObjectForm>`, charts, metrics — each a prop-driven wrapper over `SchemaRenderer`), a `<Block type=…/>` escape hatch, and the page `data`. Layout/structure is plain HTML + Tailwind (React's strength) — we do **not** bridge React children into schema-children renderers.
22+
7. **[lint] `react` is not linted by the constrained parser.** `validate-jsx-pages` (ADR-0080's build gate) validates `html`/`jsx` only; running the constrained JSX parser over real React source would false-error on hooks and expressions. The `react` tier's safety boundary is the **capability + human review**, not a static gate.
23+
24+
---
25+
26+
## The three tiers
27+
28+
| | `kind:'html'` (was `jsx`) | `kind:'react'` (new) |
29+
|---|---|---|
30+
| Source | constrained JSX = HTML + Tailwind + registered components | real React/JSX: hooks, `.map`, `onClick`, expressions |
31+
| Processing | **parsed**, never executed → SDUI tree | transpiled + **executed** in the main React tree |
32+
| JavaScript | none (static literals) | full |
33+
| Safety model | safe by construction (no execution) | **trust** (capability + review); no sandbox |
34+
| Where it runs | OSS default ON | enterprise/private only, capability default **OFF** |
35+
| Author-time gate | `os build` constrained-parse lint | none (real JS); error boundary at render |
36+
| Contract | registry `inputs` (ADR-0080) | injected scope = the ceiling |
37+
38+
The dividing line is exactly ADR-0080 §2's "parse ≠ execute". `html` keeps the property that *a stored string that is parsed is still data*; `react` deliberately crosses it, and pays for the crossing with an explicit operator opt-in.
39+
40+
---
41+
42+
## Decision
43+
44+
### 1. Rename, with a back-compat alias
45+
`PageSchema.kind` becomes `enum(['full','slotted','html','react','jsx'])`. `'html'` is the canonical constrained tier; `'jsx'` is accepted as a silent **deprecated alias** so metadata saved during ADR-0080's window keeps rendering. The PageRenderer treats `html` and `jsx` identically; lint and the completeness gate (ADR-0078) treat both as source-required.
46+
47+
### 2. Make `html` honest
48+
Register the safe native HTML flow/inline set (`h1–h6, p, a, ul/ol/li, dl/dt/dd, blockquote, pre, strong/em/b/i/u/small/mark/sub/sup, figure/figcaption, img, hr, br, time, address, …`) as passthrough renderers that forward standard attributes and recurse children. Excluded: anything already registered, and anything that can execute or escape (`script/style/iframe/object/embed/link/meta/form/input`, plus `button` which is the component). Defense-in-depth even though this tier never executes JS: strip `on*` handlers and `dangerouslySetInnerHTML`, neutralize `javascript:`/`data:` URLs on `<a href>`. Unknown attributes are warnings (the page still renders), per the ADR-0080 validator.
49+
50+
### 3. Add `react` as a capability, not a default
51+
- **Gate**: `@object-ui/core` gains `enableCapability`/`disableCapability`/`isCapabilityEnabled` + `CAP_REACT_PAGES`. Default-closed. Host-only (a global pre-seed `globalThis.__OBJECTUI_CAPABILITIES__` is also honored for HTML-bootstrap hosts). It is structurally impossible to enable from authored metadata.
52+
- **Renderer**: PageRenderer routes `kind:'react'` to a renderer that — only when the capability is on — lazy-imports `@object-ui/react-runtime`, builds the scope, and renders via its error-boundaried `ReactRunner`. Capability off → a clear "this page requires the React-pages capability" notice, not a crash.
53+
54+
### 4. Vendor the runtime; own the scope
55+
`@object-ui/react-runtime` inlines react-runner: `transform` (Sucrase `jsx`+`typescript`+`imports`, production), `generateElement` (eval with injected scope; `createRequire` resolves `require()` only against an explicit `imports` map — a control point), and `ReactRunner` (a component with `getDerivedStateFromError`). The capability ceiling is precisely the injected scope/imports — owning that file is why we vendor rather than depend.
56+
57+
---
58+
59+
## Consequences
60+
61+
- **Honest names.** `html` does HTML; `react` does React. The conflation that made the first cut feel "鸡肋" is gone.
62+
- **Two safety stories, each clean.** `html` is safe by construction and stays the OSS/multi-tenant default. `react` is safe by *trust*, opt-in, and obviously so (you typed `enableCapability`).
63+
- **No build/deploy for either.** `html` compiles at save; `react` transpiles at render (lazy chunk). Neither needs a bundler in the author loop.
64+
- **AI fit.** AI writes both fluently. For `react`, correctness is bounded by the injected scope + human review (ADR-0033), not by hoping the model never writes a bad effect.
65+
- **Cost.** `react` runs author code in the main tree: a thrown render is caught by the error boundary, but an author can still write a slow/incorrect page. That is the accepted price of the trusted tier and the reason it is off by default. Revisit if a future need arises for *untrusted* rich pages — that would be a *different* ADR (sandboxed execution), not this one.

examples/app-showcase/src/pages/command-center-jsx.page.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import { definePage } from '@objectstack/spec/ui';
44

55
/**
6-
* Command Center — a `kind:'jsx'` page (ADR-0080). The entire layout is
7-
* authored as a constrained JSX + Tailwind *string*; at save time
6+
* Command Center — a `kind:'html'` page (ADR-0080; formerly `kind:'jsx'`).
7+
* The entire layout is authored as a constrained JSX/HTML + Tailwind *string*;
8+
* at save time
89
* `@objectstack/sdui-parser` compiles it (parse, never execute) into the SDUI
910
* tree, which the normal PageRenderer / SchemaRenderer renders. Every tag is a
1011
* real registered component — `flex`, `grid`, `card`, `text`, `badge`, `stack`.
@@ -14,9 +15,9 @@ import { definePage } from '@objectstack/spec/ui';
1415
*/
1516
export const CommandCenterJsxPage = definePage({
1617
name: 'showcase_command_center_jsx',
17-
label: 'Command Center (JSX)',
18+
label: 'Command Center (HTML)',
1819
type: 'home',
19-
kind: 'jsx',
20+
kind: 'html',
2021
source: `
2122
<flex direction="col" className="min-h-screen gap-10 bg-gradient-to-br from-slate-50 via-white to-indigo-50 p-10">
2223

packages/lint/src/validate-jsx-pages.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ describe('validateJsxPages (ADR-0080 build gate)', () => {
2323
it('ignores non-jsx pages', () => {
2424
expect(validateJsxPages({ pages: [{ name: 'full', kind: 'full', regions: [] }] })).toEqual([]);
2525
});
26+
it('validates kind:\'html\' (the renamed tier) too', () => {
27+
expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'html', source: '<flex><text content="hi" /></flex>' }] })).toEqual([]);
28+
expect(validateJsxPages({ pages: [{ name: 'cc', kind: 'html' }] }).some((f) => f.rule === 'jsx-page-empty-source')).toBe(true);
29+
});
30+
it('does NOT lint kind:\'react\' (real JS, not constrained JSX)', () => {
31+
const stack = { pages: [{ name: 'rx', kind: 'react', source: 'function P(){const[n]=React.useState(0);return <div>{n}</div>}' }] };
32+
expect(validateJsxPages(stack)).toEqual([]);
33+
});
2634
});
2735

2836
describe('validateJsxPages — full validation with a manifest', () => {

packages/lint/src/validate-jsx-pages.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
3-
// Build-time diagnostics for AI-authored JSX-source pages (ADR-0080).
3+
// Build-time diagnostics for AI-authored HTML-source pages (ADR-0080).
4+
//
5+
// Applies to `kind:'html'` (and its deprecated alias `kind:'jsx'`) — the tier
6+
// whose `source` is constrained JSX/HTML parsed (never executed) to the tree.
7+
// `kind:'react'` (ADR-0081) is intentionally NOT linted here: its source is
8+
// real JavaScript, not constrained JSX, so the constrained parser would
9+
// false-error on hooks / expressions.
410
//
511
// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` / `os
6-
// build`. A `kind:'jsx'` page's `source` is a constrained JSX/Tailwind string
12+
// build`. An html page's `source` is a constrained JSX/Tailwind string
713
// compiled (parsed, never executed) to the SDUI tree at save time. This gate
814
// parses it at author time so malformed source fails loudly (ADR-0078) instead
915
// of being stored and breaking only at render.
@@ -37,7 +43,8 @@ export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } =
3743
const pages = asArray(stack.pages);
3844
for (let p = 0; p < pages.length; p++) {
3945
const page = pages[p];
40-
if (!page || page.kind !== 'jsx') continue;
46+
// html tier (+ deprecated 'jsx' alias). react pages are not constrained JSX.
47+
if (!page || (page.kind !== 'html' && page.kind !== 'jsx')) continue;
4148
const name = String(page.name ?? `#${p}`);
4249
const source = page.source;
4350
if (typeof source !== 'string' || source.trim() === '') {
@@ -47,7 +54,7 @@ export function validateJsxPages(stack: AnyRec, opts: { manifest?: Manifest } =
4754
rule: 'jsx-page-empty-source',
4855
where: `page "${name}"`,
4956
path: `pages[${p}].source`,
50-
message: "kind:'jsx' page has no `source`.",
57+
message: `kind:'${page.kind}' page has no \`source\`.`,
5158
hint: 'Author the page as a constrained JSX/Tailwind string in `source`.',
5259
});
5360
continue;

packages/spec/src/ui/page.zod.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -358,8 +358,14 @@ export const PageSchema = lazySchema(() => z.object({
358358
*
359359
* Only meaningful when `type === 'record'`. Ignored otherwise.
360360
*/
361-
kind: z.enum(['full', 'slotted', 'jsx']).default('full')
362-
.describe('Page override mode: full | slotted | jsx (ADR-0080 JSX-source authoring)'),
361+
kind: z.enum(['full', 'slotted', 'html', 'react', 'jsx']).default('full')
362+
.describe(
363+
"Page override mode. full | slotted = structured authoring; " +
364+
"html = author-written constrained JSX/HTML+Tailwind compiled (parsed, never " +
365+
"executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias); " +
366+
"react = TRUSTED real-React source executed at render by the runtime (ADR-0081), " +
367+
"gated to enterprise/private deployments.",
368+
),
363369

364370
/**
365371
* Slot override map for slotted record pages.
@@ -393,18 +399,19 @@ export const PageSchema = lazySchema(() => z.object({
393399
* wins on any mismatch). For `full`/`slotted` pages `source` is unused.
394400
*/
395401
source: z.string().optional()
396-
.describe("JSX-source page text — authoritative when kind==='jsx'; compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute)"),
402+
.describe("Page source text. For kind==='html' (alias 'jsx') it is constrained JSX/HTML+Tailwind compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute). For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier). Authoritative over `regions` in both."),
397403
/** Plugin namespaces the JSX source references — inferred at compile, checked at save AND load (ADR-0048 provenance). */
398404
requires: z.array(z.string()).optional()
399405
.describe('Plugin namespaces the JSX source references (validated at save and load)'),
400406
}).superRefine((page, ctx) => {
401-
// ADR-0080 + ADR-0078 (completeness): a `kind:'jsx'` page with no `source`
402-
// is silently inert — fail loudly at author time, do not render an empty page.
403-
if (page.kind === 'jsx' && !(typeof page.source === 'string' && page.source.trim().length > 0)) {
407+
// ADR-0080/0081 + ADR-0078 (completeness): an html/react/jsx page with no
408+
// `source` is silently inert — fail loudly at author time, never render empty.
409+
const sourceKinds = ['html', 'react', 'jsx'];
410+
if (sourceKinds.includes(page.kind) && !(typeof page.source === 'string' && page.source.trim().length > 0)) {
404411
ctx.addIssue({
405412
code: 'custom',
406413
path: ['source'],
407-
message: "A jsx page requires a non-empty `source` (ADR-0080: JSX is the source-of-truth).",
414+
message: `A ${page.kind} page requires a non-empty \`source\` (the source is the source-of-truth).`,
408415
});
409416
}
410417
}));

0 commit comments

Comments
 (0)