diff --git a/.changeset/object-schema-metadata-derive-from-spec.md b/.changeset/object-schema-metadata-derive-from-spec.md
new file mode 100644
index 0000000000..501ecb2388
--- /dev/null
+++ b/.changeset/object-schema-metadata-derive-from-spec.md
@@ -0,0 +1,34 @@
+---
+'@object-ui/types': minor
+'@object-ui/app-shell': patch
+'@object-ui/react': patch
+---
+
+`ObjectSchemaMetadata` is now derived from `@objectstack/spec`'s `ServiceObject`
+instead of being a hand-written copy (objectui#5362; maintainer ruling
+2026-08-20: the object document type belongs to the spec).
+
+What changes on the published type surface:
+
+- **Gained:** the full spec object-document surface, including the three keys
+ the runtime already reads but the old interface rejected as excess
+ properties: `icon`, `titleFormat`, `listViews` (plus `pluralLabel`,
+ `nameField`, `displayNameField`, `managedBy` as a spec key, and the rest of
+ the spec document).
+- **Removed:** nine members the old interface declared that no objectui
+ runtime code reads and the spec document does not know: `extends`,
+ `triggers`, `primary_key`, `relationships`, `name_field` (the spec spelling
+ is `nameField`), `soft_delete`, `audit_trail`, `version`, `cache`.
+ `ObjectTrigger` and `ObjectRelationship` remain exported unchanged.
+- **Kept:** `editMode` — the one measured client-side member the runtime reads
+ (`recordFormNavigation` / `AppContent`) — now declared on the new
+ `ObjectSchemaClientExtensions` interface, which the derivation intersects.
+ Note the spec's strict parse rejects `editMode` on published documents
+ (`unrecognized_keys`); it is a client-type member only.
+
+Spelling settlement: `listViews` (camelCase) is canonical — `list_views`
+appears nowhere in `@objectstack/spec` 17.2.0. Runtime read sites in
+`@object-ui/app-shell` and `@object-ui/react` keep a documented snake-spelling
+READ fallback for stored pre-settlement documents (that stock has never been
+censused — objectstack#7917); the CRUD guide and its pinned transcription now
+author the canonical spelling.
diff --git a/content/docs/guide/building-crud-app.md b/content/docs/guide/building-crud-app.md
index e66bfe9e80..f86e164ac6 100644
--- a/content/docs/guide/building-crud-app.md
+++ b/content/docs/guide/building-crud-app.md
@@ -126,7 +126,7 @@ export const TaskSchema = {
icon: 'check-circle-2',
titleFormat: '{title}',
fields,
- list_views: {
+ listViews: {
all: {
label: 'All Tasks',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
@@ -317,7 +317,7 @@ composes that view's `filter` and `sort` onto the query for you:
```tsx
const [activeView, setActiveView] = useState('all');
-// View switcher buttons — the names match the `list_views` keys from Step 3.
+// View switcher buttons — the names match the `listViews` keys from Step 3.
@@ -334,7 +334,7 @@ Selecting **Active** re-queries with the `active` view's `filter`
(`status != Done`) and `sort` (`priority asc`) applied — you never assemble
`$filter` / `$orderby` by hand. A view name your backend does not publish is
reported, not silently ignored: swap `activeView` for a name outside
-`list_views` and the grid renders a configuration-error panel in place of the
+`listViews` and the grid renders a configuration-error panel in place of the
table, the same way an unresolved `objectName` does (Step 5). A page that
instead fell back to the object's full, unfiltered scope would look like it
worked while returning every record regardless of which button was pressed —
diff --git a/content/docs/guide/console-architecture.md b/content/docs/guide/console-architecture.md
index 8ce4638a54..d945c1895d 100644
--- a/content/docs/guide/console-architecture.md
+++ b/content/docs/guide/console-architecture.md
@@ -152,7 +152,7 @@ The `ActionRunner` supports:
The shell's `ObjectView` — the one exported by `@object-ui/app-shell` and bound to the routes
above — is a **thin wrapper** around `@object-ui/plugin-view`'s `ObjectView`:
-- Resolves views from the object definition's `list_views`
+- Resolves views from the object definition's `listViews`
- Passes a `renderListView` callback for multi-view rendering (kanban, calendar, chart)
- Handles shell-level concerns: URL routing, MetadataInspector, record detail overlay
diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx
index e2a1cd76cb..f73ed34efa 100644
--- a/packages/app-shell/src/layout/AppHeader.tsx
+++ b/packages/app-shell/src/layout/AppHeader.tsx
@@ -498,6 +498,9 @@ export function AppHeader({
// humanized slug ("Kanban By Status") so the breadcrumb matches the
// tab label users clicked.
const viewName = pathParts[4];
+ // `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
+ // `list_views` leg is a compatibility READ for stored pre-settlement documents
+ // (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const definedViews = (currentObject as any).listViews || (currentObject as any).list_views || {};
const viewDef = (definedViews as Record)[viewName];
const fallbackLabel = (viewDef && (viewDef.label || viewDef.title)) || humanizeSlug(viewName);
diff --git a/packages/app-shell/src/providers/MetadataProvider.tsx b/packages/app-shell/src/providers/MetadataProvider.tsx
index e8ec6bd072..f85101cedd 100644
--- a/packages/app-shell/src/providers/MetadataProvider.tsx
+++ b/packages/app-shell/src/providers/MetadataProvider.tsx
@@ -417,6 +417,10 @@ export function mergeViewsIntoObjects(objects: any[], views: any[]): any[] {
return objects.map(obj => {
const extra = byObject[obj.name];
if (!extra) return obj;
+ // `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
+ // `list_views` / `form_views` legs are compatibility READS for stored pre-settlement
+ // documents (that stock has never been censused: objectstack#7917). Never WRITE snake keys
+ // — the merge below emits camelCase only.
const existingListViews = obj.listViews || obj.list_views || {};
const existingFormViews = obj.formViews || obj.form_views || {};
const merged: any = {
diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx
index 6eaa56076a..842fdb4a6d 100644
--- a/packages/app-shell/src/views/InterfaceListPage.tsx
+++ b/packages/app-shell/src/views/InterfaceListPage.tsx
@@ -53,6 +53,9 @@ function hasColumns(v: any): boolean {
}
function resolveSourceView(objectDef: any, sourceView?: string): any | undefined {
+ // `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
+ // `list_views` leg is a compatibility READ for stored pre-settlement documents
+ // (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const views: Record = objectDef?.listViews || objectDef?.list_views || {};
// ADR-0017 expansion can serve a default-view item with an empty config
// while the full body lives on `objectDef.list` — prefer candidates that
diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx
index e04073e6e2..262521054f 100644
--- a/packages/app-shell/src/views/ObjectView.tsx
+++ b/packages/app-shell/src/views/ObjectView.tsx
@@ -2,7 +2,7 @@
* Console ObjectView
*
* Thin wrapper around the plugin-view ObjectView that adds:
- * - Multi-view resolution from objectDef.list_views
+ * - Multi-view resolution from objectDef.listViews
* - MetadataInspector toggle
* - Drawer for record detail preview
* - useObjectActions for toolbar create button
@@ -1298,6 +1298,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
setViewOverrides({});
return;
}
+ // `listViews` is canonical (#5362; @objectstack/spec declares only camelCase). The
+ // `list_views` leg is a compatibility READ for stored pre-settlement documents
+ // (that stock has never been censused: objectstack#7917). Never WRITE the snake key.
const definedViews = (objectDef.listViews || objectDef.list_views || {}) as Record;
const ids = Object.keys(definedViews);
// Include the primary view id so overrides apply to it too. Its identity
diff --git a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx
index 9508543df4..4ca4885065 100644
--- a/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx
+++ b/packages/plugin-grid/src/__tests__/guideCrudAppRenders.test.tsx
@@ -58,7 +58,9 @@ import '../index';
const GUIDE = path.resolve(__dirname, '../../../../content/docs/guide/building-crud-app.md');
-// `list_views` mirrors the guide's own Step 3 `TaskSchema.list_views` exactly
+// `listViews` mirrors the guide's own Step 3 `TaskSchema.listViews` exactly
+// (canonical camelCase since #5362; the runtime's snake fallback is pinned by
+// `ElementDataSourceGate.test.tsx`, not by this guide transcription)
// (same two ids, same `active` filter/sort) — the fourth axis below measures
// whether Step 7's `dataSource: { object, view }` binding resolves against a
// backend that publishes what the guide instructs the reader to declare, not
@@ -73,7 +75,7 @@ const TASK_SCHEMA = {
assignee: { name: 'assignee', type: 'text', label: 'Assignee' },
due_date: { name: 'due_date', type: 'date', label: 'Due Date' },
},
- list_views: {
+ listViews: {
all: {
label: 'All Tasks',
columns: ['title', 'status', 'priority', 'assignee', 'due_date'],
@@ -343,7 +345,7 @@ describe('object-grid — Step 7’s rewritten dataSource binding actually chang
it('a view name the backend does not publish renders a configuration-error panel, not a silently unfiltered grid', async () => {
// The behavioural trade the maintainer ruled acceptable (2026-08-22):
- // `archived` is not one of `TASK_SCHEMA.list_views`'s keys.
+ // `archived` is not one of `TASK_SCHEMA.listViews`'s keys.
const [step7] = guideSchemas('object-grid').slice(2);
const unpublished = { ...step7, dataSource: { ...step7.dataSource, view: 'archived' } };
diff --git a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx
index c6430c948f..a5ad39afb7 100644
--- a/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx
+++ b/packages/react/src/element-data-source/__tests__/ElementDataSourceGate.test.tsx
@@ -168,6 +168,28 @@ describe('useElementDataSourceSchema — binding with a saved view', () => {
expect(bound.viewType).toBe('kanban');
});
+ it('still resolves a view served under snake `list_views` (stored-data compatibility, #5362)', async () => {
+ // `listViews` (camelCase) is the canonical spelling — @objectstack/spec
+ // declares nothing else, and every fixture above uses it. This pin is the
+ // OTHER half of that settlement: stored app data published before the
+ // canonization has never been censused (objectstack#7917), so the snake
+ // READ fallback in `useElementDataSource` must survive until that census
+ // exists. If this test is failing because the fallback was removed, the
+ // removal needs the census as evidence, not a cleanup rationale.
+ const snakeAdapter = {
+ find: vi.fn(),
+ getObjectSchema: vi.fn().mockResolvedValue({ name: 'account', list_views: { hot: HOT_VIEW } }),
+ };
+ const result = await resolved(
+ { type: 'list-view', dataSource: { object: 'account', view: 'hot' } },
+ FULL,
+ snakeAdapter,
+ );
+ expect(result.current.status).toBe('resolved');
+ expect(result.current.schema.columns).toEqual(['name', 'rating']);
+ expect(result.current.schema.filter).toEqual([['rating', '=', 'hot']]);
+ });
+
it('lets an authored key win over the same key from the view', async () => {
const result = await resolved(
{
diff --git a/packages/react/src/hooks/useElementDataSource.ts b/packages/react/src/hooks/useElementDataSource.ts
index 6c05149bf0..dab70a5a82 100644
--- a/packages/react/src/hooks/useElementDataSource.ts
+++ b/packages/react/src/hooks/useElementDataSource.ts
@@ -101,6 +101,8 @@ async function fetchSavedViews(
const embedded = canReadObjectDef
? dataSource!.getObjectSchema!(object).then(
+ // `listViews` is canonical (#5362); the snake leg is a compatibility READ for
+ // stored pre-settlement documents (never censused: objectstack#7917).
(def) => (isRecord(def) ? (def.listViews ?? def.list_views) : undefined),
() => undefined,
)
diff --git a/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts
new file mode 100644
index 0000000000..9bf2d39e82
--- /dev/null
+++ b/packages/types/src/__tests__/object-schema-metadata-spec-derivation.test.ts
@@ -0,0 +1,190 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `ObjectSchemaMetadata` ↔ `@objectstack/spec` derivation guard (objectui#5362).
+ *
+ * The maintainer ruled (2026-08-20, on #5362) that the object document type
+ * belongs to `@objectstack/spec` and objectui DERIVES it rather than keeping a
+ * hand-written second declaration. The hand-written interface this replaced
+ * declared none of `titleFormat` / `listViews` / `icon` — three keys the
+ * shipped runtime reads — while declaring nine members nothing reads and the
+ * spec does not know. This guard is the forcing function that keeps the
+ * settlement settled:
+ *
+ * 1. The derivation is structural — `ObjectSchemaMetadata` is the spec's
+ * `ServiceObject` plus the measured client delta, nothing else. Reverting
+ * the alias to a hand-written interface fails the `Equal` assertion and
+ * the three-key fixture below.
+ * 2. The three read-but-formerly-undeclared keys are admitted, at compile
+ * time (typed fixture) and at runtime (spec zod shape).
+ * 3. The SPELLING is settled: camelCase `listViews` is declared; snake
+ * `list_views` is not a key of the document type or the spec schema.
+ * The runtime keeps a snake READ fallback for stored app data published
+ * before this settlement (never censused — objectstack#7917); that
+ * tolerance lives at the read sites and must NOT leak back into the
+ * declared type, which is what the `@ts-expect-error` below pins.
+ * 4. The client-extension surface stays measured: every member must have a
+ * live runtime read. Growing `ObjectSchemaClientExtensions` fails the
+ * key-set assertion, forcing the promote-upstream / justify-here
+ * decision consciously (same forcing function as
+ * `object-view-spec-parity.test.ts`).
+ *
+ * When one of these fails, do NOT edit this file first. Decide whether the
+ * key belongs upstream in `@objectstack/spec` (promote it — the #5362 route
+ * for the three keys was exactly that, objectstack#10144) or is a genuine
+ * objectui-only runtime read (add it to the extension with its read site).
+ */
+import { describe, it, expect } from 'vitest';
+import { ObjectSchema as SpecObjectSchema } from '@objectstack/spec/data';
+import type { ServiceObject } from '@objectstack/spec/data';
+import type {
+ ObjectSchemaMetadata,
+ ObjectSchemaClientExtensions,
+} from '../field-types.js';
+
+// ── compile-time half ────────────────────────────────────────────────────────
+// Compiled by `tsconfig.test.json` via this package's `type-check` script —
+// see that file's header for why tests must be in a compiled project at all
+// (objectstack#4074: `satisfies` contracts that no tsc invocation ever read).
+
+type Equal = (() => T extends A ? 1 : 2) extends () => T extends B
+ ? 1
+ : 2
+ ? true
+ : false;
+type Assert = T;
+
+// 1. Structural derivation — the alias IS spec type + client delta.
+type _derivation = Assert<
+ Equal
+>;
+
+// 4. The client delta stays exactly the measured set. `editMode` is read by
+// `@object-ui/app-shell` (`utils/recordFormNavigation.ts`, routed by
+// `AppContent`'s central `handleEdit` dispatcher).
+type _extensionSurface = Assert<
+ Equal
+>;
+
+// 2. The three keys #5362 measured as read-but-undeclared are admitted, and
+// a typed document can carry the client extension alongside them.
+const derivedAdmitsTheThreeKeys: ObjectSchemaMetadata = {
+ name: 'task',
+ fields: {
+ subject: { type: 'text', label: 'Subject', required: true },
+ },
+ icon: 'check-square',
+ titleFormat: '${subject}',
+ listViews: {
+ all: { label: 'All Tasks', columns: ['subject'] },
+ },
+ editMode: 'page',
+};
+
+// 3. Snake `list_views` is NOT part of the declared surface. If this
+// directive ever reports as unused, the snake spelling has leaked back into
+// the type — that is a regression of the #5362 settlement, not a cleanup.
+const snakeSpellingIsNotDeclared: ObjectSchemaMetadata = {
+ name: 'task',
+ fields: {},
+ // @ts-expect-error -- `list_views` was never a spec key; the spec declares only camelCase `listViews` (#5362).
+ list_views: {},
+};
+
+// ── runtime half ─────────────────────────────────────────────────────────────
+
+/** Top-level keys of a zod object (same helper as the sibling parity guards). */
+function shapeKeys(schema: unknown): string[] {
+ const carrier = schema as { shape?: unknown; _def?: { shape?: unknown } };
+ const shape = carrier?.shape ?? carrier?._def?.shape;
+ const resolved = typeof shape === 'function' ? (shape as () => object)() : shape;
+ return resolved && typeof resolved === 'object' ? Object.keys(resolved) : [];
+}
+
+describe('ObjectSchemaMetadata spec derivation (#5362)', () => {
+ const keys = shapeKeys(SpecObjectSchema);
+
+ it('reads a live spec shape (positive control for every absence below)', () => {
+ // A broken import or an empty shape would make every `not.toContain`
+ // below pass vacuously; pin the corpus first.
+ expect(keys.length).toBeGreaterThan(30);
+ expect(keys).toContain('name');
+ expect(keys).toContain('fields');
+ });
+
+ it('declares the three keys the runtime reads: icon, titleFormat, listViews', () => {
+ expect(keys).toContain('icon');
+ expect(keys).toContain('titleFormat');
+ expect(keys).toContain('listViews');
+ });
+
+ it('canonizes camelCase listViews — snake list_views is not a spec key', () => {
+ expect(keys).not.toContain('list_views');
+ });
+
+ it('does not declare the nine members the retired hand-written interface invented', () => {
+ // These were declared by the old mirror, read by nothing in this repo,
+ // and absent from the spec document — dropping them followed the spec.
+ for (const retired of [
+ 'extends',
+ 'triggers',
+ 'primary_key',
+ 'relationships',
+ 'name_field',
+ 'soft_delete',
+ 'audit_trail',
+ 'version',
+ 'cache',
+ ]) {
+ expect(keys).not.toContain(retired);
+ }
+ // The spelling sibling: the identity the old `name_field` member wanted
+ // exists in the spec under its camelCase key.
+ expect(keys).toContain('nameField');
+ });
+
+ it('does not declare the client extension — editMode stays an objectui-side member', () => {
+ // If this starts failing, the spec has adopted `editMode`: retire it from
+ // `ObjectSchemaClientExtensions` and let the derivation carry it.
+ expect(keys).not.toContain('editMode');
+ });
+
+ it('accepts the spec-shaped document through the spec parse (value reachability, not just key presence)', () => {
+ const { editMode: _clientOnly, ...specShaped } = derivedAdmitsTheThreeKeys;
+ const parsed = SpecObjectSchema.safeParse(specShaped);
+ expect(parsed.success).toBe(true);
+ });
+
+ it('rejects the client extension at the spec parse — editMode is client-side ONLY', () => {
+ // The spec parse is strict on unrecognized keys (objectstack#4001), so a
+ // published document carrying `editMode` is rejected loudly, not dropped.
+ // This pins what "client extension" means: the member exists on the CLIENT
+ // type for documents served by client-side data sources, and it cannot
+ // ride a spec-validated publish path. If the spec ever adopts `editMode`,
+ // this test and the absence test above both flip — retire the extension
+ // member and let the derivation carry the key.
+ const parsed = SpecObjectSchema.safeParse(derivedAdmitsTheThreeKeys);
+ expect(parsed.success).toBe(false);
+ if (parsed.success) return;
+ const unrecognized = parsed.error.issues.find(
+ (issue) => issue.code === 'unrecognized_keys',
+ );
+ expect(unrecognized).toBeDefined();
+ expect(
+ (unrecognized as { keys?: string[] } | undefined)?.keys,
+ ).toEqual(['editMode']);
+ });
+
+ it('keeps the compile-time fixtures alive', () => {
+ expect(derivedAdmitsTheThreeKeys.listViews).toBeDefined();
+ expect(derivedAdmitsTheThreeKeys.icon).toBe('check-square');
+ expect(derivedAdmitsTheThreeKeys.titleFormat).toBe('${subject}');
+ expect(snakeSpellingIsNotDeclared.name).toBe('task');
+ });
+});
diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts
index 411cee0cc0..a7f281ff77 100644
--- a/packages/types/src/field-types.ts
+++ b/packages/types/src/field-types.ts
@@ -6,8 +6,6 @@
* LICENSE file in the root directory of this source tree.
*/
-import type { ManagedByBucket } from './managed-by.js';
-
/**
* @object-ui/types - Field Type Definitions
*
@@ -847,75 +845,48 @@ export interface ObjectTrigger {
}
/**
- * Object schema definition
- * Phase 3.1: Enhanced with inheritance, triggers, permissions, and caching
+ * Object document type — derived from `@objectstack/spec/data` rather than
+ * restated (objectui#5362; maintainer ruling 2026-08-20: the object document
+ * type belongs to `@objectstack/spec`, objectui derives rather than
+ * hand-copies — the same layer split objectui#3074 applied to
+ * `PageNodeSchema` and objectstack#4115 applied to `ObjectIndex` below).
+ *
+ * The hand-written interface this replaces had drifted in both directions:
+ *
+ * - It declared NONE of `titleFormat`, `listViews`, `icon` — three keys the
+ * shipped runtime reads (objectui#5362 lists the read sites), so a document
+ * annotated with this type got excess-property errors on keys the renderer
+ * then happily consumed.
+ * - It declared nine members no objectui runtime code reads and the spec's
+ * object document does not know: `extends`, `triggers`, `primary_key`,
+ * `relationships`, `name_field` (the spec key is `nameField`),
+ * `soft_delete`, `audit_trail`, `version`, `cache`.
+ *
+ * Spelling (objectui#5362): the spec declares only camelCase `listViews`;
+ * `list_views` appears nowhere in `@objectstack/spec` 17.2.0's `dist/`. The
+ * runtime keeps a snake-spelling READ fallback for stored app data published
+ * before this settlement (that stock has never been censused —
+ * objectstack#7917); the tolerance lives at the read sites, deliberately not
+ * in this type: new documents must author `listViews`.
+ *
+ * `ServiceObject` is the AUTHORING shape (`z.input` — pre-parse, defaults
+ * not yet applied), which is what a hand-authored or served object document
+ * is before validation. The post-parse shape is the spec's
+ * `ServiceObjectParsed`.
*/
-export interface ObjectSchemaMetadata {
- /**
- * Object name
- */
- name: string;
-
- /**
- * Display label
- */
- label?: string;
-
- /**
- * Object description
- */
- description?: string;
-
- /**
- * Fields definition
- */
- fields: Record;
-
- /**
- * Parent object to inherit from (Phase 3.1.2)
- */
- extends?: string;
-
- /**
- * Triggers configuration (Phase 3.1.3)
- */
- triggers?: ObjectTrigger[];
-
- /**
- * Primary key field
- */
- primary_key?: string;
-
- /**
- * Indexes for optimization
- */
- indexes?: ObjectIndex[];
-
- /**
- * Relationships with other objects
- */
- relationships?: ObjectRelationship[];
-
- /**
- * Record naming pattern
- */
- name_field?: string;
-
- /**
- * Soft delete configuration
- */
- soft_delete?: boolean;
-
- /**
- * Audit trail configuration
- */
- audit_trail?: boolean;
-
- /**
- * Schema version
- */
- version?: string;
-
+import type { ServiceObject } from '@objectstack/spec/data';
+
+/**
+ * Client-side members the objectui runtime reads on the object document but
+ * `@objectstack/spec` does not declare. Every member here must cite a live
+ * runtime read — this interface is the measured client DELTA on top of the
+ * spec document, not a place to restate spec keys (restating them would
+ * recreate the hand-written fork objectui#5362 retired). The member list is
+ * pinned by `__tests__/object-schema-metadata-spec-derivation.test.ts`, so
+ * growing it is a conscious decision: promote the key upstream to the spec,
+ * or add it here with the runtime read that justifies it.
+ */
+export interface ObjectSchemaClientExtensions {
/**
* Default UI mode for record create/edit interactions.
*
@@ -930,55 +901,22 @@ export interface ObjectSchemaMetadata {
* shareable links to the create/edit form.
*
* The host application reads this flag in its central `handleEdit`
- * dispatcher (see `@object-ui/app-shell` `AppContent`) — switching the
- * value requires no code changes.
+ * dispatcher (see `@object-ui/app-shell` `AppContent` and
+ * `utils/recordFormNavigation.ts`) — switching the value requires no code
+ * changes.
*
* @default 'modal'
*/
editMode?: 'modal' | 'page';
-
- /**
- * Object lifecycle bucket — sets the default CRUD affordances and the write
- * policy (ADR-0103). The enforced policy is the *resolved affordance*
- * (bucket default + `userActions`), computed by `resolveCrudAffordances`
- * in `@object-ui/core` — not the bare bucket.
- *
- * - `'platform'` (default) — ObjectStack-owned business data; full CRUD.
- * - `'config'` — admin-authored configuration; New / Edit / Delete, no import.
- * - `'system-data'` — platform-defined schema holding admin/user-writable
- * DATA (RBAC links, prefs, messaging config); full CRUD by default,
- * `userActions` NARROWS. Renamed from the residual `'system'` in protocol
- * 17 (objectstack#3355).
- * - `'engine-owned'` — runtime rows a platform service owns end to end; no
- * user writes.
- * - `'append-only'` — immutable audit trail; View + Export only.
- * - `'better-auth'` — identity tables owned by the auth driver; generic
- * user-context writes are suppressed (they bypass password hashing,
- * session validation, audit hooks) and flow through the auth API instead.
- *
- * @default 'platform'
- */
- managedBy?: ManagedByBucket;
-
- /**
- * Cache configuration (Phase 3.1.5)
- */
- cache?: {
- /**
- * Enable metadata caching
- */
- enabled?: boolean;
- /**
- * Cache TTL in seconds
- */
- ttl?: number;
- /**
- * Cache invalidation strategy
- */
- invalidation?: 'time' | 'event' | 'manual';
- };
}
+/**
+ * Object schema definition — the object document a data source's
+ * `getObjectSchema(objectName)` serves, in its authoring shape, plus the
+ * measured objectui client extensions above.
+ */
+export type ObjectSchemaMetadata = ServiceObject & ObjectSchemaClientExtensions;
+
/**
* Object index configuration — re-exported from `@objectstack/spec/data`
* rather than restated (objectstack#4115).
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index c23c760bf7..e12c938451 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -454,6 +454,7 @@ export type {
FieldMetadata,
ObjectTrigger,
ObjectSchemaMetadata,
+ ObjectSchemaClientExtensions,
ObjectIndex,
ObjectRelationship,
} from './field-types.js';