feat(spec): userActions.create/import accept the edit/delete CEL predicate union - #7758
feat(spec): userActions.create/import accept the edit/delete CEL predicate union#7758os-zhuang wants to merge 1 commit into
Conversation
…icate union (#7692) #3076 (objectui#2614) gave `userActions.edit`/`delete` a boolean-or-predicates union so the built-in row affordances could be gated on record state. `create`/`import` were left as bare booleans with no other lever, so a child object's related-list [+ New] button could not be gated on the parent record's state while the row Edit/Delete beside it could. On a frozen parent the row actions grey out and [+ New] still renders; the server guard 409s the insert, so it is an affordance leak an app has no way to close. Both keys now take the SAME union — the same RowCrudActionOverrideSchema, not a new dialect. resolveCrudAffordances carries the predicates through as createPredicates/importPredicates alongside the existing edit/delete pair, via the same normalizeRowCrudOverride collapse. What `record.*` binds to differs between the two positions, and the schema says so rather than implying a symmetry it does not have: edit/delete evaluate per row against that row's own record; create/import gate a record that does not exist yet, so they evaluate once per toolbar against the record in scope — the host (parent) record on a related list, and nothing on a standalone object list, where a `record.*` predicate therefore hides the button under the fail-closed rule. plugin-hono-server tracks the widened producer: the /me/permissions managed-write clamp tested `create` with a bare `!== true`, which would have clamped away a legitimate `create: { enabled: true, visibleWhen: … }` opt-in. It now reads `create` through the same opt-in helper as edit/delete. The renderer half (related-list toolbar honouring create.visibleWhen) is objectui's downstream card and is not part of this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aiuit7rFUsQWropgqtfnR
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #7692
The gap
#3076 (objectui#2614) gave
userActions.editanduserActions.deleteaboolean-or-predicates union so the built-in row affordances could be gated on
record state.
createandimportwere left as bare booleans, and there is noother lever for them — so a child object's related-list
[+ New]button cannotbe gated on the parent record's state, while the row
Edit/Deletebeside itcan. On a frozen parent the row actions correctly grey out and
[+ New]stillrenders. The server-side guard rejects the insert (409), so this is an affordance
leak rather than a data-integrity hole — but it is one an app has no way to close.
The ruling
Maintainer ruling recorded on the issue, 2026-08-11, quoted verbatim and
untranslated:
What changed
packages/spec/src/data/object.zod.ts—createandimportnow take thesame
RowCrudActionOverrideSchemaunionedit/deletealready use. Not acopy, not a narrowed variant: the same schema piece, so there is exactly one
definition of the object form.
enabledkeeps the bare boolean's meaning (omitted ⇒ themanagedBybucketdefault),
visibleWhenis fail-closed,disabledWhenfail-soft — identical tothe row pair.
resolveCrudAffordancescarries the predicates through ascreatePredicates/importPredicatesalongside the existingeditPredicates/deletePredicates, via the samenormalizeRowCrudOverridecollapse, so a declared predicate is reachable rather than declared-and-inert.
What
record.*binds to is NOT the same in the two positions, and the schema says soThis is the one place the card could have shipped a false promise, so it is
stated in the schema rather than implied:
edit/deleteevaluate per row, against that row's own record. That isthe docs(design): record create/edit/subtable surface + return-flow model (#2604) #2614 case, and the reason for the
RowCrudname — kept as-is for exportcompatibility.
create/importgate a record that does not exist yet, so they evaluateonce per toolbar, against the record in scope where the toolbar renders:
the host (parent) record on a record page's related list, and nothing at
all on a standalone object list — where a predicate reading
record.*hasnothing to bind and therefore hides the button under the fail-closed rule.
The describes and the
RowCrudActionOverrideSchemadocblock spell that out,including the authoring consequence: gate a toolbar action on parent state only
where a parent is actually in scope; anything else the child row must carry
itself, via the denormalised parent-status snapshot pattern
edit/deletealready use. That is the same honesty the row describes carry, not a new promise.
One consumer had to track the widened producer
packages/plugins/plugin-hono-server/src/current-user-endpoints.ts— the/me/permissionsmanaged-write clamp testedcreatewith a bareua.create !== true, whileedit/deletebeside it already read throughisWriteOptedIn. Left alone, wideningcreatewould have madecreate: { enabled: true, visibleWhen: … }clamp the create permission hintoff — a silent tightening introduced by this PR, not a pre-existing bug.
createnow reads through the same helper, with the same fail-closed rule whenenabledis omitted.Verification
Reverse verification (via
git checkout origin/main -- ...on the two sourcefiles, tests kept; restored with
git checkoutfrom the branch — nogit stash).Direction predicted before running, and it held both ways:
resolveCrudAffordancescarry-through, and the unknown-key case. That last onematters: on the reverted source it fails with
Invalid input: expected boolean, received object, not onhideWhen—which is why the case asserts the issue mentions
hideWhenrather than onlyasserting
success === false. Without that assertion it would have been greenfor the wrong reason on the very code the card targets.
is what it pins), and the "a bare string is rejected" case, which guards against
over-widening and so cannot discriminate under-widening. Recorded rather than
dressed up as evidence.
expected false to be true) on thereverted clamp.
Gates run locally, all green: full
@objectstack/specsuite (377 files / 9891tests),
@objectstack/spectypecheck,plugin-hono-servertest(18 files /211 tests) +
typecheck,check:generated(13 artifacts up to date),check:authorable-surface,check:docs,check:api-surface,check:export-origins,check:merge-driver,check:adr-anchors,check:spec-parsed-alias,check:nul-bytes.Generated closure regenerated, not hand-edited:
gen:schema(no tracked delta —RowCrudActionOverride's keys were already registered) andgen:docs(
content/docs/references/data/object.mdxpicks up the wideneduserActionsrow and the reworded predicate describes).
Scope
Back-compatible in the strict sense: every payload that validated before still
validates identically, and the boolean-only path still produces no predicate keys
at all. The only newly-accepted shapes are the object forms on
create/import. Minor on@objectstack/specbecause the accepted-input surface grows;patch on
plugin-hono-server, which only tracks it.The renderer half — the objectui related-list toolbar honouring
create.visibleWhen— is the downstream card and is not in this PR.Generated by Claude Code