Skip to content

Commit 627f225

Browse files
os-zhuangclaude
andauthored
feat(spec): userActions.edit/delete accept per-record CEL predicates (objectui#2614) (#3076)
* feat(spec): userActions.edit/delete accept per-record CEL predicates (objectui#2614) Extend the object-level userActions.edit / userActions.delete flags from plain booleans to a union with an object form { enabled?, visibleWhen?, disabledWhen? } (RowCrudActionOverrideSchema), so the built-in row Edit/Delete affordances can be hidden or disabled per record via CEL predicates — the same evaluation contract custom actions already use. Semantics: visibleWhen false → not rendered (fail-closed); disabledWhen true → rendered disabled (fail-soft). The predicates are advisory UI gating; server enforcement stays with permissions/hooks. resolveCrudAffordances() keeps returning the resolved booleans (enabled falls back to the managedBy bucket default) and now surfaces the predicates as editPredicates / deletePredicates, pass-through as authored. Boolean-only inputs produce byte-identical output — zero behavior change for existing schemas. clampManagedObjectWrites (ADR-0092 D2 hint clamp) treats the object form by its explicit enabled flag only: per-record predicates are not a write grant, so a managed object stays fail-closed unless enabled === true. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE * chore: add changeset for objectui#2614 spec slice Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE * chore(spec): regenerate api-surface snapshot for RowCrudActionOverride exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE * test(dogfood): classify userActions row-CRUD predicates in the ADR-0058 expression ledger The new data/object.zod.ts:visibleWhen / :disabledWhen surfaces (objectui#2614) tripped the conformance ratchet. Two rows, matching their actual fail policies: visibleWhen is fail-closed (a faulting predicate hides the row button), disabledWhen is fail-soft-log (a faulting predicate leaves it enabled; server hooks are the boundary). Both interpret on the canonical celEngine via objectui useRowPredicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE * docs(showcase): demo per-record row Edit/Delete CEL gating on Invoice (objectui#2614) A PAID invoice's fields already freeze via readonlyWhen; the built-in row actions now follow the same truth: Edit renders DISABLED on paid rows (disabledWhen), Delete is HIDDEN outright (visibleWhen). Draft and sent invoices keep the untouched menu. Browser-verified against the objectui HMR console: paid row menu = greyed Edit only; sent row menu = enabled Edit + Delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HnCZhYpQjRyg2E44RHBiNE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d14a387 commit 627f225

9 files changed

Lines changed: 252 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-hono-server': patch
4+
---
5+
6+
feat(spec): userActions.edit/delete accept per-record CEL predicates (objectui#2614)
7+
8+
`userActions.edit` / `userActions.delete` now accept, in addition to the
9+
plain boolean, an object form `{ enabled?, visibleWhen?, disabledWhen? }`
10+
(`RowCrudActionOverrideSchema`) so the built-in row Edit/Delete affordances
11+
can be hidden or disabled **per record** via CEL predicates — the same
12+
evaluation contract custom row actions already use. `visibleWhen` false →
13+
button not rendered (fail-closed); `disabledWhen` true → rendered disabled
14+
(fail-soft). Advisory UI gating only; server enforcement stays with
15+
permissions/hooks.
16+
17+
`resolveCrudAffordances()` keeps returning the resolved booleans (`enabled`
18+
falls back to the `managedBy` bucket default) and now surfaces the
19+
predicates as `editPredicates` / `deletePredicates`. Boolean-only inputs
20+
produce byte-identical output — zero behavior change for existing schemas.
21+
22+
`clampManagedObjectWrites` (ADR-0092 D2 hint clamp) treats the object form
23+
by its explicit `enabled` flag only: per-record predicates are not a write
24+
grant, so managed objects stay fail-closed unless `enabled === true`.

examples/app-showcase/src/data/objects/invoice.object.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ export const Invoice = ObjectSchema.create({
4646
icon: 'receipt',
4747
description: 'A customer invoice entered together with its line items.',
4848

49+
// Per-record row-action gating (objectui#2614) — a PAID invoice is frozen:
50+
// its fields already lock via `readonlyWhen: record.status == 'paid'` below,
51+
// and here the built-in row buttons follow the same truth instead of
52+
// advertising an edit the record will refuse. The two flags demonstrate the
53+
// two semantics: Edit stays visible but DISABLED on paid rows (the user sees
54+
// the affordance exists and is off), while Delete is HIDDEN outright (a paid
55+
// invoice is an accounting record; offering delete would be noise). Draft
56+
// and sent invoices keep the full untouched menu — the predicates evaluate
57+
// per row against `record.*` on the same CEL engine as everything else.
58+
userActions: {
59+
edit: { disabledWhen: P`record.status == 'paid'` },
60+
delete: { visibleWhen: P`record.status != 'paid'` },
61+
},
62+
4963
fields: {
5064
name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }),
5165
// Forward record-picker config (spec lookup* props). The renderer

packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ describe('clampManagedObjectWrites', () => {
9090
expect(objects.crm_lead.allowEdit).toBe(true);
9191
});
9292

93+
it('treats the #2614 object form by its enabled flag only (predicates are UI gating, not a grant)', () => {
94+
const schemas: Record<string, ManagedSchemaLike> = {
95+
sys_user: {
96+
managedBy: 'better-auth',
97+
userActions: { edit: { enabled: true, disabledWhen: 'record.frozen == true' } as never },
98+
},
99+
sys_account: {
100+
managedBy: 'better-auth',
101+
// enabled omitted → NOT an explicit opt-in; the clamp stays fail-closed.
102+
userActions: { edit: { disabledWhen: 'record.frozen == true' } as never },
103+
},
104+
};
105+
const objects: Record<string, any> = {
106+
sys_user: { allowEdit: true },
107+
sys_account: { allowEdit: true },
108+
};
109+
clampManagedObjectWrites(objects, (n) => schemas[n]);
110+
expect(objects.sys_user.allowEdit).toBe(true);
111+
expect(objects.sys_account.allowEdit).toBe(false);
112+
});
113+
93114
it('fold + clamp compose to permission ∩ guard for a platform admin', () => {
94115
// As produced for a platform admin (admin_full_access '*' modifyAll) who
95116
// also holds organization_admin (explicit managed denies).

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,19 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
126126
/** Minimal schema shape the managed-write clamp needs. */
127127
export interface ManagedSchemaLike {
128128
managedBy?: string;
129-
userActions?: { create?: boolean; edit?: boolean; delete?: boolean } | null;
129+
userActions?: {
130+
create?: boolean;
131+
// edit/delete accept the #2614 object form ({ enabled, visibleWhen,
132+
// disabledWhen }); only the object-level `enabled` matters here — the
133+
// per-record predicates are UI gating, not a permission grant.
134+
edit?: boolean | { enabled?: boolean };
135+
delete?: boolean | { enabled?: boolean };
136+
} | null;
137+
}
138+
139+
/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */
140+
function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean {
141+
return v === true || (typeof v === 'object' && v !== null && v.enabled === true);
130142
}
131143

132144
/**
@@ -157,9 +169,9 @@ export function clampManagedObjectWrites(
157169
const schema = schemaOf(obj);
158170
if (schema?.managedBy !== 'better-auth') continue;
159171
const ua = schema.userActions ?? {};
160-
if (ua.edit !== true) acc.allowEdit = false;
172+
if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false;
161173
if (ua.create !== true) acc.allowCreate = false;
162-
if (ua.delete !== true) acc.allowDelete = false;
174+
if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false;
163175
}
164176
}
165177

packages/qa/dogfood/test/expression-conformance.ledger.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
133133
'system/settings-manifest.zod.ts:visible',
134134
],
135135
},
136+
{
137+
id: 'cel-row-crud-visible',
138+
summary: 'built-in row Edit/Delete per-record visibility (userActions.{edit,delete}.visibleWhen, objectui#2614)',
139+
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-closed',
140+
enforcement: 'console (objectui) RowActionMenu BuiltinRowActionItem + data-table DataTableBuiltinRowActionItem → useRowPredicate → @objectstack/formula celEngine (interpret); FALSE/fault hides the row button (UI gating only — write enforcement stays with permissions/hooks)',
141+
covers: ['data/object.zod.ts:visibleWhen'],
142+
},
143+
{
144+
id: 'cel-row-crud-disabled',
145+
summary: 'built-in row Edit/Delete per-record disabling (userActions.{edit,delete}.disabledWhen, objectui#2614)',
146+
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
147+
enforcement: 'console (objectui) RowActionMenu BuiltinRowActionItem + data-table DataTableBuiltinRowActionItem → useRowPredicate → @objectstack/formula celEngine (interpret); TRUE renders the button disabled, a fault leaves it enabled (server hooks are the real boundary)',
148+
covers: ['data/object.zod.ts:disabledWhen'],
149+
},
136150
{
137151
id: 'cel-flow',
138152
summary: 'flow / sync / loader branching + filter predicates',

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,10 @@
441441
"ReplicationConfigSchema (const)",
442442
"ResolveRecordDisplayNameOptions (interface)",
443443
"ResolvedHook (type)",
444+
"RowCrudActionOverride (type)",
445+
"RowCrudActionOverrideInput (type)",
446+
"RowCrudActionOverrideSchema (const)",
447+
"RowCrudPredicates (interface)",
444448
"SQLDialect (type)",
445449
"SQLDialectSchema (const)",
446450
"SQLDriverConfig (type)",

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,7 @@
838838
"data/ReferenceResolution",
839839
"data/ReferenceResolutionError",
840840
"data/ReplicationConfig",
841+
"data/RowCrudActionOverride",
841842
"data/SQLDialect",
842843
"data/SQLDriverConfig",
843844
"data/SSLConfig",

packages/spec/src/data/object.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, type ServiceObject } from './object.zod';
2+
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, resolveCrudAffordances, type ServiceObject } from './object.zod';
33

44
describe('ObjectCapabilities', () => {
55
it('should apply default values correctly', () => {
@@ -1342,3 +1342,69 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
13421342
).toThrow(/removed from @objectstack\/spec after v15\.0/);
13431343
});
13441344
});
1345+
1346+
describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => {
1347+
it('accepts the plain boolean form unchanged (back-compat)', () => {
1348+
const obj = ObjectSchema.parse({
1349+
name: 'invoice',
1350+
fields: { name: { type: 'text' } },
1351+
userActions: { edit: false, delete: true },
1352+
});
1353+
const aff = resolveCrudAffordances(obj);
1354+
expect(aff.edit).toBe(false);
1355+
expect(aff.delete).toBe(true);
1356+
expect(aff.editPredicates).toBeUndefined();
1357+
expect(aff.deletePredicates).toBeUndefined();
1358+
});
1359+
1360+
it('accepts the object form with CEL predicate shorthand strings', () => {
1361+
const obj = ObjectSchema.parse({
1362+
name: 'task_version_check_item',
1363+
fields: { name: { type: 'text' } },
1364+
userActions: {
1365+
edit: { disabledWhen: 'record.frozen == true' },
1366+
delete: { visibleWhen: 'record.frozen != true' },
1367+
},
1368+
});
1369+
// String shorthand normalizes to the canonical CEL envelope.
1370+
expect((obj.userActions?.edit as any).disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' });
1371+
expect((obj.userActions?.delete as any).visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' });
1372+
});
1373+
1374+
it('resolveCrudAffordances carries predicates through and defaults enabled from the bucket', () => {
1375+
const aff = resolveCrudAffordances({
1376+
managedBy: 'platform',
1377+
userActions: {
1378+
edit: { disabledWhen: { dialect: 'cel', source: 'record.frozen == true' } },
1379+
delete: { enabled: false, visibleWhen: { dialect: 'cel', source: 'record.frozen != true' } },
1380+
},
1381+
} as never);
1382+
// No `enabled` on edit → platform bucket default (true) applies.
1383+
expect(aff.edit).toBe(true);
1384+
expect(aff.editPredicates?.disabledWhen).toEqual({ dialect: 'cel', source: 'record.frozen == true' });
1385+
expect(aff.editPredicates?.visibleWhen).toBeUndefined();
1386+
// Explicit enabled:false wins over the bucket default; predicates still surface.
1387+
expect(aff.delete).toBe(false);
1388+
expect(aff.deletePredicates?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' });
1389+
});
1390+
1391+
it('object form without predicates behaves exactly like the boolean form', () => {
1392+
const aff = resolveCrudAffordances({
1393+
managedBy: 'config',
1394+
userActions: { edit: { enabled: true }, delete: {} },
1395+
} as never);
1396+
expect(aff.edit).toBe(true);
1397+
expect(aff.delete).toBe(true); // config bucket default
1398+
expect(aff.editPredicates).toBeUndefined();
1399+
expect(aff.deletePredicates).toBeUndefined();
1400+
});
1401+
1402+
it('rejects unknown keys in the object form', () => {
1403+
const result = ObjectSchema.safeParse({
1404+
name: 'invoice',
1405+
fields: { name: { type: 'text' } },
1406+
userActions: { edit: { hideWhen: 'record.frozen == true' } },
1407+
});
1408+
expect(result.success).toBe(false);
1409+
});
1410+
});

packages/spec/src/data/object.zod.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ObjectListViewSchema } from '../ui/view.zod';
99
/**
1010
* API Operations Enum
1111
*/
12-
import { TemplateExpressionInputSchema } from '../shared/expression.zod';
12+
import { ExpressionInputSchema, TemplateExpressionInputSchema, type Expression, type ExpressionInput } from '../shared/expression.zod';
1313
import { lazySchema } from '../shared/lazy-schema';
1414
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
1515
import { ProtectionSchema } from '../shared/protection.zod';
@@ -556,6 +556,42 @@ export const ObjectExternalBindingSchema = z.object({
556556

557557
export type ObjectExternalBinding = z.infer<typeof ObjectExternalBindingSchema>;
558558

559+
/**
560+
* Object form of a `userActions.edit` / `userActions.delete` override —
561+
* extends the plain boolean with **per-record** CEL predicates so the
562+
* built-in row Edit/Delete affordances can be hidden or disabled for a
563+
* subset of rows (objectstack-ai/objectui#2614).
564+
*
565+
* Semantics (mirrors custom row actions' `visible` / `disabled`):
566+
* - `enabled` — object-level on/off, same meaning as the bare boolean.
567+
* Omitted → the `managedBy` bucket default.
568+
* - `visibleWhen` — CEL over `record.*`; evaluates **false** → the row's
569+
* button is not rendered. Fail-closed (a faulting
570+
* predicate hides, and warns once).
571+
* - `disabledWhen` — CEL over `record.*`; evaluates **true** → the row's
572+
* button renders greyed / non-clickable. Fail-soft (a
573+
* faulting predicate leaves the button enabled).
574+
*
575+
* The predicates are advisory UI gating only — server-side enforcement
576+
* stays with permissions / hooks (e.g. `beforeUpdate` rejecting frozen
577+
* rows). Evaluation happens on the canonical CEL engine, per row, with the
578+
* record bound as `record.*` (and bare fields) — the same machinery custom
579+
* actions already use, so authoring is identical.
580+
*/
581+
export const RowCrudActionOverrideSchema = z.object({
582+
enabled: z.boolean().optional().describe(
583+
'Object-level on/off for the generic affordance; same meaning as the bare boolean form. Omitted → managedBy bucket default.',
584+
),
585+
visibleWhen: ExpressionInputSchema.optional().describe(
586+
'Per-record CEL predicate; false → hide the row button for that record. Fail-closed.',
587+
),
588+
disabledWhen: ExpressionInputSchema.optional().describe(
589+
'Per-record CEL predicate; true → render the row button disabled for that record. Fail-soft.',
590+
),
591+
}).strict().describe('Boolean-or-predicates override for a built-in row CRUD affordance.');
592+
export type RowCrudActionOverride = z.infer<typeof RowCrudActionOverrideSchema>;
593+
export type RowCrudActionOverrideInput = z.input<typeof RowCrudActionOverrideSchema>;
594+
559595
const ObjectSchemaBase = z.object({
560596
/**
561597
* Identity & Metadata
@@ -642,8 +678,12 @@ const ObjectSchemaBase = z.object({
642678
userActions: z.object({
643679
create: z.boolean().optional().describe('Show generic "New" button.'),
644680
import: z.boolean().optional().describe('Show CSV import wizard entry.'),
645-
edit: z.boolean().optional().describe('Allow inline / form edit of existing rows.'),
646-
delete: z.boolean().optional().describe('Show row-level delete + bulk delete.'),
681+
edit: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe(
682+
'Allow inline / form edit of existing rows. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.',
683+
),
684+
delete: z.union([z.boolean(), RowCrudActionOverrideSchema]).optional().describe(
685+
'Show row-level delete + bulk delete. Boolean, or an object adding per-record visibleWhen/disabledWhen CEL predicates.',
686+
),
647687
exportCsv: z.boolean().optional().describe('Show CSV export entry.'),
648688
}).optional().describe('Per-object override of the resolved CRUD affordance matrix.'),
649689

@@ -1298,6 +1338,24 @@ export interface CrudAffordances {
12981338
delete: boolean;
12991339
/** CSV / clipboard export. Allowed even on append-only audit tables by default. */
13001340
exportCsv: boolean;
1341+
/**
1342+
* Per-record CEL predicates for the built-in row Edit action, present only
1343+
* when `userActions.edit` used the object form (objectui#2614). Evaluate
1344+
* per row against `record.*`; see {@link RowCrudActionOverrideSchema}.
1345+
*/
1346+
editPredicates?: RowCrudPredicates;
1347+
/** Per-record CEL predicates for the built-in row Delete action. */
1348+
deletePredicates?: RowCrudPredicates;
1349+
}
1350+
1351+
/**
1352+
* Per-record gating predicates carried through {@link resolveCrudAffordances}.
1353+
* Kept as authored (`string` shorthand or `{ dialect, source }` envelope) —
1354+
* consumers hand them to the canonical CEL row-predicate evaluator untouched.
1355+
*/
1356+
export interface RowCrudPredicates {
1357+
visibleWhen?: Expression | ExpressionInput;
1358+
disabledWhen?: Expression | ExpressionInput;
13011359
}
13021360

13031361
/**
@@ -1343,13 +1401,41 @@ export function resolveCrudAffordances(
13431401
const bucket = (obj?.managedBy ?? 'platform') as keyof typeof CRUD_AFFORDANCE_DEFAULTS;
13441402
const base = CRUD_AFFORDANCE_DEFAULTS[bucket] ?? CRUD_AFFORDANCE_DEFAULTS.platform;
13451403
const overrides = obj?.userActions ?? {};
1346-
return {
1404+
const edit = normalizeRowCrudOverride(overrides.edit, base.edit);
1405+
const del = normalizeRowCrudOverride(overrides.delete, base.delete);
1406+
const out: CrudAffordances = {
13471407
create: overrides.create ?? base.create,
13481408
import: overrides.import ?? base.import,
1349-
edit: overrides.edit ?? base.edit,
1350-
delete: overrides.delete ?? base.delete,
1409+
edit: edit.enabled,
1410+
delete: del.enabled,
13511411
exportCsv: overrides.exportCsv ?? base.exportCsv,
13521412
};
1413+
if (edit.predicates) out.editPredicates = edit.predicates;
1414+
if (del.predicates) out.deletePredicates = del.predicates;
1415+
return out;
1416+
}
1417+
1418+
/**
1419+
* Collapse a `userActions.edit` / `userActions.delete` override — bare
1420+
* boolean or `{ enabled, visibleWhen, disabledWhen }` object — onto the
1421+
* bucket default. The predicates pass through as authored; `predicates` is
1422+
* only set when at least one predicate is present, so the boolean-only path
1423+
* stays byte-identical to the pre-#2614 result.
1424+
*/
1425+
function normalizeRowCrudOverride(
1426+
override: boolean | { enabled?: boolean; visibleWhen?: unknown; disabledWhen?: unknown } | null | undefined,
1427+
base: boolean,
1428+
): { enabled: boolean; predicates?: RowCrudPredicates } {
1429+
if (override == null) return { enabled: base };
1430+
if (typeof override === 'boolean') return { enabled: override };
1431+
const enabled = override.enabled ?? base;
1432+
const visibleWhen = override.visibleWhen as RowCrudPredicates['visibleWhen'];
1433+
const disabledWhen = override.disabledWhen as RowCrudPredicates['disabledWhen'];
1434+
if (visibleWhen == null && disabledWhen == null) return { enabled };
1435+
const predicates: RowCrudPredicates = {};
1436+
if (visibleWhen != null) predicates.visibleWhen = visibleWhen;
1437+
if (disabledWhen != null) predicates.disabledWhen = disabledWhen;
1438+
return { enabled, predicates };
13531439
}
13541440

13551441
// =================================================================

0 commit comments

Comments
 (0)