diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6c14bca44e..af9bd8c45b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -180,6 +180,13 @@ jobs:
- 'skills/**'
- '.github/workflows/scaffold-e2e.yml'
- '.claude/skills/spec-property-retirement/SKILL.md'
+ # @objectstack/lint's production-witness suite reads the repo-root
+ # SDUI manifest (#12924) — verbatim per the declaration in
+ # scripts/cross-package-test-inputs.mjs (check:ci-filter-parity
+ # holds this list to the declarations; the examples/ page files
+ # the same suite declares are covered by entries the core filter
+ # already carries).
+ - 'sdui.manifest.json'
test:
# Sharded 6-way BY PACKAGE: a core-touching PR ran the affected suite
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index c13f30a6e6..f3910cac8c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -553,6 +553,23 @@ jobs:
- name: sdui-parser stays in lockstep with objectui's copy
run: pnpm check:sdui-lockstep
+ # The checked-in SDUI public-tier manifest (#12924, ruled 2026-08-29: wire
+ # it). `resolveSduiManifest()` degrades to parse-only SILENTLY when the
+ # repo-root `sdui.manifest.json` is absent or unreadable, so nothing in
+ # the production path ever says "the gate lost its whitelist" — this step
+ # is the thing that notices. Offline record checks only (existence, shape,
+ # sha256 vs the provenance record, record pin vs `.objectui-sha`): the
+ # regenerate-and-compare half runs where a regeneration can (the Node
+ # generator needs the published @object-ui set — see
+ # scripts/gen-sdui-manifest-node.mjs), and a pin bump goes red HERE until
+ # it has been re-run — the same moment the lockstep step above forces the
+ # parser parity re-verification, with an objectui checkout guaranteed in
+ # hand. Unfiltered for the same reason as its neighbour: filtering to the
+ # artefact would go dormant on exactly the PR that moves the pin.
+ # Direct invocation per the GATE INVOCATION IDIOM at the top of this file.
+ - name: SDUI manifest is present, intact and fresh at the objectui pin
+ run: node scripts/check-sdui-manifest.mjs --self-test && node scripts/check-sdui-manifest.mjs
+
# pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill
# is read in full by every seat session and every Routine fire, so its
# size is a per-round tax on the whole fleet. The #7341 extraction moved
@@ -4747,6 +4764,18 @@ jobs:
- name: Check docs YAML examples parse against live spec schemas
run: pnpm --filter @objectstack/spec run check:yaml-examples
+ # ADR-0082 D4's spec↔registry react-block declaration-parity ratchet, fed
+ # by the checked-in repo-root manifest (#12924 execution point 1: the same
+ # artefact resolves this gate's EXTERNAL_INPUT_REQUIRED standing — the
+ # input it always lacked now lives in the tree, held fresh by the
+ # check-sdui-manifest step in the lint job). Baseline-ratcheted: only a
+ # divergence NEW since packages/spec/react-declaration-parity.baseline.json
+ # reds, so wiring it per-PR promotes the cut-rc-only run without
+ # manufacturing a standing red. Reads spec src/ through tsx like its
+ # neighbours — no dist. ~5 s.
+ - name: Ratchet spec-vs-registry react declaration parity against the checked-in manifest
+ run: MANIFEST="$PWD/sdui.manifest.json" pnpm --filter @objectstack/spec check:react-declaration-parity --baseline react-declaration-parity.baseline.json --strict
+
# Same anti-drift class as the gates above, for the generated translation
# bundles in packages/platform-objects/src/apps/translations/. Nothing
# regenerated them either, so they rotted three ways at once (#3670):
diff --git a/packages/lint/src/sdui-jsx-baseline.json b/packages/lint/src/sdui-jsx-baseline.json
new file mode 100644
index 0000000000..6eefe57235
--- /dev/null
+++ b/packages/lint/src/sdui-jsx-baseline.json
@@ -0,0 +1,39 @@
+{
+ "//": [
+ "RATCHET-TO-ZERO ledger for the first live run of the wired SDUI JSX gate (#12924,",
+ "ruled 2026-08-29: wire it; first-wiring noise handled per ui#6779's ratchet-to-zero —",
+ "itemized, dated, visible, and NOT an exemption). Every row is a diagnostic the wired",
+ "`validateJsxPages` (real repo-root sdui.manifest.json, real shipped pages) emits TODAY.",
+ "",
+ "Direction 1 — a finding NOT covered by a row is a NEW violation: fix the page (or, if",
+ "the vocabulary legitimately grew, regenerate the manifest); never grow this file.",
+ "Direction 2 — a row the live run no longer produces is a STALE entry: delete it in the",
+ "same PR that fixed the page (or that landed the vocabulary the tags were waiting for).",
+ "The ledger only shrinks. Zero rows = delete the file and the census assertion inverts",
+ "to 'wired run is clean'.",
+ "",
+ "Why these 232 exist at all: the public tier deliberately declares no intrinsic HTML",
+ "tags (57 curated blocks; div/a/p absent; `html` is the tier-C escape hatch), while the",
+ "three shipped html pages author with div/a — every error below is a plain HTML tag,",
+ "none is a real authoring mistake (measured 2026-08-29, re-measured 2026-08-30). The",
+ "not-a-container rows are objectui#6740 (flex registers without isContainer). What the",
+ "html tier's legal vocabulary IS remains a producer-side question (objectui); this",
+ "ledger keeps the gate live and honest while that question settles."
+ ],
+ "recordedAt": "2026-08-30",
+ "findings": [
+ { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 17 },
+ { "page": "showcase_capability_map", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 23 },
+ { "page": "showcase_capability_map", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 14 },
+ { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 17 },
+ { "page": "showcase_capability_map", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 23 },
+ { "page": "showcase_command_center_jsx", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 29 },
+ { "page": "showcase_command_center_jsx", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 12 },
+ { "page": "showcase_command_center_jsx", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 29 },
+ { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "a", "count": 4 },
+ { "page": "showcase_start_here", "rule": "jsx-forbidden-tag", "severity": "error", "tag": "div", "count": 27 },
+ { "page": "showcase_start_here", "rule": "jsx-not-a-container", "severity": "warning", "tag": "flex", "count": 6 },
+ { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "a", "count": 4 },
+ { "page": "showcase_start_here", "rule": "jsx-unknown-component", "severity": "error", "tag": "div", "count": 27 }
+ ]
+}
diff --git a/packages/lint/src/validate-jsx-pages.production-witness.test.ts b/packages/lint/src/validate-jsx-pages.production-witness.test.ts
new file mode 100644
index 0000000000..f5a16eb356
--- /dev/null
+++ b/packages/lint/src/validate-jsx-pages.production-witness.test.ts
@@ -0,0 +1,164 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+//
+// Production-path witness + first-wiring ratchet for the SDUI JSX gate
+// (#12924, maintainer ruling 2026-08-29: wire it; execution point 3 demands a
+// witness that REALLY PARSES the checked-in manifest into `validateTree`).
+//
+// ── Why this file exists, stated as the blind spot it closes ──────────────
+//
+// Every other guard on `validateTree` in this repo constructs its manifest
+// IN MEMORY, so a green suite was compatible with the production gate being
+// parse-only for the whole life of the code — no test resolved a real
+// artefact, because there was nothing to resolve (#12924's finding). These
+// tests read the REAL repo-root `sdui.manifest.json` from DISK, feed it
+// through the REAL production entry points, and pin the arming delta itself.
+//
+// Two cross-package inputs, both declared so the graph can see them
+// (`check:examples-live-imports`, `@objectstack/lint#test` inputs in
+// turbo.json): the repo-root artefact, and the three shipped html pages.
+
+import { existsSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { describe, expect, it } from 'vitest';
+
+import { validateJsxPages } from './validate-jsx-pages.js';
+import { runAuthoringRules } from './authoring-rules.js';
+
+import { CapabilityMapPage } from '../../../examples/app-showcase/src/ui/pages/capability-map.page.js';
+import { CommandCenterJsxPage } from '../../../examples/app-showcase/src/ui/pages/command-center-jsx.page.js';
+import { StartHerePage } from '../../../examples/app-showcase/src/ui/pages/start-here.page.js';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+interface LedgerRow {
+ page: string;
+ rule: string;
+ severity: string;
+ tag: string;
+ count: number;
+}
+// readFileSync rather than a JSON module import: under NodeNext the latter
+// needs an import attribute (TS1543), which would add to this package's frozen
+// TEST_DEBT tsc count for no behavioral gain.
+const ledger = JSON.parse(readFileSync(join(HERE, 'sdui-jsx-baseline.json'), 'utf8')) as {
+ findings: LedgerRow[];
+};
+
+/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */
+function findUp(predicate: (dir: string) => boolean): string {
+ let dir = HERE;
+ for (;;) {
+ if (predicate(dir)) return dir;
+ const parent = dirname(dir);
+ if (parent === dir) throw new Error('workspace root not found from ' + HERE);
+ dir = parent;
+ }
+}
+const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml')));
+
+// The artefact, from DISK — the same bytes `resolveSduiManifest()` (packages/
+// cli, path 1: join(process.cwd(), 'sdui.manifest.json')) picks up when the
+// gate runs from the repo root. Loud absence: an absent artefact silently
+// reverts production to parse-only, so this read failing IS the regression.
+const ARTEFACT = join(REPO, 'sdui.manifest.json');
+const manifest = JSON.parse(readFileSync(ARTEFACT, 'utf8'));
+
+describe('production witness: the checked-in manifest reaches validateTree', () => {
+ it('is the real artefact (57-component public tier, no intrinsic HTML tags)', () => {
+ const keys = Object.keys(manifest.components);
+ expect(keys.length).toBeGreaterThan(0);
+ // The vocabulary facts the ratchet below stands on. If a regeneration
+ // legitimately changes them, the ledger is re-derived in the same PR.
+ expect(keys).toContain('flex');
+ expect(keys).toContain('html');
+ expect(keys).not.toContain('div');
+ });
+
+ it('arms full validation through validateJsxPages: manifest-only diagnostics fire', () => {
+ const stack = {
+ pages: [
+ {
+ name: 'witness_page',
+ kind: 'html',
+ // `flex` is a real public component; `no-such-block` is not. Only
+ // validateTree (fed by the DISK manifest) can tell them apart —
+ // parse-only cannot emit unknown-component at all.
+ source: '',
+ },
+ ],
+ };
+ const wired = validateJsxPages(stack, { manifest });
+ const rules = new Set(wired.map((f) => f.rule));
+ expect(rules).toContain('jsx-unknown-component'); // no-such-block, judged by the manifest
+ expect(rules).toContain('jsx-unknown-prop'); // notARealProp on flex, judged by flex's declared inputs
+
+ // The arming delta itself: the SAME stack, parse-only, emits neither.
+ const parseOnly = validateJsxPages(stack);
+ const parseOnlyRules = new Set(parseOnly.map((f) => f.rule));
+ expect(parseOnlyRules).not.toContain('jsx-unknown-component');
+ expect(parseOnlyRules).not.toContain('jsx-unknown-prop');
+ });
+
+ it('threads through the production registry entry (runAuthoringRules ctx.sduiManifest)', () => {
+ // The same seam `os validate`/`os build`/`os lint` drive: authoring-rules'
+ // validateJsxPages entry reads ctx.sduiManifest — never a lookalike call.
+ const stack = {
+ pages: [{ name: 'witness_page', kind: 'html', source: '' }],
+ };
+ const withManifest = runAuthoringRules('validate', {
+ normalized: stack,
+ sduiManifest: manifest,
+ });
+ expect(withManifest.some((f) => f.rule === 'jsx-unknown-component')).toBe(true);
+
+ const without = runAuthoringRules('validate', { normalized: stack });
+ expect(without.some((f) => f.rule === 'jsx-unknown-component')).toBe(false);
+ });
+});
+
+describe('first-wiring ratchet: the shipped pages against the wired gate (ui#6779 ratchet-to-zero)', () => {
+ it('wired census over the three shipped html pages equals the ledger — both directions', () => {
+ const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] };
+ const findings = validateJsxPages(stack as never, { manifest });
+
+ const census = new Map();
+ for (const f of findings) {
+ const page = /page "([^"]+)"/.exec(f.where)?.[1] ?? '(unknown page)';
+ const tag = /<([a-zA-Z0-9:_-]+)>/.exec(f.where)?.[1] ?? '(no tag)';
+ const key = `${page}|${f.rule}|${f.severity}|${tag}`;
+ census.set(key, (census.get(key) ?? 0) + 1);
+ }
+
+ const recorded = new Map(
+ ledger.findings.map((r): [string, number] => [`${r.page}|${r.rule}|${r.severity}|${r.tag}`, r.count]),
+ );
+
+ const newViolations: string[] = [];
+ for (const [key, count] of census) {
+ const allowed = recorded.get(key) ?? 0;
+ if (count > allowed) newViolations.push(`${key} — live ${count} vs ledger ${allowed}`);
+ }
+ const stale: string[] = [];
+ for (const [key, count] of recorded) {
+ const live = census.get(key) ?? 0;
+ if (live < count) stale.push(`${key} — ledger ${count} vs live ${live}`);
+ }
+
+ expect(
+ newViolations,
+ 'NEW wired-gate findings beyond the ratchet ledger. Fix the page (or regenerate the manifest ' +
+ 'if the vocabulary legitimately grew) — never grow packages/lint/src/sdui-jsx-baseline.json.',
+ ).toEqual([]);
+ expect(
+ stale,
+ 'STALE ledger rows — the live run no longer produces them. Ratchet-to-zero: delete these rows ' +
+ 'from packages/lint/src/sdui-jsx-baseline.json in this same PR.',
+ ).toEqual([]);
+ });
+
+ it('parse-only over the same pages stays clean (today\'s pre-wiring behavior, pinned)', () => {
+ const stack = { pages: [CapabilityMapPage, CommandCenterJsxPage, StartHerePage] };
+ expect(validateJsxPages(stack as never)).toEqual([]);
+ });
+});
diff --git a/scripts/check-ci-filter-parity.mjs b/scripts/check-ci-filter-parity.mjs
index f4dafa275e..b73662d58c 100644
--- a/scripts/check-ci-filter-parity.mjs
+++ b/scripts/check-ci-filter-parity.mjs
@@ -603,14 +603,18 @@ export async function selfTest() {
// single-file `crosspkg` entry the way #10848's SKILL.md is. Plus, since
// #12201, the one declaration under the `skills/**` root that card added
// (the export-list corpus gate reads the published catalog from inside
- // @objectstack/spec). Ten plus one plus two plus one: the rollback now
- // uncovers fourteen. This pin is judged over the LIVE declaration table on
+ // @objectstack/spec). Plus, since #12924, the one repo-root declaration that
+ // card added (the checked-in SDUI manifest artefact @objectstack/lint's
+ // production-witness suite reads from the workspace root), covered only
+ // through its own single-file `crosspkg` entry the way #10848's SKILL.md is.
+ // Ten plus one plus two plus one plus one: the rollback now uncovers
+ // fifteen. This pin is judged over the LIVE declaration table on
// purpose: a declaration added under a root the rollback keeps leaves the
// count alone, one under a new root moves it and is recorded here by name.
const preFix = judge(fixtureWorkflow({ core: real.filters?.core, crosspkg: ['scripts/**'] }), CROSS_PACKAGE_TEST_INPUTS);
assert(
- new Set(uncoveredGlobs(preFix)).size === 14,
- `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one -- got ${new Set(uncoveredGlobs(preFix)).size}`,
+ new Set(uncoveredGlobs(preFix)).size === 15,
+ `rolling \`crosspkg\` back to its pre-#10015 list uncovers the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one -- got ${new Set(uncoveredGlobs(preFix)).size}`,
);
assert(
uncoveredGlobs(preFix).includes('skills/**'),
@@ -628,6 +632,10 @@ export async function selfTest() {
uncoveredGlobs(preFix).includes('skills/objectstack-automation/SKILL.md'),
`-- and #10178 added the automation skill file, by name`,
);
+ assert(
+ uncoveredGlobs(preFix).includes('sdui.manifest.json'),
+ `-- and #12924 added the repo-root SDUI manifest artefact, by name`,
+ );
// ── (7) WIRING: the gate and its self-test really run in CI ──────────────
const SELF = 'scripts/check-ci-filter-parity.mjs';
@@ -652,7 +660,7 @@ export async function selfTest() {
`same-root-different-file case observed failing and then covered by naming the file, a glob covered by ` +
`\`core\`, one covered only by \`crosspkg\` and one covered by neither judged separately in one table, the ` +
`stale-entry direction, seven refusals over subjects that could not be read, the checked-in ci.yml, the ` +
- `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one, ` +
+ `pre-#10015 rollback uncovering the ten it fixed plus #10848's one plus #10178's two plus #12201's one plus #12924's one, ` +
`and the CI wiring read out of lint.yml.`,
);
return 0;
diff --git a/scripts/check-sdui-manifest.mjs b/scripts/check-sdui-manifest.mjs
new file mode 100644
index 0000000000..915b0acc00
--- /dev/null
+++ b/scripts/check-sdui-manifest.mjs
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * check-sdui-manifest — freshness + integrity gate for the repo-root
+ * `sdui.manifest.json` (#12924, ruled 2026-08-29: wire it, 签入 + 新鲜度门禁).
+ *
+ * node scripts/check-sdui-manifest.mjs # gate this tree
+ * node scripts/check-sdui-manifest.mjs --self-test # verify the checker itself
+ *
+ * ## What rots here, and which check catches it
+ *
+ * The artefact is a SYNC of objectui's public-tier registry (generated from the
+ * published `@object-ui/*` packages at the version `.objectui-sha` ships — see
+ * `scripts/gen-sdui-manifest-node.mjs`). Checked-in syncs rot; the ruling's
+ * words for the failure mode are 「同步一次就烂」. Offline, per-PR:
+ *
+ * 1. ABSENCE / SHAPE — the artefact exists at the repo root, parses, and
+ * carries a non-empty `components` map whose entries agree with their
+ * keys. An absent artefact silently reverts `validateJsxPages` to
+ * parse-only (`resolveSduiManifest()` degrades by design), so absence
+ * here is the loudest failure, never a skip. Same for the record file.
+ * 2. TAMPER — sha256(artefact) equals the record. The manifest is
+ * generator-owned; a hand edit is invisible to every consumer (the
+ * resolver JSON.parses whatever is there), so the hash is the only
+ * instrument that notices one.
+ * 3. STALENESS — the record's `objectuiSha` equals the live `.objectui-sha`.
+ * A pin bump changes which registry the shipped console runs, so the bump
+ * PR goes red HERE until the manifest is regenerated against the new pin
+ * — the same moment `check-sdui-lockstep` already forces a parser parity
+ * re-verification, and the moment an objectui checkout is guaranteed to
+ * exist (the bump required one).
+ *
+ * ## What this deliberately does NOT do, and where that risk is held
+ *
+ * No per-PR regeneration: that would put an npm-registry network dependency
+ * inside a required lint job — the exact shape `check-sdui-lockstep`'s header
+ * declines, for the same reasons. Under an unchanged pin the published inputs
+ * are immutable, so content drift per-PR is not a live axis. The residual
+ * axis — this repo's OWN adapter (`manifestFromConfigs`) changing while the
+ * pin stands still — is held by `packages/sdui-parser`'s lockstep gate and
+ * unit suite, and by regeneration being byte-deterministic (measured: two runs
+ * from the same install, identical sha256), so the remedy this gate prints
+ * always converges.
+ *
+ * Absence is loud, everywhere (#13014/#4690): every input is asserted before
+ * any verdict; a missing one exits 1 naming which. No `⚠` + exit 0.
+ */
+
+import { createHash } from 'node:crypto';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { isEntrypoint } from './invoked-as.mjs';
+
+const DEFAULT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** Gate one tree. Returns a list of problems; empty = green. */
+export function checkTree(root) {
+ const problems = [];
+ const artefactPath = join(root, 'sdui.manifest.json');
+ const recordPath = join(root, 'scripts', 'sdui-manifest.record.json');
+ const pinPath = join(root, '.objectui-sha');
+
+ if (!existsSync(artefactPath)) {
+ problems.push(
+ 'sdui.manifest.json is MISSING at the repo root. `resolveSduiManifest()` degrades to parse-only\n' +
+ ' silently, so this gate is the thing that notices. Regenerate: node scripts/gen-sdui-manifest-node.mjs',
+ );
+ return problems; // every later check reads it
+ }
+ const raw = readFileSync(artefactPath, 'utf8');
+
+ let manifest;
+ try {
+ manifest = JSON.parse(raw);
+ } catch (e) {
+ problems.push(`sdui.manifest.json does not parse as JSON: ${e.message}`);
+ return problems;
+ }
+ const components = manifest?.components;
+ if (!components || typeof components !== 'object' || Array.isArray(components)) {
+ problems.push('sdui.manifest.json has no `components` object — not a component manifest.');
+ return problems;
+ }
+ const keys = Object.keys(components);
+ if (keys.length === 0) problems.push('sdui.manifest.json declares 0 components — an empty whitelist would red every page.');
+ for (const k of keys) {
+ if (components[k]?.type !== k) {
+ problems.push(`components[${JSON.stringify(k)}].type is ${JSON.stringify(components[k]?.type)} — key/type disagree.`);
+ break; // one example is enough; this shape is generator-owned
+ }
+ }
+
+ if (!existsSync(recordPath)) {
+ problems.push('scripts/sdui-manifest.record.json is MISSING — provenance unknown. Regenerate to re-record.');
+ return problems;
+ }
+ let record;
+ try {
+ record = JSON.parse(readFileSync(recordPath, 'utf8'));
+ } catch (e) {
+ problems.push(`scripts/sdui-manifest.record.json does not parse: ${e.message}`);
+ return problems;
+ }
+ for (const field of ['objectuiSha', 'objectuiPackagesVersion', 'sha256', 'components']) {
+ if (record[field] === undefined) problems.push(`record is missing \`${field}\`.`);
+ }
+ if (problems.length) return problems;
+
+ const sha256 = createHash('sha256').update(raw).digest('hex');
+ if (sha256 !== record.sha256) {
+ problems.push(
+ `sdui.manifest.json sha256 ${sha256.slice(0, 12)}… does not match the record ${String(record.sha256).slice(0, 12)}…\n` +
+ ' The artefact is generator-owned — never hand-edit it. Regenerate: node scripts/gen-sdui-manifest-node.mjs',
+ );
+ }
+ if (keys.length !== record.components) {
+ problems.push(`artefact has ${keys.length} components, record says ${record.components}.`);
+ }
+
+ if (!existsSync(pinPath)) {
+ problems.push('.objectui-sha is MISSING — cannot judge freshness.');
+ return problems;
+ }
+ const pin = readFileSync(pinPath, 'utf8').trim();
+ if (pin !== record.objectuiSha) {
+ problems.push(
+ `.objectui-sha has moved to ${pin.slice(0, 12)}… but sdui.manifest.json was generated at ${String(record.objectuiSha).slice(0, 12)}…\n` +
+ ' A pin bump changes which registry the shipped console runs; the manifest must follow it (同步一次就烂 is\n' +
+ ' the failure mode this gate exists for). Regenerate against the new pin:\n' +
+ ' node scripts/gen-sdui-manifest-node.mjs --objectui-version {the @object-ui version the new pin ships}\n' +
+ " (read it from the objectui checkout's packages/core/package.json — the bump already required that checkout).",
+ );
+ }
+ return problems;
+}
+
+function selfTest() {
+ const mk = (mutate) => {
+ const root = mkdtempSync(join(tmpdir(), 'sdui-manifest-check-'));
+ // fixture tree: artefact + record + pin, green by construction
+ const manifest = { components: { flex: { type: 'flex', inputs: [] } } };
+ const raw = JSON.stringify(manifest, null, 2);
+ const scriptsDir = join(root, 'scripts');
+ mkdirSync(scriptsDir, { recursive: true });
+ writeFileSync(join(root, '.objectui-sha'), 'a'.repeat(40) + '\n');
+ writeFileSync(join(root, 'sdui.manifest.json'), raw);
+ writeFileSync(
+ join(scriptsDir, 'sdui-manifest.record.json'),
+ JSON.stringify(
+ {
+ objectuiSha: 'a'.repeat(40),
+ objectuiPackagesVersion: '0.0.0-selftest',
+ sha256: createHash('sha256').update(raw).digest('hex'),
+ components: 1,
+ },
+ null,
+ 2,
+ ),
+ );
+ mutate?.(root);
+ return root;
+ };
+
+ const cases = [
+ ['green fixture passes', mk(), 0],
+ ['missing artefact is RED', mk((r) => rmSync(join(r, 'sdui.manifest.json'))), 1],
+ ['hand-edited artefact (hash mismatch) is RED', mk((r) => writeFileSync(join(r, 'sdui.manifest.json'), '{"components":{"flex":{"type":"flex"}}}')), 1],
+ ['moved pin is RED', mk((r) => writeFileSync(join(r, '.objectui-sha'), 'b'.repeat(40))), 1],
+ ['empty components is RED', mk((r) => {
+ const raw = JSON.stringify({ components: {} }, null, 2);
+ writeFileSync(join(r, 'sdui.manifest.json'), raw);
+ const rec = JSON.parse(readFileSync(join(r, 'scripts', 'sdui-manifest.record.json'), 'utf8'));
+ rec.sha256 = createHash('sha256').update(raw).digest('hex');
+ rec.components = 0;
+ writeFileSync(join(r, 'scripts', 'sdui-manifest.record.json'), JSON.stringify(rec));
+ }), 1],
+ ['missing record is RED', mk((r) => rmSync(join(r, 'scripts', 'sdui-manifest.record.json'))), 1],
+ ];
+
+ let failures = 0;
+ for (const [name, root, want] of cases) {
+ const problems = checkTree(root);
+ const got = problems.length ? 1 : 0;
+ if (got !== want) {
+ failures++;
+ console.error(`✗ self-test: ${name} — expected ${want ? 'RED' : 'GREEN'}, got ${got ? 'RED' : 'GREEN'}`);
+ for (const p of problems) console.error(` ${p}`);
+ }
+ }
+ if (failures) {
+ console.error(`✗ check-sdui-manifest self-test: ${failures} case(s) failed.`);
+ process.exit(1);
+ }
+ console.log(`✓ check-sdui-manifest self-test: ${cases.length} cases behave (green passes; absence, tamper, moved pin, emptiness are RED).`);
+}
+
+if (isEntrypoint(import.meta.url)) {
+ if (process.argv.includes('--self-test')) {
+ selfTest();
+ } else {
+ const problems = checkTree(DEFAULT_ROOT);
+ if (problems.length) {
+ console.error('✗ check-sdui-manifest:');
+ for (const p of problems) console.error(` ${p}`);
+ process.exit(1);
+ }
+ const record = JSON.parse(readFileSync(join(DEFAULT_ROOT, 'scripts', 'sdui-manifest.record.json'), 'utf8'));
+ console.log(
+ `✓ check-sdui-manifest: sdui.manifest.json is present, intact (sha256 ${String(record.sha256).slice(0, 12)}…, ` +
+ `${record.components} components) and fresh at objectui pin ${String(record.objectuiSha).slice(0, 12)}….`,
+ );
+ }
+}
diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs
index 5b5b6aebb8..83724d68c6 100644
--- a/scripts/cross-package-test-inputs.mjs
+++ b/scripts/cross-package-test-inputs.mjs
@@ -441,6 +441,18 @@ export const CROSS_PACKAGE_TEST_INPUTS = {
'examples/app-showcase/src/data/objects/contact.object.ts',
'examples/app-showcase/src/system/translations/index.ts',
'examples/app-showcase/src/ui/views/contact.view.ts',
+ // src/validate-jsx-pages.production-witness.test.ts (#12924) imports the
+ // three shipped html pages LIVE and holds the wired gate's census over
+ // them equal to the ratchet-to-zero ledger (sdui-jsx-baseline.json), so
+ // an edit to any page — or to the repo-root manifest whose vocabulary
+ // judges them — must re-run this package's suite. The manifest is the
+ // checked-in producer artefact `resolveSduiManifest()` picks up from
+ // the repo root; regenerating it moves the census, so it is an input
+ // in exactly the #7802 sense.
+ 'examples/app-showcase/src/ui/pages/capability-map.page.ts',
+ 'examples/app-showcase/src/ui/pages/command-center-jsx.page.ts',
+ 'examples/app-showcase/src/ui/pages/start-here.page.ts',
+ 'sdui.manifest.json',
],
heldBy: {
// `const commandsDir = join(repoRoot, 'packages/cli/src/commands')`
diff --git a/scripts/gen-sdui-manifest-node.mjs b/scripts/gen-sdui-manifest-node.mjs
new file mode 100644
index 0000000000..cef1b73b7f
--- /dev/null
+++ b/scripts/gen-sdui-manifest-node.mjs
@@ -0,0 +1,194 @@
+#!/usr/bin/env node
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * gen-sdui-manifest-node — regenerate the repo-root `sdui.manifest.json` from
+ * objectui's PUBLISHED registry packages, without a browser.
+ *
+ * node scripts/gen-sdui-manifest-node.mjs # temp npm install (network)
+ * node scripts/gen-sdui-manifest-node.mjs --modules-root DIR # use a preinstalled node_modules parent
+ * node scripts/gen-sdui-manifest-node.mjs --objectui-version V # override the version to install
+ *
+ * ## What this produces, and from what
+ *
+ * The ADR-0080 public-tier component manifest that `resolveSduiManifest()`
+ * (packages/cli) picks up from the project root, switching `validateJsxPages`
+ * from parse-only to full `validateTree` validation (#12924, ruled 2026-08-29:
+ * wire it). The enumeration mirrors objectui's own generator page
+ * (`apps/console/dev/manifest-dump.tsx`) exactly: eager-import the 16 registry
+ * modules IN ITS ORDER, read `ComponentRegistry.getPublicConfigs()`, and
+ * serialize through `manifestFromConfigs` from `@objectstack/sdui-parser` —
+ * the hoisted, lockstep-pinned copy of objectui's adapter
+ * (`pnpm check:sdui-lockstep` holds the two copies byte-equal).
+ *
+ * ## Why plain Node is a valid producer (the browser-only claim is expired)
+ *
+ * `packages/spec/CHANGELOG.md` records (twice, byte-identically) that only a
+ * real browser can enumerate the registry. Measured false on 2026-08-29
+ * (objectui#6741) and re-measured on 2026-08-30 against published
+ * `@object-ui/*` 17.6.0: all 16 modules import under plain Node once `.css`
+ * imports resolve to an empty module — the ONLY failure without the hook is
+ * `ERR_UNKNOWN_FILE_EXTENSION .css` on plugin-dashboard/plugin-map, a loader
+ * limitation, not a browser API. 57 configs, 0 lazy stubs, ~4.5 s, no
+ * Playwright, no objectui build. The browser route (`pnpm sdui:manifest`)
+ * still exists for operators holding an objectui checkout; both routes
+ * serialize the same registry through the same adapter.
+ *
+ * ## Versioning contract
+ *
+ * The manifest must describe the registry the SHIPPED console runs — i.e. the
+ * `@object-ui/*` version that `.objectui-sha` ships. This script installs that
+ * version from npm; the version is read from `scripts/sdui-manifest.record.json`
+ * unless `--objectui-version` overrides it. After a pin bump, pass the version
+ * the new pin carries (its `packages/core/package.json` in the objectui
+ * checkout the bump already required) — `check-sdui-manifest.mjs` goes red on
+ * a moved pin until this script has been re-run and the record re-written.
+ *
+ * ## Preconditions (all loud)
+ *
+ * `packages/sdui-parser/dist` must exist (`pnpm --filter @objectstack/sdui-parser build`):
+ * the adapter is consumed exactly as production consumes it. Absence exits 1.
+ */
+
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { pathToFileURL, fileURLToPath } from 'node:url';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const RECORD_PATH = join(ROOT, 'scripts', 'sdui-manifest.record.json');
+const OUT_PATH = join(ROOT, 'sdui.manifest.json');
+const PARSER_DIST = join(ROOT, 'packages', 'sdui-parser', 'dist', 'index.mjs');
+
+/** The registration set, in objectui's `apps/console/dev/manifest-dump.tsx` order. */
+const REGISTRY_MODULES = [
+ '@object-ui/components',
+ '@object-ui/plugin-grid',
+ '@object-ui/plugin-form',
+ '@object-ui/plugin-view',
+ '@object-ui/plugin-list',
+ '@object-ui/plugin-detail',
+ '@object-ui/plugin-dashboard',
+ '@object-ui/plugin-charts',
+ '@object-ui/plugin-kanban',
+ '@object-ui/plugin-calendar',
+ '@object-ui/plugin-gantt',
+ '@object-ui/plugin-timeline',
+ '@object-ui/plugin-map',
+ '@object-ui/plugin-markdown',
+ '@object-ui/plugin-report',
+ '@object-ui/plugin-tree',
+];
+
+function fail(msg) {
+ console.error(`✗ gen-sdui-manifest-node: ${msg}`);
+ process.exit(1);
+}
+
+function arg(name) {
+ const i = process.argv.indexOf(name);
+ return i > -1 ? process.argv[i + 1] : undefined;
+}
+
+if (!existsSync(PARSER_DIST)) {
+ fail(
+ `packages/sdui-parser/dist is missing — the adapter is consumed as production consumes it.\n` +
+ ` Run: pnpm --filter @objectstack/sdui-parser build`,
+ );
+}
+
+const pinPath = join(ROOT, '.objectui-sha');
+if (!existsSync(pinPath)) fail('.objectui-sha is missing — cannot record provenance.');
+const pin = readFileSync(pinPath, 'utf8').trim();
+
+let record = {};
+if (existsSync(RECORD_PATH)) {
+ try {
+ record = JSON.parse(readFileSync(RECORD_PATH, 'utf8'));
+ } catch {
+ fail(`${RECORD_PATH} exists but does not parse — fix or delete it first.`);
+ }
+}
+
+const version = arg('--objectui-version') ?? record.objectuiPackagesVersion;
+if (!version) {
+ fail(
+ 'no --objectui-version and no version in scripts/sdui-manifest.record.json.\n' +
+ " Pass the @object-ui/* version the pin ships (the objectui checkout's packages/core/package.json).",
+ );
+}
+
+let modulesRoot = arg('--modules-root');
+if (modulesRoot) {
+ if (!existsSync(join(modulesRoot, 'node_modules'))) {
+ fail(`--modules-root ${modulesRoot} has no node_modules/ — point it at a directory whose install carries the @object-ui set.`);
+ }
+} else {
+ modulesRoot = mkdtempSync(join(tmpdir(), 'sdui-manifest-gen-'));
+ const deps = Object.fromEntries(REGISTRY_MODULES.concat('@object-ui/core').map((m) => [m, version]));
+ deps.react = '18.3.1';
+ deps['react-dom'] = '18.3.1';
+ writeFileSync(
+ join(modulesRoot, 'package.json'),
+ JSON.stringify({ name: 'sdui-manifest-gen', private: true, type: 'module', dependencies: deps }, null, 2),
+ );
+ console.error(`→ installing @object-ui/* ${version} into ${modulesRoot} (npm, network)...`);
+ execFileSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], {
+ cwd: modulesRoot,
+ stdio: ['ignore', 'inherit', 'inherit'],
+ });
+}
+
+// The one loader accommodation plain Node needs: `.css` side-effect imports
+// (plugin-dashboard, plugin-map) resolve to an empty module. Everything else
+// is stock resolution from the install above.
+const runner = `
+import { register } from 'node:module';
+register('data:text/javascript,' + encodeURIComponent(
+ 'export async function resolve(s, c, n) {' +
+ ' if (s.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true };' +
+ ' return n(s, c);' +
+ '}'), import.meta.url);
+const MODULES = ${JSON.stringify(REGISTRY_MODULES)};
+const failures = [];
+for (const m of MODULES) {
+ try { await import(m); } catch (e) { failures.push(m + ': ' + String(e).slice(0, 200)); }
+}
+if (failures.length) {
+ console.error('IMPORT FAILURES:\\n' + failures.join('\\n'));
+ process.exit(1);
+}
+const { ComponentRegistry } = await import('@object-ui/core');
+const parser = await import(${JSON.stringify(pathToFileURL(PARSER_DIST).href)});
+const configs = ComponentRegistry.getPublicConfigs();
+if (!configs.length) { console.error('getPublicConfigs() returned 0 configs'); process.exit(1); }
+const lazy = configs.filter((c) => c.lazy);
+if (lazy.length) { console.error('lazy stubs present (their inputs would be missing): ' + lazy.map((c) => c.type).join(', ')); process.exit(1); }
+const manifest = parser.manifestFromConfigs(configs);
+if (!Object.keys(manifest.components).length) { console.error('manifestFromConfigs produced 0 components'); process.exit(1); }
+// Same serialization as objectui's dump (JSON.stringify(manifest, null, 2), no trailing newline).
+process.stdout.write(JSON.stringify(manifest, null, 2));
+`;
+const runnerPath = join(modulesRoot, 'sdui-manifest-runner.mjs');
+writeFileSync(runnerPath, runner);
+console.error(`→ enumerating the registry (${REGISTRY_MODULES.length} modules)...`);
+const json = execFileSync(process.execPath, [runnerPath], { cwd: modulesRoot, maxBuffer: 64 * 1024 * 1024 }).toString();
+
+const manifest = JSON.parse(json);
+const count = Object.keys(manifest.components).length;
+writeFileSync(OUT_PATH, json);
+const sha256 = createHash('sha256').update(json).digest('hex');
+const nextRecord = {
+ '//': record['//'] ?? [],
+ objectuiSha: pin,
+ objectuiPackagesVersion: version,
+ generator: 'scripts/gen-sdui-manifest-node.mjs',
+ generatedAt: new Date().toISOString().slice(0, 10),
+ sha256,
+ components: count,
+};
+writeFileSync(RECORD_PATH, JSON.stringify(nextRecord, null, 2) + '\n');
+console.error(`✓ wrote sdui.manifest.json (${count} components, ${Buffer.byteLength(json)} bytes, sha256 ${sha256.slice(0, 12)}…)`);
+console.error(`✓ re-recorded scripts/sdui-manifest.record.json at pin ${pin.slice(0, 12)} / @object-ui ${version}`);
diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json
new file mode 100644
index 0000000000..7694e8d66d
--- /dev/null
+++ b/scripts/sdui-manifest.record.json
@@ -0,0 +1,18 @@
+{
+ "//": [
+ "Provenance record for the repo-root `sdui.manifest.json` (ADR-0080; #12924 wiring ruling 2026-08-29).",
+ "The artefact is objectui's public-tier component manifest, generated from the PUBLISHED",
+ "@object-ui/* packages at the version `.objectui-sha` ships, via scripts/gen-sdui-manifest-node.mjs",
+ "(same enumeration as objectui's apps/console/dev/manifest-dump.tsx: eager-import the 16 registry",
+ "modules, ComponentRegistry.getPublicConfigs(), manifestFromConfigs from @objectstack/sdui-parser).",
+ "`scripts/check-sdui-manifest.mjs` holds this record equal to the artefact and to the live pin:",
+ "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.",
+ "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs (see its header for the offline mode)."
+ ],
+ "objectuiSha": "9602dc820450dda956843c6cfe5b329bcf88c757",
+ "objectuiPackagesVersion": "17.6.0",
+ "generator": "scripts/gen-sdui-manifest-node.mjs",
+ "generatedAt": "2026-08-30",
+ "sha256": "49211fee7792cf51174930dc2c1be2169d5175f77a83491f68f90b3f0c19e69d",
+ "components": 57
+}
diff --git a/sdui.manifest.json b/sdui.manifest.json
new file mode 100644
index 0000000000..78f870e42f
--- /dev/null
+++ b/sdui.manifest.json
@@ -0,0 +1,2339 @@
+{
+ "components": {
+ "object-grid": {
+ "type": "object-grid",
+ "namespace": "plugin-grid",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "columns",
+ "type": "array",
+ "description": "Columns to show, either field names (`[\"name\", \"email\"]`) or column objects (`[{ field: \"name\", label: \"Full Name\", width: 200 }]`). The canonical spelling — the deprecated `fields` is only read when this is absent."
+ },
+ {
+ "name": "filter",
+ "type": "array",
+ "description": "Filter criteria in JSON-rules form. The canonical spelling — the deprecated `defaultFilters` is only read when this is absent."
+ },
+ {
+ "name": "label",
+ "type": "string",
+ "description": "Grid label, used as the table caption and as the export file title. The canonical spelling — the deprecated `title` is only read when this is absent."
+ },
+ {
+ "name": "sort",
+ "type": "array",
+ "description": "Initial sort order, `[{ field, order }]`. The canonical spelling — the deprecated single-sort `defaultSort` is only read when this is absent."
+ },
+ {
+ "name": "pagination",
+ "type": "object",
+ "description": "Pagination config, `{ pageSize, pageSizeOptions, … }`. Its presence is what enables paging; prefer it over the deprecated flat `pageSize` / `showPagination` pair."
+ },
+ {
+ "name": "searchableFields",
+ "type": "array",
+ "description": "Fields the toolbar search box queries. A non-empty list is what enables search — prefer it over the deprecated boolean `showSearch`, which cannot say WHICH fields to search."
+ },
+ {
+ "name": "data",
+ "type": "object",
+ "description": "Data source configuration — a `ViewData` object discriminated by `provider`: `{ provider: \"object\", object }` (what an omitted `data` falls back to, using `objectName`), `{ provider: \"api\", read, write }`, `{ provider: \"value\", items: [...] }` for inline rows that bypass the object query, or `{ provider: \"schema\", schemaId }`. The canonical spelling — the deprecated `staticData` is the array-only shortcut for the `value` provider, so inline rows go under `items` here rather than in a bare array."
+ },
+ {
+ "name": "rowHeight",
+ "type": "enum",
+ "enum": [
+ "compact",
+ "short",
+ "medium",
+ "tall",
+ "extra_tall"
+ ],
+ "description": "Row density. An unrecognised value falls back to `compact` rather than erroring."
+ },
+ {
+ "name": "frozenColumns",
+ "type": "number",
+ "description": "How many leading columns stay pinned while the grid scrolls horizontally."
+ },
+ {
+ "name": "resizable",
+ "type": "boolean",
+ "description": "Let users drag column borders to resize. The canonical spelling — the deprecated `resizableColumns` is only read when this is absent."
+ },
+ {
+ "name": "reorderableColumns",
+ "type": "boolean",
+ "description": "Let users drag columns into a different order."
+ },
+ {
+ "name": "showColumnTypeIcons",
+ "type": "boolean",
+ "description": "Show a field-type icon in each column header. Off by default — the type is usually obvious from the cell content, and the icons compete with the column labels."
+ },
+ {
+ "name": "rowColor",
+ "type": "object",
+ "description": "Rules that colour whole rows from a field value."
+ },
+ {
+ "name": "conditionalFormatting",
+ "type": "array",
+ "description": "Row/cell styling rules. Accepts both the ObjectUI `{ field, operator, value }` form and the spec expression form `{ condition, style }`."
+ },
+ {
+ "name": "grouping",
+ "type": "object",
+ "description": "Group rows by one or more fields into collapsible sections."
+ },
+ {
+ "name": "aggregations",
+ "type": "array",
+ "description": "Per-group roll-ups shown in group headers, `[{ field, type: \"sum\" | \"count\" | \"avg\" | \"min\" | \"max\" | \"count_distinct\" }]`. Needs `grouping` to have anything to roll up."
+ },
+ {
+ "name": "selection",
+ "type": "object",
+ "description": "Selection config, `{ type: \"none\" | \"single\" | \"multiple\" }`. The canonical spelling — the deprecated boolean/string `selectable` is only read when this is absent."
+ },
+ {
+ "name": "rowActions",
+ "type": "array",
+ "description": "Names of actions offered on each row’s menu."
+ },
+ {
+ "name": "bulkActions",
+ "type": "array",
+ "description": "Names of actions offered once rows are selected. Needs a multi-row `selection` to be reachable."
+ },
+ {
+ "name": "batchActions",
+ "type": "array",
+ "description": "Legacy alias of `bulkActions`, and the one the renderer reads FIRST when both are set. Prefer `bulkActions` in new schemas."
+ },
+ {
+ "name": "bulkActionDefs",
+ "type": "array",
+ "description": "Full inline bulk-action definitions, for actions that are not named entries in the object’s action set. Use `bulkActions` when the action already exists."
+ },
+ {
+ "name": "editable",
+ "type": "boolean",
+ "description": "Enable inline cell editing (double-click or Enter opens a cell)."
+ },
+ {
+ "name": "singleClickEdit",
+ "type": "boolean",
+ "description": "With `editable`, a single click opens the cell instead of a double-click. Has no effect on a non-editable grid."
+ },
+ {
+ "name": "navigation",
+ "type": "object",
+ "description": "What a row click does, `{ mode: \"page\" | \"drawer\" | \"modal\" | \"split\" | \"none\", … }`."
+ },
+ {
+ "name": "operations",
+ "type": "object",
+ "description": "Toggles for the built-in create/read/update/delete/export/import affordances, e.g. `{ delete: false }`."
+ },
+ {
+ "name": "exportOptions",
+ "type": "object",
+ "description": "Export config, `{ formats, maxRecords, includeHeaders, fileNamePrefix }`. Needs `operations.export` to be reachable from the toolbar."
+ }
+ ]
+ },
+ "list-view": {
+ "type": "list-view",
+ "namespace": "plugin-list",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "viewType",
+ "type": "enum",
+ "enum": [
+ {
+ "label": "Grid",
+ "value": "grid"
+ },
+ {
+ "label": "Kanban",
+ "value": "kanban"
+ },
+ {
+ "label": "Gallery",
+ "value": "gallery"
+ },
+ {
+ "label": "Calendar",
+ "value": "calendar"
+ },
+ {
+ "label": "Timeline",
+ "value": "timeline"
+ },
+ {
+ "label": "Gantt",
+ "value": "gantt"
+ },
+ {
+ "label": "Map",
+ "value": "map"
+ }
+ ]
+ },
+ {
+ "name": "columns",
+ "type": "array"
+ },
+ {
+ "name": "filter",
+ "type": "array"
+ },
+ {
+ "name": "sort",
+ "type": "array"
+ },
+ {
+ "name": "options",
+ "type": "object"
+ }
+ ]
+ },
+ "object-form": {
+ "type": "object-form",
+ "namespace": "plugin-form",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "fields",
+ "type": "array"
+ },
+ {
+ "name": "mode",
+ "type": "enum",
+ "enum": [
+ "create",
+ "edit",
+ "view"
+ ]
+ },
+ {
+ "name": "formType",
+ "type": "enum",
+ "enum": [
+ "simple",
+ "tabbed",
+ "wizard",
+ "split",
+ "drawer",
+ "modal"
+ ]
+ },
+ {
+ "name": "sections",
+ "type": "array"
+ },
+ {
+ "name": "title",
+ "type": "string"
+ },
+ {
+ "name": "description",
+ "type": "string"
+ },
+ {
+ "name": "layout",
+ "type": "enum",
+ "enum": [
+ "vertical",
+ "horizontal",
+ "inline",
+ "grid"
+ ]
+ },
+ {
+ "name": "columns",
+ "type": "number"
+ },
+ {
+ "name": "defaultTab",
+ "type": "string"
+ },
+ {
+ "name": "tabPosition",
+ "type": "enum",
+ "enum": [
+ "top",
+ "bottom",
+ "left",
+ "right"
+ ]
+ },
+ {
+ "name": "allowSkip",
+ "type": "boolean"
+ },
+ {
+ "name": "showStepIndicator",
+ "type": "boolean"
+ },
+ {
+ "name": "splitDirection",
+ "type": "enum",
+ "enum": [
+ "horizontal",
+ "vertical"
+ ]
+ },
+ {
+ "name": "splitSize",
+ "type": "number"
+ },
+ {
+ "name": "splitResizable",
+ "type": "boolean"
+ },
+ {
+ "name": "drawerSide",
+ "type": "enum",
+ "enum": [
+ "top",
+ "bottom",
+ "left",
+ "right"
+ ]
+ },
+ {
+ "name": "drawerWidth",
+ "type": "string"
+ },
+ {
+ "name": "modalSize",
+ "type": "enum",
+ "enum": [
+ "sm",
+ "default",
+ "lg",
+ "xl",
+ "full"
+ ]
+ },
+ {
+ "name": "modalCloseButton",
+ "type": "boolean",
+ "description": "Show the modal presentation’s close button. Read at ObjectForm.tsx:361 and honoured by ModalForm."
+ },
+ {
+ "name": "contentLayout",
+ "type": "enum",
+ "enum": [
+ "simple",
+ "tabbed"
+ ],
+ "description": "How the modal presentation lays out sections. `tabbed` needs more than one section to differ from `simple` (ModalForm.tsx:638)."
+ },
+ {
+ "name": "confirmOnDiscard",
+ "type": "boolean",
+ "description": "Ask before discarding unsaved edits when a drawer/modal form is dismissed. Set `false` to close immediately."
+ },
+ {
+ "name": "recordId",
+ "type": "string",
+ "description": "The record to load in `edit` / `view` mode. Leave unset for `create`."
+ },
+ {
+ "name": "customFields",
+ "type": "array",
+ "description": "Field definitions merged over the set generated from object metadata. With inline definitions and no data source, this becomes the only field source."
+ },
+ {
+ "name": "initialValues",
+ "type": "object",
+ "description": "Values to prefill in `create` mode."
+ },
+ {
+ "name": "initialData",
+ "type": "object",
+ "description": "Alternate spelling of `initialValues` that the drawer/modal presentations read FIRST (`schema.initialData || schema.initialValues`). Prefer `initialValues` in new schemas."
+ },
+ {
+ "name": "readOnly",
+ "type": "boolean",
+ "description": "Render every field read-only, whatever `mode` says."
+ },
+ {
+ "name": "submitText",
+ "type": "string"
+ },
+ {
+ "name": "cancelText",
+ "type": "string"
+ },
+ {
+ "name": "nextText",
+ "type": "string",
+ "description": "Label of the next-step button (wizard)."
+ },
+ {
+ "name": "prevText",
+ "type": "string",
+ "description": "Label of the previous-step button (wizard)."
+ },
+ {
+ "name": "showSubmit",
+ "type": "boolean"
+ },
+ {
+ "name": "showCancel",
+ "type": "boolean"
+ },
+ {
+ "name": "showReset",
+ "type": "boolean"
+ },
+ {
+ "name": "submitBehavior",
+ "type": "object",
+ "description": "Declarative post-submit behaviour, one of `{ kind: \"thank-you\", title?, message? }`, `{ kind: \"redirect\", url, delayMs? }`, `{ kind: \"continue\" }`, `{ kind: \"next-record\" }`. When present it takes precedence over `successMessage` / `navigateOnSuccess` / `resetOnSuccess`."
+ },
+ {
+ "name": "successMessage",
+ "type": "string",
+ "description": "Toast shown after a successful submit. Ignored when `submitBehavior` or `navigateOnSuccess` is set."
+ },
+ {
+ "name": "navigateOnSuccess",
+ "type": "string",
+ "description": "Path to navigate to after a successful create/update. Supports `{id}` / `{recordId}` interpolation from the saved record and is same-origin-guarded. Takes precedence over `successMessage`."
+ },
+ {
+ "name": "resetOnSuccess",
+ "type": "boolean",
+ "description": "Clear the form after a successful submit instead of keeping the saved values."
+ },
+ {
+ "name": "mobile",
+ "type": "object",
+ "description": "Phone-only presentation overrides, e.g. `{ stepper: \"auto\", stepperMinFields: 8, fullscreenLongText: true }`."
+ }
+ ]
+ },
+ "embeddable-form": {
+ "type": "embeddable-form",
+ "namespace": "plugin-form",
+ "inputs": [
+ {
+ "name": "formId",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "title",
+ "type": "string"
+ },
+ {
+ "name": "description",
+ "type": "string"
+ },
+ {
+ "name": "fields",
+ "type": "array"
+ },
+ {
+ "name": "allowMultiple",
+ "type": "boolean"
+ }
+ ]
+ },
+ "object-master-detail-form": {
+ "type": "object-master-detail-form",
+ "namespace": "plugin-form",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "mode",
+ "type": "enum",
+ "enum": [
+ "create",
+ "edit"
+ ]
+ },
+ {
+ "name": "sections",
+ "type": "array"
+ },
+ {
+ "name": "details",
+ "type": "array",
+ "required": true
+ },
+ {
+ "name": "recordId",
+ "type": "string",
+ "description": "The parent record to load in `edit` mode. Leave unset for `create`."
+ },
+ {
+ "name": "formType",
+ "type": "string",
+ "description": "How the PARENT half of the form is presented. The detail grids below it are unaffected."
+ },
+ {
+ "name": "fields",
+ "type": "array",
+ "description": "Which parent fields to show, in order. Ignored when `sections` is given — sections carry their own field lists."
+ },
+ {
+ "name": "title",
+ "type": "string"
+ },
+ {
+ "name": "submitText",
+ "type": "string",
+ "description": "Label of the button that saves the parent and every detail row in one batch."
+ },
+ {
+ "name": "cancelText",
+ "type": "string"
+ },
+ {
+ "name": "showSubmit",
+ "type": "boolean"
+ },
+ {
+ "name": "initialValues",
+ "type": "object",
+ "description": "Values to prefill on the PARENT record in `create` mode."
+ },
+ {
+ "name": "initialData",
+ "type": "object",
+ "description": "Alternate spelling of `initialValues` the renderer also reads (MasterDetailForm.tsx:602). Prefer `initialValues` in new schemas."
+ },
+ {
+ "name": "taxRateField",
+ "type": "string",
+ "description": "Name of the field ON THE CHILD object that holds each line’s tax rate. Feeds the line-items totals row; leave unset when the detail rows carry no tax."
+ }
+ ]
+ },
+ "object-kanban": {
+ "type": "object-kanban",
+ "namespace": "plugin-kanban",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "columns",
+ "type": "array"
+ }
+ ]
+ },
+ "object-calendar": {
+ "type": "object-calendar",
+ "namespace": "plugin-calendar",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "calendar",
+ "type": "object",
+ "description": "startDateField, endDateField, titleField, colorField"
+ }
+ ]
+ },
+ "object-gantt": {
+ "type": "object-gantt",
+ "namespace": "plugin-gantt",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "gantt",
+ "type": "object",
+ "description": "startDateField, endDateField, titleField, progressField, percentageField, colorField, dependenciesField"
+ }
+ ]
+ },
+ "object-timeline": {
+ "type": "object-timeline",
+ "namespace": "plugin-timeline",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "variant",
+ "type": "enum",
+ "enum": [
+ "vertical",
+ "horizontal",
+ "gantt"
+ ]
+ }
+ ]
+ },
+ "object-map": {
+ "type": "object-map",
+ "namespace": "plugin-map",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "map",
+ "type": "object",
+ "description": "latitudeField, longitudeField, titleField"
+ }
+ ]
+ },
+ "object-metric": {
+ "type": "object-metric",
+ "namespace": "plugin-dashboard",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "label",
+ "type": "string"
+ },
+ {
+ "name": "aggregate",
+ "type": "object",
+ "description": "Aggregation config: { field, function, groupBy }"
+ },
+ {
+ "name": "icon",
+ "type": "string"
+ },
+ {
+ "name": "description",
+ "type": "string",
+ "description": "Helper text rendered under the value."
+ },
+ {
+ "name": "title",
+ "type": "string",
+ "description": "Heading of the drill-down panel. Defaults to `label` — set it only when the records list wants a different name from the tile."
+ },
+ {
+ "name": "filter",
+ "type": "array",
+ "description": "Criteria the aggregation is scoped by. The same filter narrows the drill-down list, so the number and the records behind it always agree."
+ },
+ {
+ "name": "colorVariant",
+ "type": "enum",
+ "enum": [
+ "default",
+ "blue",
+ "teal",
+ "orange",
+ "purple",
+ "success",
+ "warning",
+ "danger"
+ ],
+ "description": "Colour of the icon container. Semantic, not decorative: `success` / `warning` / `danger` should track what the number means."
+ },
+ {
+ "name": "variant",
+ "type": "enum",
+ "enum": [
+ "card",
+ "bare"
+ ],
+ "description": "`card` draws the tile’s own surface; `bare` drops it, for a metric already sitting inside a card."
+ },
+ {
+ "name": "format",
+ "type": "string",
+ "description": "Numeral-style format pattern, e.g. `0,0`, `$0,0`, `0%`. Use `currency` instead of hard-coding a currency symbol here."
+ },
+ {
+ "name": "currency",
+ "type": "string",
+ "description": "ISO 4217 code, e.g. `USD`. Enables locale-aware currency formatting of the value."
+ },
+ {
+ "name": "prefix",
+ "type": "string",
+ "description": "Static text placed before the formatted value."
+ },
+ {
+ "name": "suffix",
+ "type": "string",
+ "description": "Static text placed after the formatted value."
+ },
+ {
+ "name": "invert",
+ "type": "boolean",
+ "description": "Display `1 - value` — for gauges whose good direction is down, such as error rate shown as uptime."
+ },
+ {
+ "name": "fallbackValue",
+ "type": "string",
+ "description": "Value shown when no data source resolves. For static/demo tiles; a bound metric should not need it."
+ },
+ {
+ "name": "trend",
+ "type": "object",
+ "description": "Static trend badge: `{ value, label, direction }`. Use `compareTo` instead when the trend should be computed from data."
+ },
+ {
+ "name": "compareTo",
+ "type": "object",
+ "description": "Period-over-period comparison, `{ kind: \"previousPeriod\" }` or `{ kind: \"previousYear\" }` — the computed alternative to a static `trend`."
+ },
+ {
+ "name": "drillDown",
+ "type": "object",
+ "description": "Click-through config that opens the records behind the number."
+ }
+ ]
+ },
+ "object-chart": {
+ "type": "object-chart",
+ "namespace": "plugin-charts",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "data",
+ "type": "array",
+ "description": "Optional static data"
+ },
+ {
+ "name": "filter",
+ "type": "array"
+ },
+ {
+ "name": "aggregate",
+ "type": "object",
+ "description": "Aggregation config: { field, function, groupBy }"
+ },
+ {
+ "name": "drillDown",
+ "type": "object",
+ "description": "Segment drill config: { enabled?, filter?, title?, target?: 'drawer' | 'dialog', columns?, maxRows? }. Present = on; {} is enough. Clicking a segment opens the underlying records filtered by the clicked category."
+ }
+ ]
+ },
+ "dashboard": {
+ "type": "dashboard",
+ "namespace": "view",
+ "inputs": [
+ {
+ "name": "widgets",
+ "type": "array",
+ "description": "The widget tree — the spec’s DashboardWidget[]. Each widget binds a dataset (ADR-0021) and may carry a layout ({ x, y, w, h }) and filterBindings. When omitted the dashboard renders an empty grid."
+ },
+ {
+ "name": "label",
+ "type": [
+ "string",
+ "object"
+ ],
+ "description": "Display name, shown as the header title when `header` is declared — a string or an inline per-locale map such as { en, \"zh-CN\" }. Spec-canonical spelling; the legacy `title` spelling is not authoring surface."
+ },
+ {
+ "name": "description",
+ "type": [
+ "string",
+ "object"
+ ],
+ "description": "Header description shown under the title — a string or an inline per-locale map. Rendered only when `header` is declared and `header.showDescription` is not false."
+ },
+ {
+ "name": "header",
+ "type": "object",
+ "description": "Header block: { showTitle?, showDescription?, actions? }. Strict — the contract rejects any other key. Renders nothing (zero pixels) when everything it would show is suppressed."
+ },
+ {
+ "name": "globalFilters",
+ "type": "array",
+ "description": "Dashboard-level filter bar — the spec’s GlobalFilter[]. Filter values live as dashboard variables (readable in widget expressions as page.) and are AND-merged into each bound widget’s query per its filterBindings."
+ },
+ {
+ "name": "dateRange",
+ "type": "object",
+ "description": "Built-in date-range filter: { field?, defaultRange?, allowCustomRange? }. `defaultRange` takes the spec’s date presets plus \"custom\"; the bound field defaults to created_at."
+ },
+ {
+ "name": "refreshInterval",
+ "type": "number",
+ "description": "Auto-refresh period in seconds. Zero or a negative value disables the timer, and it only runs when the host wires an onRefresh handler."
+ },
+ {
+ "name": "columns",
+ "type": "number"
+ },
+ {
+ "name": "gap",
+ "type": "number"
+ },
+ {
+ "name": "className",
+ "type": "string"
+ }
+ ]
+ },
+ "object-pivot": {
+ "type": "object-pivot",
+ "namespace": "plugin-dashboard",
+ "inputs": [
+ {
+ "name": "objectName",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "title",
+ "type": "string"
+ },
+ {
+ "name": "rowField",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "columnField",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "valueField",
+ "type": "string",
+ "required": true
+ },
+ {
+ "name": "aggregation",
+ "type": "enum",
+ "enum": [
+ {
+ "label": "Sum",
+ "value": "sum"
+ },
+ {
+ "label": "Count",
+ "value": "count"
+ },
+ {
+ "label": "Average",
+ "value": "avg"
+ },
+ {
+ "label": "Min",
+ "value": "min"
+ },
+ {
+ "label": "Max",
+ "value": "max"
+ }
+ ]
+ },
+ {
+ "name": "showRowTotals",
+ "type": "boolean"
+ },
+ {
+ "name": "showColumnTotals",
+ "type": "boolean"
+ },
+ {
+ "name": "filter",
+ "type": "array"
+ },
+ {
+ "name": "format",
+ "type": "string"
+ }
+ ]
+ },
+ "record:details": {
+ "type": "record:details",
+ "namespace": "record",
+ "inputs": [
+ {
+ "name": "columns",
+ "type": "enum",
+ "enum": [
+ "1",
+ "2",
+ "3",
+ "4"
+ ],
+ "description": "Number of columns for field layout (1-4)"
+ },
+ {
+ "name": "sections",
+ "type": "array",
+ "description": "Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` (required) are the field names shown in this section, in order. `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.