From f2800b73685c5a803b05a3ae4d577735a3e4fce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 02:08:38 +0000 Subject: [PATCH] refactor(managedBy): consolidate lifecycle-bucket logic into one shared source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managedBy bucket taxonomy was hand-mirrored across crudAffordances.ts, ManagedByBadge.tsx (own Bucket union + isWriteOptedIn + writable-system derivation), and plugin-detail record-details.tsx (NON_EDITABLE_BUCKETS, duplicated because it can't depend on app-shell) — a drift risk. The schema managedBy type was also open-ended ((string & {})), so unknown buckets slipped through and silently defaulted to fully-editable. - @object-ui/types owns the closed ManagedByBucket union (+ MANAGED_BY_BUCKETS); ObjectSchema.managedBy is tightened to it (unknown buckets now a type error). - @object-ui/core owns the React-free runtime logic (resolveCrudAffordances, isWriteOptedIn, isSystemWritable, isObjectInlineEditable), reachable by every UI package including plugin-detail. - app-shell crudAffordances.ts is now a re-export shim; ManagedByBadge uses the shared isSystemWritable; record-details uses isObjectInlineEditable in place of the hand-mirrored NON_EDITABLE_BUCKETS. Behavior-preserving: full-graph build typechecks; existing affordance/edit-gate tests stay green; new core unit tests cover the logic incl. the previously untested isSystemWritable. Follows framework ADR-0103. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fp9yZxRQ3mb7p4vVwqFXKE --- .../managedby-single-source-of-truth.md | 14 ++ .../src/components/ManagedByBadge.tsx | 15 +- .../app-shell/src/utils/crudAffordances.ts | 131 +++-------------- packages/core/src/index.ts | 1 + packages/core/src/utils/managedBy.test.ts | 103 +++++++++++++ packages/core/src/utils/managedBy.ts | 138 ++++++++++++++++++ .../src/renderers/record-details.tsx | 28 ++-- packages/types/src/field-types.ts | 26 ++-- packages/types/src/index.ts | 2 + packages/types/src/managed-by.ts | 32 ++++ 10 files changed, 341 insertions(+), 149 deletions(-) create mode 100644 .changeset/managedby-single-source-of-truth.md create mode 100644 packages/core/src/utils/managedBy.test.ts create mode 100644 packages/core/src/utils/managedBy.ts create mode 100644 packages/types/src/managed-by.ts diff --git a/.changeset/managedby-single-source-of-truth.md b/.changeset/managedby-single-source-of-truth.md new file mode 100644 index 000000000..54027165f --- /dev/null +++ b/.changeset/managedby-single-source-of-truth.md @@ -0,0 +1,14 @@ +--- +"@object-ui/types": patch +"@object-ui/core": patch +"@object-ui/app-shell": patch +"@object-ui/plugin-detail": patch +--- + +**Consolidate the `managedBy` lifecycle-bucket logic into one shared source of truth (follows framework ADR-0103).** The bucket taxonomy was hand-mirrored in several places — `crudAffordances.ts`, `ManagedByBadge.tsx` (its own `Bucket` union + `isWriteOptedIn` + the writable-system derivation), and `plugin-detail`'s `record-details.tsx` (`NON_EDITABLE_BUCKETS`, duplicated because it can't depend on app-shell) — a drift risk, and the object-schema `managedBy` type was open-ended (`(string & {})`) so unknown buckets slipped through and silently defaulted to fully-editable. + +- **`@object-ui/types`** now owns the closed `ManagedByBucket` union (+ `MANAGED_BY_BUCKETS`), and `ObjectSchema.managedBy` is tightened from `'platform' | 'better-auth' | (string & {})` to that union — unknown buckets are now a type error at authoring time. +- **`@object-ui/core`** now owns the React-free runtime logic — `resolveCrudAffordances`, `isWriteOptedIn`, `isSystemWritable`, `isObjectInlineEditable` — reachable by every UI package including `plugin-detail` (which could not import app-shell). +- **`app-shell/utils/crudAffordances.ts`** is now a thin re-export of `@object-ui/core` (existing imports keep working); `ManagedByBadge` consumes the shared `isSystemWritable`; `plugin-detail` `record-details.tsx` replaces its hand-mirrored `NON_EDITABLE_BUCKETS` with `isObjectInlineEditable`. + +Behavior-preserving — all existing affordance/edit-gate tests stay green; the shared module adds direct unit coverage (including the previously-untested `isSystemWritable` derivation). Translated copy (badge variants, empty-state messages) stays in app-shell. diff --git a/packages/app-shell/src/components/ManagedByBadge.tsx b/packages/app-shell/src/components/ManagedByBadge.tsx index 3635ca4e5..b553c3297 100644 --- a/packages/app-shell/src/components/ManagedByBadge.tsx +++ b/packages/app-shell/src/components/ManagedByBadge.tsx @@ -8,6 +8,7 @@ import { cn, } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; +import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances'; /** * ManagedByBadge — replaces the verbose, full-width `ManagedByBanner` with @@ -43,11 +44,11 @@ import { useObjectTranslation } from '@object-ui/i18n'; * `ObjectView` via `resolveManagedByEmptyState()`. */ -type Bucket = 'platform' | 'config' | 'system' | 'append-only' | 'better-auth'; +type Bucket = ManagedByBucket; /** * Subset of `userActions` (ADR-0103) the badge needs to tell an engine-owned - * `system` object apart from an admin/user-writable one. Mirrors the server's + * `system` object apart from an admin/user-writable one. Mirrors the shared * `resolveCrudAffordances` inputs; `edit`/`delete` accept the #2614 object form. */ export interface ManagedByUserActions { @@ -56,11 +57,6 @@ export interface ManagedByUserActions { delete?: boolean | { enabled?: boolean }; } -/** True only when a userActions flag (bare boolean or object form) opts the write in. */ -function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { - return v === true || (typeof v === 'object' && v !== null && v.enabled === true); -} - export interface ManagedByBadgeProps { /** The `managedBy` flag from the object schema. */ managedBy?: string; @@ -149,10 +145,7 @@ export function ManagedByBadge({ managedBy, userActions, label, className }: Man if (!managedBy || managedBy === 'platform') return null; // ADR-0103 — a `system` object that opened any write is admin/user-writable // data, not an engine-owned monitoring surface: pick the writable variant/copy. - const ua = userActions ?? undefined; - const systemWritable = - managedBy === 'system' && - (ua?.create === true || isWriteOptedIn(ua?.edit) || isWriteOptedIn(ua?.delete)); + const systemWritable = isSystemWritable({ managedBy, userActions }); const variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey); const variant = VARIANTS[variantKey]; if (!variant) return null; diff --git a/packages/app-shell/src/utils/crudAffordances.ts b/packages/app-shell/src/utils/crudAffordances.ts index 9c11e987f..6603038f7 100644 --- a/packages/app-shell/src/utils/crudAffordances.ts +++ b/packages/app-shell/src/utils/crudAffordances.ts @@ -1,113 +1,26 @@ /** - * crudAffordances — UI-side mirror of the framework's - * `resolveCrudAffordances()` helper (see - * `@objectstack/spec/data/object.zod.ts`). + * crudAffordances — re-export shim. * - * The framework tags every object with a `managedBy` lifecycle bucket - * (`platform | config | system | append-only | better-auth`) and an - * optional `userActions` override block. UI components ask this helper - * "should I show the New / Import / Edit / Delete / Export buttons?" so - * the toolbar tracks the object's lifecycle automatically — no need for - * each view to special-case `sys_*` names. - * - * Keep this in lockstep with the framework helper. The bucket defaults - * mirror what Salesforce / ServiceNow / Workday / Notion do for the - * equivalent categories of system tables. - */ - -export type ManagedByBucket = - | 'platform' - | 'config' - | 'system' - | 'append-only' - | 'better-auth'; - -export interface CrudAffordances { - /** Generic "New" button for single-record creation. */ - create: boolean; - /** CSV bulk-import wizard. */ - import: boolean; - /** Inline + form editing of existing rows. */ - edit: boolean; - /** Row-level + bulk delete. */ - delete: boolean; - /** CSV / clipboard export. */ - exportCsv: boolean; - /** - * Per-record CEL predicates for the built-in row Edit/Delete actions, - * present only when `userActions.edit` / `delete` used the object form - * (objectui#2614). Carried through as authored (bare CEL string or - * `{ dialect, source }` envelope) for row renderers to evaluate via - * `useRowPredicate`; they never affect the object-level booleans above. - */ - editPredicates?: RowCrudPredicates; - deletePredicates?: RowCrudPredicates; -} - -/** Per-record predicates from the #2614 object form of a userActions flag. */ -export interface RowCrudPredicates { - visibleWhen?: unknown; - disabledWhen?: unknown; -} - -/** `edit`/`delete` accept a bare boolean or the #2614 object form. */ -export type UserActionOverride = - | boolean - | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown }; - -export interface UserActionsOverride { - create?: boolean; - import?: boolean; - edit?: UserActionOverride; - delete?: UserActionOverride; - exportCsv?: boolean; -} - -const DEFAULTS: Record = { - platform: { create: true, import: true, edit: true, delete: true, exportCsv: true }, - config: { create: true, import: false, edit: true, delete: true, exportCsv: true }, - system: { create: false, import: false, edit: false, delete: false, exportCsv: true }, - 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true }, - 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true }, -}; - -export interface SchemaLike { - managedBy?: string | null; - userActions?: UserActionsOverride | null; -} - -/** - * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto - * the bucket default, surfacing any per-record predicates alongside. + * The canonical bucket/affordance logic now lives in `@object-ui/core` + * (`utils/managedBy.ts`) so it is defined ONCE and shared by every UI package + * — app-shell, plugin-detail, plugin-form, plugin-grid — instead of being + * hand-mirrored. This file is kept so existing app-shell imports of + * `./crudAffordances` keep working; prefer importing from `@object-ui/core` + * directly in new code. */ -function normalizeOverride( - v: UserActionOverride | undefined | null, - base: boolean, -): { enabled: boolean; predicates?: RowCrudPredicates } { - if (v == null) return { enabled: base }; - if (typeof v === 'boolean') return { enabled: v }; - const enabled = v.enabled ?? base; - if (v.visibleWhen == null && v.disabledWhen == null) return { enabled }; - const predicates: RowCrudPredicates = {}; - if (v.visibleWhen != null) predicates.visibleWhen = v.visibleWhen; - if (v.disabledWhen != null) predicates.disabledWhen = v.disabledWhen; - return { enabled, predicates }; -} -export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances { - const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform'; - const base = DEFAULTS[bucket] ?? DEFAULTS.platform; - const o = obj?.userActions ?? {}; - const edit = normalizeOverride(o.edit, base.edit); - const del = normalizeOverride(o.delete, base.delete); - const out: CrudAffordances = { - create: o.create ?? base.create, - import: o.import ?? base.import, - edit: edit.enabled, - delete: del.enabled, - exportCsv: o.exportCsv ?? base.exportCsv, - }; - if (edit.predicates) out.editPredicates = edit.predicates; - if (del.predicates) out.deletePredicates = del.predicates; - return out; -} +export type { + ManagedByBucket, + CrudAffordances, + RowCrudPredicates, + UserActionOverride, + UserActionsOverride, + SchemaLike, +} from '@object-ui/core'; + +export { + resolveCrudAffordances, + isWriteOptedIn, + isSystemWritable, + isObjectInlineEditable, +} from '@object-ui/core'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 70659d000..2d172ce8a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export * from './registry/WidgetRegistry.js'; export * from './validation/index.js'; export * from './builder/schema-builder.js'; export * from './utils/filter-converter.js'; +export * from './utils/managedBy.js'; export * from './utils/extract-records.js'; export * from './utils/expand-fields.js'; export * from './evaluator/index.js'; diff --git a/packages/core/src/utils/managedBy.test.ts b/packages/core/src/utils/managedBy.test.ts new file mode 100644 index 000000000..f534b0e83 --- /dev/null +++ b/packages/core/src/utils/managedBy.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { MANAGED_BY_BUCKETS } from '@object-ui/types'; +import { + resolveCrudAffordances, + isWriteOptedIn, + isSystemWritable, + isObjectInlineEditable, +} from './managedBy'; + +describe('resolveCrudAffordances (shared source of truth)', () => { + it('defaults to the platform bucket (full CRUD) when managedBy is unset', () => { + expect(resolveCrudAffordances({})).toEqual({ + create: true, import: true, edit: true, delete: true, exportCsv: true, + }); + expect(resolveCrudAffordances(null)).toEqual(resolveCrudAffordances({ managedBy: 'platform' })); + }); + + it('config: New/Edit/Delete + export, no import', () => { + expect(resolveCrudAffordances({ managedBy: 'config' })).toEqual({ + create: true, import: false, edit: true, delete: true, exportCsv: true, + }); + }); + + it('system / append-only / better-auth: export-only by default', () => { + for (const managedBy of ['system', 'append-only', 'better-auth']) { + expect(resolveCrudAffordances({ managedBy })).toEqual({ + create: false, import: false, edit: false, delete: false, exportCsv: true, + }); + } + }); + + it('userActions overrides the bucket default (ADR-0103 writable system)', () => { + const aff = resolveCrudAffordances({ managedBy: 'system', userActions: { create: true, edit: true, delete: true } }); + expect(aff).toMatchObject({ create: true, edit: true, delete: true, import: false }); + }); + + it('unknown bucket falls back to platform (defensive)', () => { + expect(resolveCrudAffordances({ managedBy: 'totally-unknown' }).edit).toBe(true); + }); + + it('#2614 object form: carries predicates, keys off enabled, boolean path unchanged', () => { + const withPreds = resolveCrudAffordances({ + managedBy: 'platform', + userActions: { edit: { enabled: true, disabledWhen: 'record.locked == true' } }, + }); + expect(withPreds.edit).toBe(true); + expect(withPreds.editPredicates).toEqual({ disabledWhen: 'record.locked == true' }); + // enabled omitted → falls back to the bucket default (platform edit = true) + expect(resolveCrudAffordances({ managedBy: 'platform', userActions: { edit: { disabledWhen: 'x' } } }).edit).toBe(true); + // boolean form leaves predicates absent + expect(resolveCrudAffordances({ managedBy: 'system', userActions: { edit: true } }).editPredicates).toBeUndefined(); + }); +}); + +describe('isWriteOptedIn', () => { + it('true only for boolean true or { enabled: true }', () => { + expect(isWriteOptedIn(true)).toBe(true); + expect(isWriteOptedIn({ enabled: true })).toBe(true); + expect(isWriteOptedIn(false)).toBe(false); + expect(isWriteOptedIn({ enabled: false })).toBe(false); + expect(isWriteOptedIn({ disabledWhen: 'x' })).toBe(false); + expect(isWriteOptedIn(undefined)).toBe(false); + expect(isWriteOptedIn(null)).toBe(false); + }); +}); + +describe('isSystemWritable (ADR-0103)', () => { + it('true only for a system object that opened create, edit, or delete', () => { + expect(isSystemWritable({ managedBy: 'system', userActions: { create: true } })).toBe(true); + expect(isSystemWritable({ managedBy: 'system', userActions: { edit: { enabled: true } } })).toBe(true); + expect(isSystemWritable({ managedBy: 'system', userActions: { delete: true } })).toBe(true); + }); + it('false for engine-owned system and for other buckets even with userActions', () => { + expect(isSystemWritable({ managedBy: 'system' })).toBe(false); + expect(isSystemWritable({ managedBy: 'system', userActions: { edit: false } })).toBe(false); + // append-only / better-auth are never "system-writable" regardless of userActions + expect(isSystemWritable({ managedBy: 'append-only', userActions: { create: true } })).toBe(false); + expect(isSystemWritable({ managedBy: 'better-auth', userActions: { edit: true } })).toBe(false); + expect(isSystemWritable({ managedBy: 'platform' })).toBe(false); + expect(isSystemWritable(null)).toBe(false); + }); +}); + +describe('isObjectInlineEditable', () => { + it('mirrors the resolved edit affordance (replaces the old NON_EDITABLE_BUCKETS set)', () => { + // Non-editable buckets by default... + for (const managedBy of ['system', 'append-only', 'better-auth']) { + expect(isObjectInlineEditable({ managedBy })).toBe(false); + } + // ...editable buckets and opened-up system objects. + expect(isObjectInlineEditable({ managedBy: 'platform' })).toBe(true); + expect(isObjectInlineEditable({ managedBy: 'config' })).toBe(true); + expect(isObjectInlineEditable({ managedBy: 'system', userActions: { edit: true } })).toBe(true); + // an explicit edit:false disables even on an otherwise-editable bucket + expect(isObjectInlineEditable({ managedBy: 'platform', userActions: { edit: false } })).toBe(false); + }); +}); + +describe('MANAGED_BY_BUCKETS', () => { + it('is the closed 5-bucket union in canonical order', () => { + expect(MANAGED_BY_BUCKETS).toEqual(['platform', 'config', 'system', 'append-only', 'better-auth']); + }); +}); diff --git a/packages/core/src/utils/managedBy.ts b/packages/core/src/utils/managedBy.ts new file mode 100644 index 000000000..68f5f024d --- /dev/null +++ b/packages/core/src/utils/managedBy.ts @@ -0,0 +1,138 @@ +/** + * managedBy — the single source of truth for interpreting an object's + * lifecycle bucket (`ObjectSchema.managedBy`) into CRUD affordances. + * + * UI-side mirror of the framework's `resolveCrudAffordances()` + * (`@objectstack/spec/data/object.zod.ts`, ADR-0103). Lives in `@object-ui/core` + * — React-free and reachable by every UI package (app-shell, plugin-detail, + * plugin-form, plugin-grid) — so the bucket logic is defined ONCE instead of + * hand-mirrored in each. Components ask "should I show New / Import / Edit / + * Delete / Export?" and get an answer that tracks the object's lifecycle without + * special-casing `sys_*` names. + * + * The bucket UNION type lives in `@object-ui/types` (`ManagedByBucket`) so the + * schema field can reference it; this module re-exports it for convenience. + * Only the affordance BOOLEANS live here — all translated copy (badge variants, + * empty-state messages) stays in `@object-ui/app-shell`. + */ + +import type { ManagedByBucket } from '@object-ui/types'; + +export type { ManagedByBucket } from '@object-ui/types'; + +export interface CrudAffordances { + /** Generic "New" button for single-record creation. */ + create: boolean; + /** CSV bulk-import wizard. */ + import: boolean; + /** Inline + form editing of existing rows. */ + edit: boolean; + /** Row-level + bulk delete. */ + delete: boolean; + /** CSV / clipboard export. */ + exportCsv: boolean; + /** + * Per-record CEL predicates for the built-in row Edit/Delete actions, + * present only when `userActions.edit` / `delete` used the object form + * (objectui#2614). Carried through as authored for row renderers to evaluate; + * they never affect the object-level booleans above. + */ + editPredicates?: RowCrudPredicates; + deletePredicates?: RowCrudPredicates; +} + +/** Per-record predicates from the #2614 object form of a userActions flag. */ +export interface RowCrudPredicates { + visibleWhen?: unknown; + disabledWhen?: unknown; +} + +/** `edit`/`delete` accept a bare boolean or the #2614 object form. */ +export type UserActionOverride = + | boolean + | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown }; + +export interface UserActionsOverride { + create?: boolean; + import?: boolean; + edit?: UserActionOverride; + delete?: UserActionOverride; + exportCsv?: boolean; +} + +export interface SchemaLike { + managedBy?: string | null; + userActions?: UserActionsOverride | null; +} + +const DEFAULTS: Record = { + platform: { create: true, import: true, edit: true, delete: true, exportCsv: true }, + config: { create: true, import: false, edit: true, delete: true, exportCsv: true }, + system: { create: false, import: false, edit: false, delete: false, exportCsv: true }, + 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true }, + 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true }, +}; + +/** + * Collapse an `edit`/`delete` override (boolean or #2614 object form) onto + * the bucket default, surfacing any per-record predicates alongside. + */ +function normalizeOverride( + v: UserActionOverride | undefined | null, + base: boolean, +): { enabled: boolean; predicates?: RowCrudPredicates } { + if (v == null) return { enabled: base }; + if (typeof v === 'boolean') return { enabled: v }; + const enabled = v.enabled ?? base; + if (v.visibleWhen == null && v.disabledWhen == null) return { enabled }; + const predicates: RowCrudPredicates = {}; + if (v.visibleWhen != null) predicates.visibleWhen = v.visibleWhen; + if (v.disabledWhen != null) predicates.disabledWhen = v.disabledWhen; + return { enabled, predicates }; +} + +/** Resolve the effective CRUD affordances for an object schema. */ +export function resolveCrudAffordances(obj: SchemaLike | null | undefined): CrudAffordances { + const bucket = (obj?.managedBy as ManagedByBucket | undefined) ?? 'platform'; + const base = DEFAULTS[bucket] ?? DEFAULTS.platform; + const o = obj?.userActions ?? {}; + const edit = normalizeOverride(o.edit, base.edit); + const del = normalizeOverride(o.delete, base.delete); + const out: CrudAffordances = { + create: o.create ?? base.create, + import: o.import ?? base.import, + edit: edit.enabled, + delete: del.enabled, + exportCsv: o.exportCsv ?? base.exportCsv, + }; + if (edit.predicates) out.editPredicates = edit.predicates; + if (del.predicates) out.deletePredicates = del.predicates; + return out; +} + +/** True only when a `userActions` flag (bare boolean or #2614 object form) opts the write in. */ +export function isWriteOptedIn(v: UserActionOverride | undefined | null): boolean { + return v === true || (typeof v === 'object' && v !== null && v.enabled === true); +} + +/** + * ADR-0103 — a `system`-bucket object that opened ANY write via `userActions` + * is admin/user-writable DATA, not an engine-owned monitoring surface. Used to + * pick the writable-system badge/empty-state copy. + */ +export function isSystemWritable(obj: SchemaLike | null | undefined): boolean { + if (obj?.managedBy !== 'system') return false; + const o = obj?.userActions ?? {}; + return o.create === true || isWriteOptedIn(o.edit) || isWriteOptedIn(o.delete); +} + +/** + * Whether an object's rows may be edited in place (detail inline-edit / form + * fields) — the resolved `edit` affordance. Engine-owned `system` / + * `append-only` / `better-auth` objects resolve to `false` unless they opened + * `userActions.edit`. (Per-record predicates gate row action buttons, not + * object-level editability, so only the resolved boolean matters here.) + */ +export function isObjectInlineEditable(obj: SchemaLike | null | undefined): boolean { + return resolveCrudAffordances(obj).edit; +} diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 7fb5cbaaf..98a087f53 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -15,6 +15,7 @@ import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel } from '@ob import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { RecordDetailsComponentProps } from '@object-ui/types'; +import { isObjectInlineEditable } from '@object-ui/core'; import { DetailView } from '../DetailView'; /** Normalize a field entry (string | {field} | {name}) to its machine name. */ @@ -220,26 +221,15 @@ export const RecordDetailsRenderer: React.FC = ({ : schema.sections; // Inline-edit by default, but gated by the object's lifecycle: system / - // append-only / better-auth objects are not user-editable, so the per-field - // double-click / pencil affordances must not be offered on them. This mirrors - // resolveCrudAffordances (app-shell/utils/crudAffordances.ts) — duplicated - // here because plugin-detail cannot depend on app-shell; keep the two in - // lockstep. Previously this gate was carried only by the `sys_inline_edit` - // header button (removed in #2401); now that double-click is the entry point - // the object-editability check has to live at the field-render source. + // Engine-owned system / append-only / better-auth objects are not + // user-editable, so the per-field double-click / pencil affordances must not + // be offered on them — unless the object opened `userActions.edit` (the + // ADR-0103 admin/user-writable set). This is the shared resolved `edit` + // affordance from `@object-ui/core` (`isObjectInlineEditable`), the single + // source of truth — formerly a hand-mirrored `NON_EDITABLE_BUCKETS` set kept + // in lockstep by hand because plugin-detail can't depend on app-shell. // Authors can still force-disable with `inlineEdit: false`. - const NON_EDITABLE_BUCKETS = new Set(['system', 'append-only', 'better-auth']); - const managedBy = objSchema?.managedBy as string | undefined; - // `userActions.edit` is a boolean or, since #2614, an object form whose - // `enabled` carries the boolean (its per-record predicates gate row action - // buttons, not detail inline-edit, so they are ignored here). - const rawEditOverride = (objSchema?.userActions as { edit?: boolean | { enabled?: boolean } } | undefined)?.edit; - const userEditOverride = - typeof rawEditOverride === 'object' && rawEditOverride !== null - ? rawEditOverride.enabled - : rawEditOverride; - const objectInlineEditable = - userEditOverride ?? !(managedBy != null && NON_EDITABLE_BUCKETS.has(managedBy)); + const objectInlineEditable = isObjectInlineEditable(objSchema); const inlineEditDefault = (schema.inlineEdit ?? true) && objectInlineEditable; const synthesized: any = { diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index d162cd9d4..ee393588f 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -6,6 +6,8 @@ * LICENSE file in the root directory of this source tree. */ +import type { ManagedByBucket } from './managed-by'; + /** * @object-ui/types - Field Type Definitions * @@ -930,20 +932,24 @@ export interface ObjectSchemaMetadata { editMode?: 'modal' | 'page'; /** - * Identifies the upstream system that owns this object's schema. + * 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. * - * - `undefined` or `'platform'` — ObjectStack owns it; the Console - * may freely render create/edit/delete actions. - * - any other string (e.g. `'better-auth'`) — the table is managed - * by a third-party library or service. The Console renders a - * warning banner on list/detail pages and treats the schema as - * read-only by default, because direct writes through the generic - * data API would bypass the owning system's business logic - * (password hashing, session validation, audit hooks, etc.). + * - `'platform'` (default) — ObjectStack-owned business data; full CRUD. + * - `'config'` — admin-authored configuration; New / Edit / Delete, no import. + * - `'system'` — platform-defined schema; engine-owned (read-only) by + * default, unless it declares `userActions` opening writes (the + * admin/user-writable set: RBAC links, prefs, messaging config). + * - `'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?: 'platform' | 'better-auth' | (string & {}); + managedBy?: ManagedByBucket; /** * Cache configuration (Phase 3.1.5) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 49bb5fae2..6b14a1cf8 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -434,6 +434,8 @@ export type { // used by default list-column derivation to keep framework-injected fields // (notably `owner_id`) out of the leading business columns. export { SYSTEM_MANAGED_FIELD_NAMES, isSystemManagedField } from './system-fields'; +export { MANAGED_BY_BUCKETS } from './managed-by'; +export type { ManagedByBucket } from './managed-by'; // ============================================================================ // Phase 3: Data Protocol Advanced Types diff --git a/packages/types/src/managed-by.ts b/packages/types/src/managed-by.ts new file mode 100644 index 000000000..fd357e481 --- /dev/null +++ b/packages/types/src/managed-by.ts @@ -0,0 +1,32 @@ +/** + * `ManagedByBucket` — the object-lifecycle bucket declared on + * `ObjectSchema.managedBy`, the single source of truth for the closed union. + * + * Mirrors the framework's `@objectstack/spec/data/object.zod.ts` taxonomy + * (ADR-0103). The RUNTIME affordance logic that interprets these buckets — + * `resolveCrudAffordances`, `isWriteOptedIn`, `isSystemWritable`, + * `isObjectInlineEditable` — lives in `@object-ui/core` (React-free, reachable + * by every UI package including plugin-detail); the UI copy (badge variants, + * empty-state messages) stays in `@object-ui/app-shell`. Keeping the union here + * lets the schema type reference it and prevents the hand-mirrored bucket lists + * that previously drifted. + * + * NOTE: distinct from the permission-set / metadata-record *provenance* + * `managedBy` (`'platform' | 'package' | 'admin'`), which is an unrelated axis + * that happens to share the word. + */ +export type ManagedByBucket = + | 'platform' + | 'config' + | 'system' + | 'append-only' + | 'better-auth'; + +/** All lifecycle buckets, in canonical order. */ +export const MANAGED_BY_BUCKETS: readonly ManagedByBucket[] = [ + 'platform', + 'config', + 'system', + 'append-only', + 'better-auth', +];