Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/retire-managed-by-system-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@object-ui/types": minor
"@object-ui/core": minor
"@object-ui/app-shell": minor
---

feat!: follow the framework's `managedBy: 'system'` → `'system-data'` retirement (objectstack#3355)

**FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** The framework
retired the residual `system` bucket in protocol 17; this is the UI half of that
change, landing with it so the closed `ManagedByBucket` union stays a mirror
rather than a fork.

ADR-0103 split the overloaded `system` bucket additively in v16 — the
engine-owned objects moved to the explicit `engine-owned`, the admin/user-writable
ones stayed on `system` — which left that value named after the half that had
already moved out. `system-data` names what it actually holds: the SCHEMA is the
platform's, the DATA is the admin's or the user's.

**The derivation this deletes is the point.** Because v16's `system` doubled as
both the engine-owned default and the writable set, three UI surfaces had to
RECOVER the distinction from `userActions` at render time:

- `isSystemWritable()` probed `userActions` for any opted-in write. It is now
`managedBy === 'system-data'` — the bucket answers directly.
- `ManagedByBadge` derived a synthetic `'system-writable'` variant key. The
variant map is now 1:1 with the bucket union, so a new bucket is a compile
error to miss instead of a silent fallthrough. The `systemWritable` /
`system` i18n keys are **unchanged**, so no locale bundle moves.
- `resolveManagedByEmptyState()` asked the resolved `create` affordance whether a
`system` list should read "entries appear automatically" or show the New
button. `system-data` now falls through to the generic empty state by
definition; `engine-owned` keeps the automatic-entries copy.

**Breaking (UI API):** `ManagedByBadge`'s `userActions` prop and the exported
`ManagedByUserActions` interface are **removed**. The bucket alone selects the
variant now, so the prop had become metadata nothing read — the exact defect the
framework change exists to remove; shipping it as an accepted-but-ignored prop
would have reproduced it one layer up. Drop the prop from call sites; no other
change is needed.

`MANAGED_BY_BUCKETS` and `ManagedByBucket` no longer contain `'system'`.
74 changes: 25 additions & 49 deletions packages/app-shell/src/components/ManagedByBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
cn,
} from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances';
import { type ManagedByBucket } from '../utils/crudAffordances';

/**
* ManagedByBadge — replaces the verbose, full-width `ManagedByBanner` with
Expand All @@ -28,12 +28,11 @@ import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances
* `@objectstack/spec/data/object.zod.ts` → `ObjectSchemaBase.managedBy`:
* - `platform` — User-owned business data. **Renders nothing.**
* - `config` — Admin-authored configuration.
* - `system` — Engine-managed schema. A `system` object that opens writes
* via `userActions` (ADR-0103) is platform-defined,
* admin/user-writable DATA and gets the distinct
* writable-system copy; a locked one reads engine-owned.
* - `system-data` — Platform-defined schema whose DATA is admin/user-writable
* (objectstack#3355; the writable half of the old `system`).
* - `engine-owned` — Runtime rows a platform service owns end to end (ADR-0103);
* the explicit read-only monitoring surface.
* the explicit read-only monitoring surface, and the
* engine-owned half of the old `system`.
* - `append-only` — Immutable audit log.
* - `better-auth` — Identity tables owned by better-auth driver.
*
Expand All @@ -42,32 +41,15 @@ import { isSystemWritable, type ManagedByBucket } from '../utils/crudAffordances
* users understand at a glance why those affordances are missing — the
* detailed explanation lives in the tooltip.
*
* Empty-state guidance for `system`-bucket lists is rendered separately by
* Empty-state guidance for the read-only buckets is rendered separately by
* `ObjectView` via `resolveManagedByEmptyState()`.
*/

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 shared
* `resolveEffectiveCrudAffordances` inputs; `edit`/`delete` accept the #2614 object form.
*/
export interface ManagedByUserActions {
create?: boolean;
edit?: boolean | { enabled?: boolean };
delete?: boolean | { enabled?: boolean };
}

export interface ManagedByBadgeProps {
/** The `managedBy` flag from the object schema. */
managedBy?: string;
/**
* The object's `userActions` (ADR-0103). When a `system`-bucket object opens
* any write here, the badge switches from the engine-owned "read-only
* monitoring surface" copy to the admin/user-writable variant.
*/
userActions?: ManagedByUserActions | null;
/** Optional override for the human-readable system name shown in the tooltip. */
label?: string;
/** Optional extra classes. */
Expand All @@ -86,8 +68,13 @@ interface Variant {
tone: string;
}

/** Variant keys: the non-platform buckets plus the ADR-0103 writable-system split. */
type VariantKey = Exclude<Bucket, 'platform'> | 'system-writable';
/**
* Variant keys: every non-platform bucket. The extra `'system-writable'` key
* ADR-0103 needed is gone — objectstack#3355 made the writable half a real
* bucket (`system-data`), so the variant map is once again 1:1 with the union
* and a new bucket is a compile error to miss.
*/
type VariantKey = Exclude<Bucket, 'platform'>;

const VARIANTS: Record<VariantKey, Variant> = {
config: {
Expand All @@ -99,22 +86,11 @@ const VARIANTS: Record<VariantKey, Variant> = {
'These rows define how the platform behaves at runtime. Author them here; the runtime data they produce lives in a separate table.',
tone: 'border-sky-300/60 bg-sky-50 text-sky-900 hover:bg-sky-100 dark:border-sky-500/40 dark:bg-sky-950/40 dark:text-sky-100',
},
system: {
icon: Lock,
i18nKey: 'system',
short: 'System-managed',
title: 'Managed by the platform',
body: () =>
'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
},
// ADR-0103 — the explicit engine-owned bucket: rows a platform service owns end
// to end (jobs, automation runs, approval runtime rows, the metadata store, …).
// To a user this reads identically to a locked `system` object ("the platform
// manages this, read-only"), so it deliberately REUSES the `system` copy/i18n key
// — zero translation churn, consistent UX; the self-documentation is at the
// schema level. (Same object shape as `system`; the distinct bucket value is the
// point, not distinct user-facing copy.)
// Keeps the `system` i18n KEY (not the retired bucket value): the copy is
// unchanged and every locale bundle already carries it, so objectstack#3355
// costs zero translation churn. The vocabulary fix is at the schema level.
'engine-owned': {
icon: Lock,
i18nKey: 'system',
Expand All @@ -124,12 +100,12 @@ const VARIANTS: Record<VariantKey, Variant> = {
'Rows here are created automatically when actions run on the source record. The list below is a read-only monitoring surface — row-level actions (Approve, Recall, Resend, …) live on each row.',
tone: 'border-slate-300/60 bg-slate-50 text-slate-900 hover:bg-slate-100 dark:border-slate-500/40 dark:bg-slate-950/40 dark:text-slate-100',
},
// ADR-0103a `system`-bucket object that opened writes via `userActions`:
// platform-defined schema, but admin/user-writable DATA (e.g. Notification
// Preferences, delegated RBAC assignments). Resolved in the component when the
// bucket is `system` and any write is opted in, so the copy no longer claims a
// "read-only monitoring surface".
'system-writable': {
// objectstack#3355platform-defined schema, admin/user-writable DATA (e.g.
// Notification Preferences, delegated RBAC assignments). Under ADR-0103 this
// was a synthetic `'system-writable'` variant the component had to DERIVE from
// `userActions`; it is now the plain `system-data` bucket. The `systemWritable`
// i18n key is kept so no locale bundle has to change.
'system-data': {
icon: Settings2,
i18nKey: 'systemWritable',
short: 'Platform schema',
Expand Down Expand Up @@ -158,13 +134,13 @@ const VARIANTS: Record<VariantKey, Variant> = {
},
};

export function ManagedByBadge({ managedBy, userActions, label, className }: ManagedByBadgeProps) {
export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgeProps) {
const { t } = useObjectTranslation();
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 systemWritable = isSystemWritable({ managedBy, userActions });
const variantKey: VariantKey = systemWritable ? 'system-writable' : (managedBy as VariantKey);
// objectstack#3355 — the bucket IS the variant now; nothing to derive.
const variantKey = managedBy as VariantKey;
const variant = VARIANTS[variantKey];
if (!variant) return null;
const Icon = variant.icon;
Expand Down
44 changes: 29 additions & 15 deletions packages/app-shell/src/utils/__tests__/managedByEmptyState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,42 @@ describe('resolveManagedByEmptyState', () => {
expect(resolveManagedByEmptyState('nope', t)).toBeUndefined();
});

it('leaves the system / append-only buckets intact', () => {
expect(resolveManagedByEmptyState('system', t)?.title).toBe('Nothing here yet');
it('leaves the engine-owned / append-only buckets intact', () => {
expect(resolveManagedByEmptyState('engine-owned', t)?.title).toBe('Nothing here yet');
expect(resolveManagedByEmptyState('append-only', t)?.title).toBe('No events recorded');
});

// ADR-0103 — the explicit engine-owned bucket reuses the `system` engine-owned
// empty state; it never opens creation, so it always renders that copy.
it('gives the engine-owned bucket the same engine-owned empty state as locked system', () => {
// ADR-0103 — rows a platform service owns end to end; it never opens creation,
// so it always renders the "entries appear automatically" copy.
it('gives the engine-owned bucket the engine-owned empty state', () => {
expect(resolveManagedByEmptyState('engine-owned', t)?.title).toBe('Nothing here yet');
expect(resolveManagedByEmptyState('engine-owned', t, 'sys_automation_run', undefined)?.title).toBe('Nothing here yet');
});

// ADR-0103 — a `system` object that opened creation (writable set: Notification
// Preferences, delegated RBAC assignments, …) is admin/user-writable data. The
// "entries appear automatically" copy would be wrong, so the helper returns
// undefined and the caller falls back to the generic empty state (New button).
it('returns undefined for a system object whose userActions opened creation', () => {
expect(resolveManagedByEmptyState('system', t, 'sys_notification_preference', { create: true })).toBeUndefined();
// A system object that did NOT open creation stays engine-owned.
expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', {})?.title).toBe('Nothing here yet');
expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', undefined)?.title).toBe('Nothing here yet');
// append-only is unaffected by userActions.create (audit logs stay locked).
/**
* objectstack#3355 — the writable half is its own bucket now.
*
* Under ADR-0103 this helper had to ASK `userActions` whether a `system` list
* should get the "entries appear automatically" copy or the generic
* New-button empty state. `system-data` answers it by name: it is
* admin/user-writable data, so it falls through to `default` and the caller
* renders the generic empty state — no probe, no derivation.
*/
it('returns undefined for `system-data` — the generic New-button empty state', () => {
expect(resolveManagedByEmptyState('system-data', t, 'sys_notification_preference')).toBeUndefined();
// …and stays undefined however `userActions` narrows, since the bucket default is full CRUD.
expect(resolveManagedByEmptyState('system-data', t, 'sys_notification_preference', { create: true })).toBeUndefined();
expect(resolveManagedByEmptyState('system-data', t, 'sys_user_position', {})).toBeUndefined();
});

it('gives the retired `system` value no special-casing at all', () => {
// It falls through to `default` like any unknown bucket — the retired value
// must not keep steering UI copy after objectstack#3355.
expect(resolveManagedByEmptyState('system', t)).toBeUndefined();
expect(resolveManagedByEmptyState('system', t, 'sys_automation_run', {})).toBeUndefined();
});

it('append-only is unaffected by userActions.create (audit logs stay locked)', () => {
expect(resolveManagedByEmptyState('append-only', t, 'sys_audit_log', { create: true })?.title).toBe('No events recorded');
});

Expand Down
19 changes: 10 additions & 9 deletions packages/app-shell/src/utils/managedByEmptyState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@ export function resolveManagedByEmptyState(
userActions?: UserActionsOverride | null,
): ManagedByEmptyState | undefined {
switch (managedBy) {
// ADR-0103 — the explicit engine-owned bucket reuses the `system` engine-owned
// empty state ("entries appear automatically"); it never opens creation, so the
// resolveEffectiveCrudAffordances guard below is a no-op for it.
// ADR-0103 — rows a platform service owns end to end: "entries appear
// automatically" is the whole story. It keeps the `system` i18n KEY (the copy
// is unchanged and every locale bundle carries it), not the retired bucket
// VALUE — objectstack#3355 renamed the writable half to `system-data`, which
// now falls through to `default` and gets the generic empty state with its
// New button, exactly as its full-CRUD bucket default implies.
case 'engine-owned':
case 'system':
// ADR-0103 — a `system` object that opened creation is admin/user-writable
// data (e.g. Notification Preferences). The "entries appear automatically"
// copy would be wrong; fall back to the generic empty state (which surfaces
// the New button) by returning undefined. The resolved `create` affordance
// (shared @object-ui/core policy) is the one place that reads the override.
// An `engine-owned` object that nonetheless opens creation via `userActions`
// would make the "appear automatically" copy wrong; fall back to the generic
// empty state. The resolved `create` affordance (shared @object-ui/core
// policy) is the one place that reads the override.
if (resolveEffectiveCrudAffordances({ managedBy, userActions }).create) return undefined;
return {
icon: 'Lock',
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1682,7 +1682,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
title={
<span className="inline-flex items-center gap-2">
<span className="truncate">{objectLabel(objectDef)}</span>
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
</span>
}
description={objectDef.description ? objectDesc(objectDef) : undefined}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('resolveRecordHeaderActionGates — effective API operations (#3546)',
// server set must NOT re-open them — the server governs what it will
// accept, the bucket governs what the product offers.
expect(
resolveRecordHeaderActionGates({ name: 'sys_automation_run', managedBy: 'system' }, [
resolveRecordHeaderActionGates({ name: 'sys_automation_run', managedBy: 'engine-owned' }, [
'get',
'list',
'create',
Expand Down
2 changes: 1 addition & 1 deletion packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2041,7 +2041,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
{recordPresence.length > 0 && (
<PresenceAvatars users={recordPresence} size="sm" maxVisible={3} showStatus />
)}
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} userActions={(objectDef as any)?.userActions} />
<ManagedByBadge managedBy={(objectDef as any)?.managedBy} />
</div>

<RecordContextProvider
Expand Down
1 change: 0 additions & 1 deletion packages/app-shell/src/views/RecordFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,6 @@ export function RecordFormPage({ mode }: RecordFormPageProps) {
buckets via ObjectForm's own readOnly resolution. */}
<ManagedByBadge
managedBy={(objectDef as any)?.managedBy}
userActions={(objectDef as any)?.userActions}
className="ml-1"
/>
</nav>
Expand Down
Loading
Loading