Skip to content

feat(spec): userActions.create/import accept the edit/delete CEL predicate union - #7758

Draft
os-zhuang wants to merge 1 commit into
mainfrom
claude/issue-7692-useractions-create-import-predicate
Draft

feat(spec): userActions.create/import accept the edit/delete CEL predicate union#7758
os-zhuang wants to merge 1 commit into
mainfrom
claude/issue-7692-useractions-create-import-predicate

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #7692

The gap

#3076 (objectui#2614) gave userActions.edit and userActions.delete a
boolean-or-predicates union so the built-in row affordances could be gated on
record state. create and import were left as bare booleans, and there is no
other lever for them — so a child object's related-list [+ New] button cannot
be gated on the parent record's state, while the row Edit / Delete beside it
can. On a frozen parent the row actions correctly grey out and [+ New] still
renders. 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:

Ruling: widen. userActions.create — and import, same shape, same
argument — widen to the union edit/delete already carry (boolean | { enabled?, visibleWhen: CEL }), evaluation context identical to
edit/delete's. No new dialect; pure symmetry completion of #3076. The
renderer counterpart (related-list toolbar honoring create.visibleWhen) is
objectui's downstream card, scoped after the spec half lands.

What changed

packages/spec/src/data/object.zod.tscreate and import now take the
same RowCrudActionOverrideSchema union edit/delete already use. Not a
copy, not a narrowed variant: the same schema piece, so there is exactly one
definition of the object form.

userActions: {
  create: { visibleWhen: 'record.version_status == "draft"' },
  import: { enabled: true, disabledWhen: 'record.frozen == true' },
}

enabled keeps the bare boolean's meaning (omitted ⇒ the managedBy bucket
default), visibleWhen is fail-closed, disabledWhen fail-soft — identical to
the row pair. resolveCrudAffordances carries the predicates through as
createPredicates / importPredicates alongside the existing
editPredicates / deletePredicates, via the same normalizeRowCrudOverride
collapse, 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 so

This is the one place the card could have shipped a false promise, so it is
stated in the schema rather than implied:

  • edit / delete evaluate per row, against that row's own record. That is
    the docs(design): record create/edit/subtable surface + return-flow model (#2604) #2614 case, and the reason for the RowCrud name — kept as-is for export
    compatibility.
  • create / import gate a record that does not exist yet, so they evaluate
    once 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.* has
    nothing to bind and therefore hides the button under the fail-closed rule.

The describes and the RowCrudActionOverrideSchema docblock 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/delete
already 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/permissions managed-write clamp tested create with a bare
ua.create !== true, while edit/delete beside it already read through
isWriteOptedIn. Left alone, widening create would have made
create: { enabled: true, visibleWhen: … } clamp the create permission hint
off — a silent tightening introduced by this PR, not a pre-existing bug.
create now reads through the same helper, with the same fail-closed rule when
enabled is omitted.

Verification

Reverse verification (via git checkout origin/main -- ... on the two source
files, tests kept; restored with git checkout from the branch — no git stash).
Direction predicted before running, and it held both ways:

  • spec: 6 of 8 new cases red — the object-form parses, the
    resolveCrudAffordances carry-through, and the unknown-key case. That last one
    matters: on the reverted source it fails with
    Invalid input: expected boolean, received object, not on hideWhen
    which is why the case asserts the issue mentions hideWhen rather than only
    asserting success === false. Without that assertion it would have been green
    for the wrong reason on the very code the card targets.
  • 2 green, deliberately: the boolean back-compat case (green by design — that
    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.
  • plugin-hono-server: the clamp case red (expected false to be true) on the
    reverted clamp.

Gates run locally, all green: full @objectstack/spec suite (377 files / 9891
tests), @objectstack/spec typecheck, plugin-hono-server test (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) and gen:docs
(content/docs/references/data/object.mdx picks up the widened userActions
row 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/spec because 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

…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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 1:06pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-hono-server, @objectstack/spec.

107 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/plugin-hono-server)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/plugin-hono-server, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

userActions.create is boolean-only — related-list [+ New] cannot be gated on parent-record state, unlike edit/delete (#3076 left create behind)

2 participants