Skip to content

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

Description

@baozhoutao

The gap

@objectstack/spec@17.0.0-rc.6 — UserActionsSchema:

userActions: z.ZodOptional<z.ZodObject<{
  create: z.ZodOptional<z.ZodBoolean>;                  // ← boolean only
  import: z.ZodOptional<z.ZodBoolean>;                  // ← boolean only
  edit:   z.ZodOptional<z.ZodUnion<[z.ZodBoolean, z.ZodObject<{ enabled, visibleWhen: CEL }>]>>;
  delete: /* same union as edit */
}>>

#3076 (feat(spec): userActions.edit/delete accept per-record CEL predicates, objectui#2614) gave edit and delete a per-record CEL predicate. create was left behind, and there is no other lever for it.

Why that is a real hole, not a nice-to-have

A child object's [+ New] button in a related list must very often be gated on the parent record's state — the classic case being "the parent is frozen/published/archived, so nothing may be added under it any more".

With edit/delete you can express this today: snapshot the parent's status onto the child and write userActions.delete.visibleWhen. For create there is nothing to write — the button is either always on or always off for the whole object, and "always off" is wrong because the child is creatable while the parent is a draft.

Concrete case that hit us

App-side object model:

  • task_version (parent) — has states draft / published / deprecated
  • task_version_check_item, task_position (children, rendered as related lists on the version record page)

Spec requirement: once a version is published or deprecated, its children are frozen — no add, no edit, no delete.

What we could implement:

control gated on parent state?
child row Edit / Delete ✅ via userActions.edit/delete.visibleWhen against a version_status snapshot field
related-list [+ New] ❌ no expression accepted — create is boolean

Result: on a published version the row actions correctly grey out, but the [+ New] button still renders. The server-side guard does reject the insert (HTTP 409 with a business message), so this is not a data-integrity problem — it is a pure UI affordance leak: users are shown an action that can never succeed, and QA legitimately reports it as a defect every round.

We deliberately did not work around it (no custom page, no patched component), which is why we are reporting it rather than shipping a local hack.

Requested change

Widen create (and import, same shape and same argument) to the union already used by edit / delete:

create: z.union([
  z.boolean(),
  z.object({ enabled: z.boolean().optional(), visibleWhen: CelPredicate.optional() })
]).optional()

Evaluation context should be the same one edit/delete already get. For a related list the useful context is the parent record; if that is not available at that point, the snapshot-field workaround we already use for edit/delete (parent status denormalised onto the child) would be enough — the blocker is purely that create accepts no expression at all.

Renderer side presumably needs the objectui counterpart of objectui#2614/#2617 for the related-list toolbar.

Environment

  • @objectstack/*@17.0.0-rc.6
  • Reproduced in an app project; server-side guard confirmed working (409 on insert), so the request is strictly about the affordance.

Activity

  1. claude commented on Aug 11, 2026

    @claude
    Contributor

    Triage: needs-user-decision + domain:spec — contract-shape widening, correctly filed rather than worked around.

    Landing site (read, not guessed): packages/spec/src/data/object.zod.ts:1471-1477 on origin/main @ 6a9dec6 confirms the premise verbatim — create and import are z.boolean().optional() while edit/delete take z.union([z.boolean(), RowCrudActionOverrideSchema]) (the #3076 / objectui#2614 union defined at object.zod.ts:1075). Widening create/import to that union changes the set of legal metadata (an object-form create: that rejects today would validate), so per the acceptance-surface rule this is domain:spec, not surface/tooling.

    Why decision box, not queue: the mechanical half (reuse the existing union) is settled precedent, but the card itself names the open semantic questions: what record.* binds to for a related-list [+ New] (parent record vs. denormalised snapshot — RowCrudActionOverrideSchema's docblock defines evaluation as per-row over the row's own record, which a not-yet-created row does not have), and the objectui toolbar counterpart (rule-2 split expected if accepted: spec sub-issue first, objectui renderer sub-issue Blocked-by: it). #3076 ruled edit/delete only; extending the vocabulary is a new acceptance-surface ruling.

    Dedup: no open shadow in either repo — objectui#2614 (edit/delete half) is closed; repo-scoped searches for the create half return only this card.

    Not target:v17: affordance-only leak by its own account (server guard 409s the insert; no data integrity issue) — fails all four release-blocker classes.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Maintainer ruling recorded 2026-08-11 (spec-lane PM session chat, verbatim: 「接受你的建议,开始加速处理」, accepting the lane sweep's recommendation on this card).

    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.

    State: needs-user-decision → pm:queue (spec lane; dispatches at the next batch slot).


    Generated by Claude Code

  3. self-assigned this
    on Aug 11, 2026
  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 2 (spec lane)
    Session: session_01JY2Q5Xto1u8YHADgrZDTnk
    Branch: claude/issue-7692-useractions-create-import-predicate
    Worktree: cloud dev session (own container)
    Domain: domain:spec
    File surface: UserActionsSchema declaration site (locate on origin/main — the card quotes rc.6 dts; #3076 is the precedent PR shape), pins, generated closure. ⛔ Not filter.zod.ts (three cards in flight/queued there). objectui renderer counterpart is downstream, NOT this card. Stop on breach; explain in the report.
    Container & model: M (ruled symmetry widening on the acceptance face), mode:cloud, model: opus
    Serial constraints cleared: no in-flight writer on the userActions surface (open PRs + queue checked 12:5xZ); in-flight lane cards touch filter.zod.ts / secret-fields / i18n-resolver / component.zod.ts — disjoint.


    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    OS-DEV-REPORT — #7692

    {
      "issue": 7692,
      "status": "done",
      "branch": "claude/issue-7692-useractions-create-import-predicate",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7758",
      "premise_still_valid": true,
      "summary": "Premise confirmed on origin/main @ 6979157: object.zod.ts had create/import as z.boolean().optional() beside edit/delete's z.union([z.boolean(), RowCrudActionOverrideSchema]). Widened create and import to that SAME union — the same schema piece, not a copy or a narrowed variant — per the recorded ruling, and carried the predicates through resolveCrudAffordances as createPredicates/importPredicates via the existing normalizeRowCrudOverride collapse, so a declared predicate is reachable rather than declared-and-inert. The one honesty point the card flagged is stated in the schema rather than implied: edit/delete evaluate per row against the row's own record, while create/import gate a record that does not exist yet and so 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 hides the button under the family's fail-closed rule. One consumer had to track the widened producer: plugin-hono-server's /me/permissions managed-write clamp tested create with a bare `ua.create !== true` while edit/delete beside it used isWriteOptedIn, so an object opting create IN via the object form would have had its create permission hint clamped OFF — a tightening this PR would have introduced, fixed in the same PR by reading create through the same helper.",
      "tests": "All local gates green. (1) Full @objectstack/spec suite: `Test Files 377 passed (377) / Tests 9891 passed (9891)` — includes the 8 new cases in the '#7692' describe in src/data/object.test.ts. (2) @objectstack/spec typecheck: clean (tsc + check:scripts-typecheck + check:test-typecheck 'OK — test layer compiles'). (3) plugin-hono-server: `Test Files 18 passed (18) / Tests 211 passed (211)`; typecheck clean. (4) Generated closure: 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 + reworded predicate describes); `check:generated` → 'All 13 generated artifacts are up to date'; check:authorable-surface, check:docs, check:api-surface, check:export-origins all green. (5) Root gates: check:merge-driver, check:adr-anchors ('OK (47 anchored file(s) … 22349 citation(s) … resolve)'), check:spec-parsed-alias ('1512 bare z.input aliases … OK'), check:nul-bytes ('scanned 7115 text file(s) … no raw ASCII control bytes'). REVERSE VERIFICATION — direction predicted before running, held both ways; restore via `git checkout origin/main -- FILE` then `git checkout BRANCH -- FILE`, no git stash. Reverting the two source files with the tests kept: spec went 6 red / 2 green of 8. The 6 reds are the object-form parses ('Invalid input: expected boolean, received object'), the resolveCrudAffordances carry-through ('expected { visibleWhen: {…} } to be true'), and the unknown-key case — that last one is the point: on the reverted source it fails with `expected '[{\"expected\":\"boolean\",…' to contain 'hideWhen'`, i.e. it would have been GREEN FOR THE WRONG REASON had it only asserted success === false, which is why it asserts the issue names hideWhen. The 2 greens are deliberate and reported as such rather than dressed up: the boolean back-compat case (green by design — that is what it pins) and the bare-string-rejected case, which guards over-widening and therefore cannot discriminate under-widening. plugin-hono-server's clamp case went red on the reverted clamp ('expected false to be true'). Restored, both re-run green (spec 8/8, hono 13/13), and `git diff HEAD` is empty against the committed tree.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Answers to the card's explicit questions

    Sibling-card impact — verified, not assumed: all three untouched.

    Changeset lean. @objectstack/spec minor — the accepted-input surface
    grows (an object-form create: that is rejected today validates after this),
    which is a widening, not a fix, and not a break: every payload that validated
    before still validates identically and the boolean-only path still emits no
    predicate keys. @objectstack/plugin-hono-server patch — it only tracks the
    widened producer, with no surface change of its own. No skip-changeset label:
    this PR ships a changeset.

    Scope held. No objectui changes (the related-list toolbar renderer is the
    downstream card). Nothing touched in filter.zod.ts. No
    content/docs/releases/ edit. The one file outside packages/spec —
    plugin-hono-server's clamp — is the consumer half of this change, not an
    unrelated bug: leaving it would have made this PR silently tighten a
    permission hint, so it is in scope by the contract-first rule rather than
    filed as a finding.

    Note on the platform PR subscription. This session was auto-subscribed to
    PR #7758 by the platform with the standard resident/drive-to-green text. Per
    .claude/agents/os-dev.md (maintainer ruling 2026-08-11, #7512) the dispatch
    contract outranks it for a dispatched dev: reporting at draft-PR time, no CI
    idle-polling, no self check-ins. CI convergence, the ready-flip and landing are
    the PM's. Nothing beyond the standard subscription text was involved.


    Generated by Claude Code

  6. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Step-7 review — ACCEPT (spec-lane PM, session session_01JY2Q5Xto1u8YHADgrZDTnk). PR: #7758 (draft, Fixes #7692).

    • Ruling lands as pure symmetry: create/import take the SAME RowCrudActionOverrideSchema piece as edit/delete — one definition, not a copy; resolveCrudAffordances carries createPredicates/importPredicates through the same collapse, so the declared predicate is reachable, not declared-and-inert.
    • The binding-context honesty is the mark of a careful run: create/import evaluate once per toolbar against the HOST record (or nothing on a standalone list, where fail-closed hides the button) — stated in the describes with the authoring consequence, instead of shipping the false implication that record.* means the same thing in both positions.
    • A boundary-crossing consumer caught proactively: the /me/permissions clamp in plugin-hono-server read create as a bare boolean — left alone, this PR would have silently clamped the create hint OFF for object-form values. Fixed through the same isWriteOptedIn helper, with the clamp case red in reverse verification. This is exactly the "does the benefit survive the boundary" failure class, closed before review had to find it.
    • Reverse verification reported with per-case honesty: 6/8 red including the unknown-key case asserted on hideWhen specifically (green-for-the-wrong-reason ruled out); the 2 deliberate greens recorded as what they are.
    • Changeset spec minor + hono-server patch, correctly graded. Gates green incl. check:generated 13/13.

    Landing: object.mdx regenerated ⇒ joins the os-regen relay. Position: after #7756 alongside #7759 (order by slot readiness). objectui's related-list toolbar card unblocks on merge.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions