diff --git a/.full-stack-feature/00-research-map.md b/.full-stack-feature/00-research-map.md
new file mode 100644
index 00000000..58c15a6a
--- /dev/null
+++ b/.full-stack-feature/00-research-map.md
@@ -0,0 +1,173 @@
+# Research map: ARD-THEOREM-2026-08-04 (Parts 1, 4, 6) → UI
+
+**Mode:** research only (no implementation this round).
+**Source ARD:** `/Users/travisgilbert/Downloads/ARD-THEOREM-2026-08-04 (1).md`
+**Repos:** CommonPlace (product UI) + Theorem (substrate / REST / crates).
+**Design palette (mandatory for new UI):** `twenty-ui` · `@base-ui/react` · jal-co (structure extraction) · keenthemes/reui · 21st.dev installs. Enforced by `gate:twenty`, `gate:sourcing`, and `.cursor/rules/no-hand-roll-components.mdc`.
+
+**Transport note (temporary):** Models console → `/api/observed-model` → MCP `graphql_query`/`graphql_mutate` today because deployed harness has no public `/graphql`. Side chat owns the HTTP GraphQL door. UI work must not invent a second invoke path; prefer REST `/rest/metadata/*` where the meta-model already exposes it, and keep console same-origin adapters.
+
+---
+
+## Design palette inventory (what to build with)
+
+| Layer | Status | Paths / notes |
+|---|---|---|
+| `packages/twenty-ui` | **DONE** (MIT fork) | data-display, input, feedback, navigation, surfaces, json-visualizer, icon |
+| `@base-ui/react` | **DONE** (dep) | Used by reui kanban + twenty-ui primitives |
+| reui | **PARTIAL** | `ui/kanban.tsx`, `ui/kanban-board-5.tsx`, `ui/app-shell-8.tsx` (not mounted to product shell) |
+| 21st / jal-co | **PARTIAL** | sidebar-component, dashboard-sidebar, filesystem-item, repo-card/commit-graph anatomy in ledger |
+| Hand-roll risk | **ACTIVE** | ModelView field inspector uses native ``/`
+ {sourceNote ? {sourceNote}
: null}
+ {error ? {error}
: null}
- No published entries
+ {busy ? 'Loading gallery…' : 'No published entries'}
{entries.map((entry) => (
{
- if (entry.kind !== 'monitor_template') {
- return;
- }
- const forkName = `Fork of ${entry.title}`;
- setForked((current) => [...current, forkName]);
- void host.emit({
- kind: 'invoke_tool',
- tool: 'program.fork',
- args: {
- parentProgramId: entry.programId ?? '',
- name: forkName,
- },
- });
+ void forkEntry(entry);
}}
>
@@ -147,6 +161,7 @@ export function CommandsGalleryView({ set, host }: ViewRenderProps) {
{entry.publicationRef ? pub {entry.publicationRef} : null}
{entry.parentProgramId ? lineage {entry.parentProgramId} : null}
{entry.kind === 'monitor_template' ? Fork : null}
+ {entry.source === 'LocalDevCommandGallery' ? stand-in : null}
))}
@@ -154,7 +169,7 @@ export function CommandsGalleryView({ set, host }: ViewRenderProps) {
{forked.length > 0 ? (
- Forked: {forked.join(', ')}
+ Forked via gallery_fork: {forked.join(', ')}
) : null}
diff --git a/apps/console/src/views/RecordTableView.tsx b/apps/console/src/views/RecordTableView.tsx
index c4e75baa..a5f8442e 100644
--- a/apps/console/src/views/RecordTableView.tsx
+++ b/apps/console/src/views/RecordTableView.tsx
@@ -263,9 +263,16 @@ export function RecordTableView({ set: initialSet, host, instance }: ViewRenderP
return;
}
const note = receipt.value?.note ?? '';
- if (/reject|enforcement|refused/i.test(note)) {
+ const code =
+ typeof receipt.value?.code === 'string'
+ ? receipt.value.code
+ : typeof (receipt.value as { refusal?: { code?: string } } | undefined)?.refusal
+ ?.code === 'string'
+ ? (receipt.value as { refusal: { code: string } }).refusal.code
+ : null;
+ if (code || /reject|enforcement|refused|validated|put_item_validated/i.test(note)) {
setSchemaRowProperty(rowId, fieldKey, previous);
- setEditError(note);
+ setEditError(code ? `${code}: ${note || 'validated write refused'}` : note || 'Update refused');
setCellFocus((current) => (current ? { ...current, mode: 'soft' } : current));
return;
}
diff --git a/apps/console/src/views/model/ModelView.tsx b/apps/console/src/views/model/ModelView.tsx
index a9957142..8ce3dd1e 100644
--- a/apps/console/src/views/model/ModelView.tsx
+++ b/apps/console/src/views/model/ModelView.tsx
@@ -4,6 +4,7 @@
// @xyflow/react and tablecn structure through the registered lens components.
import { useCallback, useEffect, useReducer, useRef, useState, type FormEvent } from 'react';
+import Link from 'next/link';
import type { ViewRenderProps } from '@commonplace/block-view/types';
import {
emptyDeclaredModel,
@@ -19,7 +20,6 @@ import {
type ScopeRef,
} from '@commonplace/data-model-contracts';
import { DiffDialog, diffGraphs, type ModelGraph } from '@commonplace/model-canvas';
-import { BlockShell } from '@/components/block/BlockShell';
import { degradationFor, withAction } from '@/lib/degradation';
import {
exportOkfModel,
@@ -34,17 +34,12 @@ import {
type OkfModelPreviewPayload,
} from '@/lib/observed-model-client';
import { WhyTrace } from '../harness-ux/WhyTracePanel';
-import {
- DiagramLens,
- FieldsTableLens,
-} from './ObservedDeclaredLenses';
-import { RecordsLens } from './RecordsLens';
+import { DiagramLens } from './ObservedDeclaredLenses';
import type { LayoutPositions } from './diagram/layout';
import {
createModelQueryState,
modelScopeFromSet,
reduceModelQuery,
- type ModelLens,
type ModelSelection,
} from './modelQuery';
import { modelCanvasId } from '@/lib/canvas/store';
@@ -53,7 +48,7 @@ import {
declaredToModelGraph,
parseOkfBundle,
} from './okfBridge';
-import { schemaDeclareInputForField } from './schemaDeclare';
+import { schemaDeclareInputForField, schemaDeclareInputForNewObject } from './schemaDeclare';
import {
UNKNOWN_REGISTRY_SIGNAL,
registryMoved,
@@ -406,7 +401,6 @@ function ModelInspector({
);
}
-const LENSES: readonly ModelLens[] = ['diagram', 'fields', 'records'];
const LAYOUT_PERSIST_MS = 400;
/** Fallback heartbeat for registry changes made outside this client. */
const REGISTRY_SIGNAL_MS = 15_000;
@@ -456,12 +450,22 @@ export function ModelView({ set, host }: ViewRenderProps) {
});
}, [setScopeTenant, setScopeTopicId]);
+ const registrySignalRef = useRef(UNKNOWN_REGISTRY_SIGNAL);
+ const hasPaintedModelRef = useRef(false);
+
+ useEffect(() => {
+ hasPaintedModelRef.current =
+ declared.objectTypes.length > 0 || observed.types.length > 0;
+ }, [declared.objectTypes.length, observed.types.length]);
+
useEffect(() => {
if (!topicId) return;
let active = true;
void Promise.resolve().then(async () => {
if (!active) return;
- setLoading(true);
+ // Soft refresh: keep the current canvas mounted while re-reading. A hard
+ // loading flash during drag/registry polls is what made nodes "vanish".
+ if (!hasPaintedModelRef.current) setLoading(true);
setError(null);
setNotice(null);
try {
@@ -491,8 +495,6 @@ export function ModelView({ set, host }: ViewRenderProps) {
// and PR 385's versioned projections are how we notice: re-read the head
// version on the same subscription tick and rehydrate only when the anchor
// actually moved, so an unchanged registry costs one comparison.
- const registrySignalRef = useRef(UNKNOWN_REGISTRY_SIGNAL);
-
useEffect(() => {
if (!declared) return;
registrySignalRef.current = registrySignal(declared);
@@ -570,7 +572,9 @@ export function ModelView({ set, host }: ViewRenderProps) {
return () => {
active = false;
};
- }, [layoutHost, topicId, reloadToken]);
+ // Layout is topic-scoped, not registry-version-scoped. Reloading it on every
+ // reloadToken races drag persistence and snaps nodes away mid-gesture.
+ }, [layoutHost, topicId]);
async function persistLayout(positions: LayoutPositions): Promise {
pendingLayoutRef.current = null;
@@ -668,6 +672,38 @@ export function ModelView({ set, host }: ViewRenderProps) {
}
}
+ async function spawnObject(position: { x: number; y: number }): Promise {
+ if (!topicId || proposalBusy) return;
+ setProposalBusy(true);
+ setError(null);
+ try {
+ const ordinal = declared.objectTypes.length + 1;
+ const result = await postSchemaDeclare(
+ topicId,
+ schemaDeclareInputForNewObject(ordinal),
+ host,
+ );
+ setDeclared(result.declared);
+ const layoutKey = `declared:${result.receipt.objectTypeId}`;
+ scheduleLayoutPersist({
+ ...layoutPositions,
+ [layoutKey]: position,
+ });
+ setNotice(result.receipt.status === 'declared'
+ ? `${result.receipt.objectTypeId} declared on the canvas.`
+ : 'Object type declaration recorded.');
+ setReloadToken((token) => token + 1);
+ dispatch({
+ type: 'select',
+ selection: { kind: 'declared-type', key: result.receipt.objectTypeId },
+ });
+ } catch (spawnError) {
+ setError(spawnError instanceof Error ? spawnError.message : String(spawnError));
+ } finally {
+ setProposalBusy(false);
+ }
+ }
+
async function applyFieldEdit(
fieldId: string,
replacement: FieldMetadata,
@@ -830,6 +866,8 @@ export function ModelView({ set, host }: ViewRenderProps) {
const leftVersion = versionById.get(leftVersionId);
const rightVersion = versionById.get(rightVersionId);
+ const okfFileInputRef = useRef(null);
+
const lensProps = {
observed,
declared,
@@ -849,6 +887,49 @@ export function ModelView({ set, host }: ViewRenderProps) {
onLayoutChange: (positions: LayoutPositions) => {
scheduleLayoutPersist(positions);
},
+ modelName: topicId ? `topic:${topicId}` : 'Data model',
+ onImport: () => okfFileInputRef.current?.click(),
+ onExport: () => {
+ void exportOkf();
+ },
+ onDeclare: () => {
+ const ghost = observed.types.find(
+ (type) => !declared.objectTypes.some(
+ (objectType) => objectType.provenance?.observedKey === type.observedKey,
+ ),
+ );
+ if (ghost) void applyPin(ghost.observedKey, 'type');
+ },
+ declareDisabled: proposalBusy || observed.types.length === 0,
+ onSpawnObject: (position) => {
+ void spawnObject(position);
+ },
+ onCompareVersion: (id: string) => {
+ const current = declared.versions.at(-1)?.id ?? '';
+ setDiffVersionIds([id, current]);
+ setDiffOpen(true);
+ },
+ onRestoreVersion: (id: string) => {
+ void (async () => {
+ if (!topicId) return;
+ setDiffVersionIds([diffVersionIds[0], id]);
+ setProposalBusy(true);
+ setError(null);
+ try {
+ const result = await postSchemaRestore(topicId, id, host);
+ setDeclared(result.declared);
+ setReloadToken((token) => token + 1);
+ const version = declared.versions.find((row) => row.id === id);
+ setNotice(
+ `Restored schema ${String(version?.version ?? id)} as a new receipted declaration batch.`,
+ );
+ } catch (restoreError) {
+ setError(restoreError instanceof Error ? restoreError.message : String(restoreError));
+ } finally {
+ setProposalBusy(false);
+ }
+ })();
+ },
};
const unavailable = !topicId
? degradationFor('observed_model_scope_unavailable', 400)
@@ -857,234 +938,85 @@ export function ModelView({ set, host }: ViewRenderProps) {
: null;
return (
-
-
topic:{topicId} : 'No topic selected'}
- count={`${observed.eventCount} events`}
- degradation={unavailable}
- controlRow={(
-
-
- {LENSES.map((lens) => (
-
- ))}
-
-
+
+
+
+ Settings register
+
+
+ Object types · fields · indexes · facet conformance
+
+
+
void previewOkfImport(event.target.files)}
+ />
+ {unavailable ? (
+
+ {unavailable.cause}
+ {unavailable.level === 'unavailable' && unavailable.action ? (
+
+ ) : null}
+
+ ) : null}
+ {proposal ? (
+
void acceptProposal()}
+ onDecline={() => setProposal(null)}
+ />
+ ) : null}
+ {okfPreview ? (
+
+
+
+ OKF dry run: {okfPreview.diff.tables.added.length} tables added,
+ {' '}{okfPreview.diff.tables.removed.length} removed, and
+ {' '}{okfPreview.diff.fields.length} field groups changed.
+
+
- {declared.versions.length >= 2 ? (
-
- setDiffVersionIds([event.target.value, rightVersionId])}
- className="h-ij-control max-w-32 rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 text-xs text-ij-ink"
- >
-
- {declared.versions.map((version) => (
-
- ))}
-
- setDiffVersionIds([leftVersionId, event.target.value])}
- className="h-ij-control max-w-32 rounded-ij-arc border border-ij-control-border bg-ij-editor px-2 text-xs text-ij-ink"
- >
-
- {declared.versions.map((version) => (
-
- ))}
-
-
-
-
- ) : null}
-
- )}
- className="bg-transparent text-ij-ink"
- >
-
-
-
- {proposalComposerOpen ? (
-
- ) : (
-
-
-
- Schema actions
-
-
- Propose a declaration from the observed model.
-
-
-
-
- )}
-
-
- {proposal ? (
- void acceptProposal()}
- onDecline={() => setProposal(null)}
- />
- ) : null}
- {okfPreview ? (
-
-
-
- OKF dry run: {okfPreview.diff.tables.added.length} tables added,
- {' '}{okfPreview.diff.tables.removed.length} removed, and
- {' '}{okfPreview.diff.fields.length} field groups changed.
- {' '}{okfPreview.server.changes.length} Rust model-profile declarations checked.
-
-
-
-
- {okfPreview.server.changes.some((change) => change.status === 'conflict') ? (
-
- Registry conflicts: {
- okfPreview.server.changes
- .filter((change) => change.status === 'conflict')
- .map((change) => change.concept_id)
- .join(', ')
- }
-
- ) : null}
-
- ) : null}
- {notice ? (
-
- {notice}
-
- ) : null}
-
-
- {loading && topicId ? (
-
- Loading observed model.
-
- ) : queryState.lens === 'diagram' ? (
-
- ) : queryState.lens === 'fields' ? (
-
- ) : (
-
- )}
-
-
{
- void applyFieldEdit(fieldId, replacement);
- }}
- />
+
+ ) : null}
+ {notice ? (
+
+ {notice}
-
+ ) : null}
+
+ {loading && topicId ? (
+
+ Loading observed model.
+
+ ) : (
+
+ )}
+
{diffOpen && leftVersion && rightVersion ? (
void;
+ onSave: (patch: {
+ label: string;
+ type: TwentyFieldTypeToken;
+ isNullable: boolean;
+ isFilterable: boolean;
+ isSortable: boolean;
+ variants: readonly string[];
+ vectorDim: number;
+ relationTarget: string;
+ relationCardinality: 'one' | 'many';
+ indexPolicy: IndexPolicyWire;
+ }) => Promise;
+}) {
+ const initial = selectedField ? editorStateFromField(selectedField) : null;
+ const [label, setLabel] = useState(selectedField?.label ?? '');
+ const [type, setType] = useState(initial?.token ?? 'TEXT');
+ const [isNullable, setIsNullable] = useState(selectedField?.isNullable ?? true);
+ const [isFilterable, setIsFilterable] = useState(
+ selectedField?.settings.isFilterable ?? false,
+ );
+ const [isSortable, setIsSortable] = useState(selectedField?.settings.isSortable ?? false);
+ const [variantsText, setVariantsText] = useState((initial?.variants ?? []).join(', '));
+ const [vectorDim, setVectorDim] = useState(initial?.vectorDim ?? 0);
+ const [relationTarget, setRelationTarget] = useState(initial?.relationTarget ?? '*');
+ const [relationCardinality, setRelationCardinality] = useState<'one' | 'many'>(
+ initial?.relationCardinality ?? 'many',
+ );
+ const [indexPolicy, setIndexPolicy] = useState(
+ initial?.indexPolicy ?? {
+ indexed: false,
+ reverseIndexed: false,
+ tokenized: false,
+ indexOnly: false,
+ },
+ );
+
+ useEffect(() => {
+ if (!selectedField) return;
+ const next = editorStateFromField(selectedField);
+ setLabel(selectedField.label);
+ setType(next.token);
+ setIsNullable(selectedField.isNullable);
+ setIsFilterable(selectedField.settings.isFilterable);
+ setIsSortable(selectedField.settings.isSortable);
+ setVariantsText(next.variants.join(', '));
+ setVectorDim(next.vectorDim);
+ setRelationTarget(next.relationTarget);
+ setRelationCardinality(next.relationCardinality);
+ setIndexPolicy(next.indexPolicy);
+ }, [selectedField]);
+
+ const canonical = useMemo(
+ () =>
+ formatFieldType(
+ fieldTypeFromEditor({
+ token: type,
+ variants: variantsText.split(',').map((value) => value.trim()).filter(Boolean),
+ vectorDim,
+ relationTarget,
+ relationCardinality,
+ }),
+ ),
+ [type, variantsText, vectorDim, relationTarget, relationCardinality],
+ );
+
+ const showEnum = type === 'SELECT' || type === 'MULTI_SELECT';
+ const showVector = type === 'RAW_JSON';
+ const showRelation = type === 'RELATION' || type === 'MORPH_RELATION';
+
+ return (
+
+
+ {object.fields.map((field) => (
+ -
+
+
+ ))}
+
+ {!selectedField ? (
+
Select a field.
+ ) : (
+
+
+
+
{selectedField.name}
+ {selectedField.isSystem ? : (
+
+ )}
+
+
+ Twenty presentation: {type}
+ {' · '}
+ Canonical FieldType: {canonical}
+ {' · '}
+ round-trip:{' '}
+
+ {fieldTypeToTwentyToken(
+ fieldTypeFromEditor({
+ token: type,
+ variants: variantsText.split(',').map((value) => value.trim()).filter(Boolean),
+ vectorDim,
+ relationTarget,
+ relationCardinality,
+ }),
+ )}
+
+
+
+
+ {showEnum ? (
+
+ ) : null}
+ {showVector ? (
+
+ ) : null}
+ {showRelation ? (
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
IndexPolicy (four flags)
+
+ {(
+ [
+ ['indexed', 'indexed'],
+ ['reverseIndexed', 'reverse'],
+ ['tokenized', 'tokenized'],
+ ['indexOnly', 'indexOnly'],
+ ] as const
+ ).map(([key, labelText]) => (
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export function IndexesScreen({
+ object,
+ candidates,
+ busy,
+ onPromote,
+ onDemote,
+}: {
+ object: ObjectMetadataWire;
+ candidates: PromotionCandidateWire[];
+ busy: boolean;
+ onPromote: (fieldName: string) => Promise;
+ onDemote: (fieldName: string) => Promise;
+}) {
+ return (
+
+
+
+ Promoted indexes
+ {object.indexMetadataList.length === 0 ? (
+ No indexes yet.
+ ) : (
+
+ )}
+
+
+
+
+ Promotion candidates
+ {candidates.length === 0 ? (
+ No candidates for this type.
+ ) : (
+
+ {candidates.map((candidate) => (
+ -
+
+ {candidate.fieldName}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export function FacetsScreen({
+ object,
+ facets,
+ busy,
+ onMap,
+ onRevoke,
+}: {
+ object: ObjectMetadataWire;
+ facets: FacetDefWire[];
+ busy: boolean;
+ onMap: (
+ facet: string,
+ propertyMap: Record,
+ relationMap?: Record,
+ ) => Promise;
+ onRevoke: (facet: string) => Promise;
+}) {
+ const active = new Set((object.conformances ?? []).map((row) => row.facet));
+ const [draftMaps, setDraftMaps] = useState>>({});
+
+ useEffect(() => {
+ const next: Record> = {};
+ for (const facet of facets) {
+ const existing = (object.conformances ?? []).find((row) => row.facet === facet.name);
+ const propertyMap: Record = { ...(existing?.propertyMap ?? {}) };
+ for (const property of facet.properties) {
+ if (propertyMap[property.name]) continue;
+ const match =
+ object.fields.find((field) => field.name === property.name) ??
+ object.fields.find((field) => field.type === property.type);
+ if (match) propertyMap[property.name] = match.name;
+ }
+ next[facet.name] = propertyMap;
+ }
+ setDraftMaps(next);
+ }, [facets, object]);
+
+ return (
+
+
+ Facet conformance maps object fields onto declared capability contracts
+ (CONFORMS_TO). Pick local fields explicitly — auto-suggestions are only a starting point.
+
+ {facets.map((facet) => {
+ const isActive = active.has(facet.name);
+ const propertyMap = draftMaps[facet.name] ?? {};
+ const existing = (object.conformances ?? []).find((row) => row.facet === facet.name);
+ const relationMap = existing?.relationMap ?? {};
+ const missingRequired = facet.properties.some(
+ (property) => property.required && !propertyMap[property.name],
+ );
+ return (
+
+
+
+
{facet.label}
+
+ {facet.isSystem ? : null}
+ {isActive ? : }
+
+ {facet.description ? (
+ {facet.description}
+ ) : null}
+
+ {facet.properties.map((property) => (
+ -
+
+ {property.name}
+ {property.required ? ' · required' : ''}
+ {!propertyMap[property.name] && property.required ? (
+ · unmapped
+ ) : null}
+
+ {
+ const value = event.target.value;
+ setDraftMaps((current) => ({
+ ...current,
+ [facet.name]: {
+ ...(current[facet.name] ?? {}),
+ [property.name]: value,
+ },
+ }));
+ }}
+ >
+
+ {object.fields.map((field) => (
+
+ ))}
+
+
+ ))}
+
+ {Object.keys(relationMap).length > 0 ? (
+
+ Relation map:{' '}
+ {Object.entries(relationMap)
+ .map(([from, to]) => `${from}→${to}`)
+ .join(', ')}
+
+ ) : null}
+
+ {isActive ? (
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/console/src/views/model/settings/ModelSettingsView.tsx b/apps/console/src/views/model/settings/ModelSettingsView.tsx
new file mode 100644
index 00000000..579da736
--- /dev/null
+++ b/apps/console/src/views/model/settings/ModelSettingsView.tsx
@@ -0,0 +1,443 @@
+'use client';
+
+// SOURCING: twenty-ui SegmentedControl / Toggle / Button / Tag / Pill / Card +
+// cmdk SearchInput pattern for D31 four-screen metadata settings register
+// (ARD Part 1 + SPEC-COMMONPLACE-META-MODEL). Facet conformance has no Twenty
+// analog — Card + mapping rows on twenty-ui primitives.
+
+import Link from 'next/link';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ViewRenderProps } from '@commonplace/block-view/types';
+import {
+ fieldTypeFromEditor,
+ type FacetDefWire,
+ type IndexPolicyWire,
+ type ObjectMetadataWire,
+ type PromotionCandidateWire,
+ type TwentyFieldTypeToken,
+} from '@commonplace/data-model-contracts';
+import { Button, SearchInput, SegmentedControl } from 'twenty-ui/input';
+import { Pill, Tag } from 'twenty-ui/data-display';
+import { Card, CardContent } from 'twenty-ui/surfaces';
+import {
+ createConformance,
+ deleteConformance,
+ demoteFieldIndex,
+ listFacets,
+ listMetadataObjects,
+ listPromotionCandidates,
+ patchMetadataField,
+ patchMetadataObject,
+ promoteFieldIndex,
+} from './metadataClient';
+import { FacetsScreen, FieldsScreen, IndexesScreen } from './FieldSettingsPanels';
+
+type Screen = 'objects' | 'fields' | 'indexes' | 'facets';
+
+const SCREENS: Array<{ value: Screen; label: string }> = [
+ { value: 'objects', label: 'Object types' },
+ { value: 'fields', label: 'Fields' },
+ { value: 'indexes', label: 'Indexes' },
+ { value: 'facets', label: 'Facets' },
+];
+
+export function ModelSettingsView(_props: ViewRenderProps) {
+ const [screen, setScreen] = useState('objects');
+ const [objects, setObjects] = useState([]);
+ const [facets, setFacets] = useState([]);
+ const [candidates, setCandidates] = useState([]);
+ const [selectedName, setSelectedName] = useState(null);
+ const [selectedField, setSelectedField] = useState(null);
+ const [query, setQuery] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+ const [sourceNote, setSourceNote] = useState(null);
+
+ const selected = useMemo(
+ () => objects.find((row) => row.nameSingular === selectedName) ?? null,
+ [objects, selectedName],
+ );
+
+ const selectedFieldMeta = useMemo(() => {
+ if (!selected || !selectedField) return null;
+ return selected.fields.find((row) => row.name === selectedField) ?? null;
+ }, [selected, selectedField]);
+
+ const reload = useCallback(async () => {
+ setBusy(true);
+ setError(null);
+ try {
+ const [nextObjects, nextFacets, nextCandidates] = await Promise.all([
+ listMetadataObjects(),
+ listFacets(),
+ listPromotionCandidates(),
+ ]);
+ setObjects(nextObjects);
+ setFacets(nextFacets);
+ setCandidates(nextCandidates);
+ setSelectedName((prev) => prev ?? nextObjects[0]?.nameSingular ?? null);
+ const standIn = nextObjects.length > 0;
+ setSourceNote(
+ standIn
+ ? 'Loaded via /api/rest/metadata (harness or LocalDevMetadataStore).'
+ : 'No object types returned.',
+ );
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : String(loadError));
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ const filteredObjects = useMemo(() => {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return objects;
+ return objects.filter((row) =>
+ [row.nameSingular, row.labelSingular, row.namePlural]
+ .join(' ')
+ .toLowerCase()
+ .includes(needle),
+ );
+ }, [objects, query]);
+
+ async function saveObjectLabels(labelSingular: string, labelPlural: string): Promise {
+ if (!selected) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const updated = await patchMetadataObject(selected.nameSingular, {
+ labelSingular,
+ labelPlural,
+ });
+ setObjects((prev) =>
+ prev.map((row) => (row.nameSingular === updated.nameSingular ? updated : row)),
+ );
+ setNotice('Object labels saved.');
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : String(saveError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function saveField(patch: {
+ label: string;
+ type: TwentyFieldTypeToken;
+ isNullable: boolean;
+ isFilterable: boolean;
+ isSortable: boolean;
+ variants: readonly string[];
+ vectorDim: number;
+ relationTarget: string;
+ relationCardinality: 'one' | 'many';
+ indexPolicy: IndexPolicyWire;
+ }): Promise {
+ if (!selected || !selectedFieldMeta) return;
+ setBusy(true);
+ setError(null);
+ try {
+ const fieldType = fieldTypeFromEditor({
+ token: patch.type,
+ variants: patch.variants,
+ vectorDim: patch.vectorDim,
+ relationTarget: patch.relationTarget,
+ relationCardinality: patch.relationCardinality,
+ });
+ const updated = await patchMetadataField(selected.nameSingular, selectedFieldMeta.name, {
+ label: patch.label,
+ type: patch.type,
+ isNullable: patch.isNullable,
+ settings: {
+ isFilterable: patch.isFilterable,
+ isSortable: patch.isSortable,
+ raw: {
+ ...(selectedFieldMeta.settings.raw ?? {}),
+ fieldType,
+ indexPolicy: patch.indexPolicy,
+ },
+ },
+ });
+ setObjects((prev) =>
+ prev.map((row) => (row.nameSingular === updated.nameSingular ? updated : row)),
+ );
+ setNotice(`Field ${selectedFieldMeta.name} saved.`);
+ } catch (saveError) {
+ setError(saveError instanceof Error ? saveError.message : String(saveError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function promote(fieldName: string): Promise {
+ if (!selected) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await promoteFieldIndex(selected.nameSingular, fieldName);
+ await reload();
+ setNotice(`Promoted index on ${selected.nameSingular}.${fieldName}.`);
+ } catch (promoteError) {
+ setError(promoteError instanceof Error ? promoteError.message : String(promoteError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function demote(fieldName: string): Promise {
+ if (!selected) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await demoteFieldIndex(selected.nameSingular, fieldName);
+ await reload();
+ setNotice(`Demoted user-mark index on ${selected.nameSingular}.${fieldName}.`);
+ } catch (demoteError) {
+ setError(demoteError instanceof Error ? demoteError.message : String(demoteError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function mapFacet(
+ facetName: string,
+ propertyMap: Record,
+ relationMap?: Record,
+ ): Promise {
+ if (!selected) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await createConformance(selected.nameSingular, {
+ facet: facetName,
+ propertyMap,
+ relationMap,
+ });
+ await reload();
+ setNotice(`Conformance ${facetName} applied to ${selected.nameSingular}.`);
+ } catch (mapError) {
+ setError(mapError instanceof Error ? mapError.message : String(mapError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function revokeFacet(facetName: string): Promise {
+ if (!selected) return;
+ setBusy(true);
+ setError(null);
+ try {
+ await deleteConformance(selected.nameSingular, facetName);
+ await reload();
+ setNotice(`Revoked conformance ${facetName}.`);
+ } catch (revokeError) {
+ setError(revokeError instanceof Error ? revokeError.message : String(revokeError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ {(error || notice || sourceNote) && (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {sourceNote ?
{sourceNote}
: null}
+
+ )}
+
+
+ setScreen(value)}
+ options={SCREENS}
+ />
+
+
+
+
+
+
+ {!selected ? (
+ Select an object type.
+ ) : screen === 'objects' ? (
+
+ ) : screen === 'fields' ? (
+
+ ) : screen === 'indexes' ? (
+ row.objectNameSingular === selected.nameSingular,
+ )}
+ busy={busy}
+ onPromote={promote}
+ onDemote={demote}
+ />
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function ObjectDetailScreen({
+ object,
+ busy,
+ onSave,
+}: {
+ object: ObjectMetadataWire;
+ busy: boolean;
+ onSave: (labelSingular: string, labelPlural: string) => Promise;
+}) {
+ const [labelSingular, setLabelSingular] = useState(object.labelSingular);
+ const [labelPlural, setLabelPlural] = useState(object.labelPlural);
+ useEffect(() => {
+ setLabelSingular(object.labelSingular);
+ setLabelPlural(object.labelPlural);
+ }, [object]);
+
+ return (
+
+
+
+
{object.nameSingular}
+ {object.isSystem ? : }
+ {object.isActive ? : }
+
+
+ {object.universalIdentifier}
+
+
+
+
+ void onSave(labelSingular, labelPlural)}
+ />
+
+
+
+ Conformances
+
+
+ {(object.conformances ?? []).length === 0 ? (
+ None yet — map on the Facets screen.
+ ) : (
+ (object.conformances ?? []).map((row) => (
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/console/src/views/model/settings/metadataClient.ts b/apps/console/src/views/model/settings/metadataClient.ts
new file mode 100644
index 00000000..d66dca41
--- /dev/null
+++ b/apps/console/src/views/model/settings/metadataClient.ts
@@ -0,0 +1,149 @@
+// SOURCING: none. Browser client for same-origin `/api/rest/metadata/*`.
+
+import type {
+ FacetDefWire,
+ FieldMetadataWire,
+ ObjectConformanceWire,
+ ObjectMetadataWire,
+ PromotionCandidateWire,
+ TwentyFieldTypeToken,
+} from '@commonplace/data-model-contracts';
+import { objectFields } from '@commonplace/data-model-contracts';
+
+async function metadataFetch(
+ path: string,
+ init?: RequestInit,
+): Promise<{ ok: true; data: T } | { ok: false; status: number; error: string }> {
+ const response = await fetch(`/api/rest/metadata/${path.replace(/^\//, '')}`, {
+ ...init,
+ headers: {
+ Accept: 'application/json',
+ ...(init?.body ? { 'Content-Type': 'application/json' } : {}),
+ ...init?.headers,
+ },
+ cache: 'no-store',
+ });
+ const text = await response.text();
+ let parsed: unknown = null;
+ try {
+ parsed = text ? JSON.parse(text) : null;
+ } catch {
+ parsed = { raw: text };
+ }
+ if (!response.ok) {
+ const error =
+ typeof parsed === 'object' &&
+ parsed &&
+ 'error' in parsed &&
+ typeof (parsed as { error: unknown }).error === 'string'
+ ? (parsed as { error: string }).error
+ : `metadata_${response.status}`;
+ return { ok: false, status: response.status, error };
+ }
+ return { ok: true, data: parsed as T };
+}
+
+export async function listMetadataObjects(): Promise {
+ const result = await metadataFetch<{
+ objects?: { edges?: Array<{ node?: ObjectMetadataWire }> };
+ }>('objects');
+ if (!result.ok) throw new Error(result.error);
+ return (result.data.objects?.edges ?? [])
+ .map((edge) => edge.node)
+ .filter((node): node is ObjectMetadataWire => Boolean(node))
+ .map((node) => ({ ...node, fields: objectFields(node) }));
+}
+
+export async function patchMetadataObject(
+ nameSingular: string,
+ patch: Partial>,
+): Promise {
+ const result = await metadataFetch(`objects/${encodeURIComponent(nameSingular)}`, {
+ method: 'PATCH',
+ body: JSON.stringify(patch),
+ });
+ if (!result.ok) throw new Error(result.error);
+ return { ...result.data, fields: objectFields(result.data) };
+}
+
+export async function listFacets(): Promise {
+ const result = await metadataFetch<{ facets?: FacetDefWire[] }>('facets');
+ if (!result.ok) throw new Error(result.error);
+ return result.data.facets ?? [];
+}
+
+export async function createConformance(
+ objectNameSingular: string,
+ conformance: {
+ facet: string;
+ propertyMap: Record;
+ relationMap?: Record;
+ },
+): Promise {
+ const result = await metadataFetch(
+ `objects/${encodeURIComponent(objectNameSingular)}/conformances`,
+ { method: 'POST', body: JSON.stringify(conformance) },
+ );
+ if (!result.ok) throw new Error(result.error);
+ return result.data;
+}
+
+export async function deleteConformance(
+ objectNameSingular: string,
+ facet: string,
+): Promise {
+ const result = await metadataFetch(
+ `objects/${encodeURIComponent(objectNameSingular)}/conformances/${encodeURIComponent(facet)}`,
+ { method: 'DELETE' },
+ );
+ if (!result.ok && result.status !== 204) throw new Error(result.error);
+}
+
+export async function patchMetadataField(
+ objectNameSingular: string,
+ fieldName: string,
+ patch: Partial<{
+ label: string;
+ description: string;
+ type: TwentyFieldTypeToken;
+ isNullable: boolean;
+ settings: FieldMetadataWire['settings'];
+ }>,
+): Promise {
+ const result = await metadataFetch(
+ `fields/${encodeURIComponent(objectNameSingular)}/${encodeURIComponent(fieldName)}`,
+ { method: 'PATCH', body: JSON.stringify(patch) },
+ );
+ if (!result.ok) throw new Error(result.error);
+ return { ...result.data, fields: objectFields(result.data) };
+}
+
+export async function demoteFieldIndex(
+ objectNameSingular: string,
+ fieldName: string,
+): Promise {
+ const result = await metadataFetch(
+ `indexes/${encodeURIComponent(objectNameSingular)}/${encodeURIComponent(fieldName)}`,
+ { method: 'DELETE' },
+ );
+ if (!result.ok && result.status !== 204) throw new Error(result.error);
+}
+
+export async function promoteFieldIndex(
+ objectNameSingular: string,
+ fieldName: string,
+): Promise {
+ const result = await metadataFetch(
+ `indexes/${encodeURIComponent(objectNameSingular)}/${encodeURIComponent(fieldName)}`,
+ { method: 'POST', body: JSON.stringify({ kind: 'user_marked' }) },
+ );
+ if (!result.ok) throw new Error(result.error);
+}
+
+export async function listPromotionCandidates(): Promise {
+ const result = await metadataFetch<{ candidates?: PromotionCandidateWire[] }>(
+ 'promotion-candidates',
+ );
+ if (!result.ok) throw new Error(result.error);
+ return result.data.candidates ?? [];
+}
diff --git a/apps/console/src/views/program/ProgramView.tsx b/apps/console/src/views/program/ProgramView.tsx
index 019a98a6..04e876b3 100644
--- a/apps/console/src/views/program/ProgramView.tsx
+++ b/apps/console/src/views/program/ProgramView.tsx
@@ -106,6 +106,7 @@ import {
validNext,
validateCompilerProposal,
validateEdgeSchema,
+ validateProgramDefinition,
type ProgramListItem,
} from './programClient';
import {
@@ -287,6 +288,8 @@ function ProgramCanvasInner({ host }: ViewRenderProps) {
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState(null);
+ const [validationSummary, setValidationSummary] = useState(null);
+ const [validationNodeIds, setValidationNodeIds] = useState([]);
const [livenessByNode, setLivenessByNode] = useState>({});
const [runReceipt, setRunReceipt] = useState(null);
const [runInvocationId, setRunInvocationId] = useState(null);
@@ -1132,11 +1135,43 @@ function ProgramCanvasInner({ host }: ViewRenderProps) {
setNotice(`Run ${receipt.run_id}: ${receipt.events.length} server events.`);
}
+ async function applyValidationResult(
+ result: Awaited>,
+ ): Promise {
+ if (result.ok) {
+ const failed = result.receipt.checks.filter((check) => !check.passed);
+ setValidationNodeIds([]);
+ setValidationSummary(
+ failed.length === 0
+ ? `Valid · ${result.receipt.checks.length} checks passed`
+ : `Invalid · ${failed.map((check) => check.requirement).join(', ')}`,
+ );
+ if (failed.length > 0) {
+ setError('Validation reported failed checks.');
+ return false;
+ }
+ return true;
+ }
+ setValidationSummary(`${result.code}: ${result.message}`);
+ setValidationNodeIds([...result.nodeIds]);
+ setError(result.message);
+ return false;
+ }
+
+ /** ARD D24: whole program validates before anything executes. */
async function runProgram(): Promise {
setBusy(true);
setNotice(null);
setError(null);
+ setValidationSummary(null);
+ setValidationNodeIds([]);
try {
+ const validation = await validateProgramDefinition(definition);
+ const ok = await applyValidationResult(validation);
+ if (!ok) {
+ setNotice('Run blocked: fix validation before execute.');
+ return;
+ }
const invocationId = crypto.randomUUID();
setRunInvocationId(invocationId);
applyReceipt(await runProgramDefinition(definition, optionsForRun(invocationId)));
@@ -1147,6 +1182,26 @@ function ProgramCanvasInner({ host }: ViewRenderProps) {
}
}
+ async function validateProgram(): Promise {
+ setBusy(true);
+ setError(null);
+ setValidationSummary(null);
+ setValidationNodeIds([]);
+ try {
+ const result = await validateProgramDefinition(definition);
+ const ok = await applyValidationResult(result);
+ if (ok) {
+ setNotice(`Validation passed for ${
+ result.ok ? result.receipt.program_id || 'program' : 'program'
+ }.`);
+ }
+ } catch (validationError) {
+ setError(validationError instanceof Error ? validationError.message : String(validationError));
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function resumeRun(answer: string): Promise {
if (!runReceipt?.parked || !runInvocationId) return;
setBusy(true);
@@ -1752,6 +1807,9 @@ function ProgramCanvasInner({ host }: ViewRenderProps) {
if (selectedNodeId) updateCodeSource(selectedNodeId, value);
}}
onRun={() => void runProgram()}
+ onValidate={() => void validateProgram()}
+ validationSummary={validationSummary}
+ validationNodeIds={validationNodeIds}
onPin={(value) => void pinSelectedValue(value)}
onUnpin={() => {
if (!selectedNodeId) return;
diff --git a/apps/console/src/views/program/RunRail.tsx b/apps/console/src/views/program/RunRail.tsx
index f0fc3556..be6f207d 100644
--- a/apps/console/src/views/program/RunRail.tsx
+++ b/apps/console/src/views/program/RunRail.tsx
@@ -21,6 +21,9 @@ export type RunRailProps = {
readonly onTweakTextChange: (value: string) => void;
readonly onCodeSourceChange: (value: string) => void;
readonly onRun: () => void;
+ readonly onValidate?: () => void;
+ readonly validationSummary?: string | null;
+ readonly validationNodeIds?: readonly string[];
readonly onPin: (value: ProgramValueRef) => void;
readonly onUnpin: () => void;
readonly onResume: (answer: string) => void;
@@ -82,6 +85,9 @@ export function RunRail({
onTweakTextChange,
onCodeSourceChange,
onRun,
+ onValidate,
+ validationSummary,
+ validationNodeIds,
onPin,
onUnpin,
onResume,
@@ -105,14 +111,39 @@ export function RunRail({
return (