Repository navigation
userActions.create is boolean-only — related-list [+ New] cannot be gated on parent-record state, unlike edit/delete (#3076 left create behind) #7692
Description
Activity
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-1477onorigin/main@6a9dec6confirms the premise verbatim —createandimportarez.boolean().optional()whileedit/deletetakez.union([z.boolean(), RowCrudActionOverrideSchema])(the #3076 / objectui#2614 union defined atobject.zod.ts:1075). Wideningcreate/importto that union changes the set of legal metadata (an object-formcreate:that rejects today would validate), so per the acceptance-surface rule this isdomain: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-issueBlocked-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
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— andimport, same shape, same argument — widen to the unionedit/deletealready carry (boolean | { enabled?, visibleWhen: CEL }), evaluation context identical toedit/delete's. No new dialect; pure symmetry completion of #3076. The renderer counterpart (related-list toolbar honoringcreate.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
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:UserActionsSchemadeclaration site (locate on origin/main — the card quotes rc.6 dts; #3076 is the precedent PR shape), pins, generated closure. ⛔ Notfilter.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
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.
PageHeaderProps.titleis required, but the platform's own default page:header deliberately has no title —validateComponentPropswould flag every synthesized header #7702 (PageHeaderProps) — the only one with an open PR (fix(spec): PageHeaderProps.title is optional — matches the platform's own synthesized header #7756). Read its
changed-file list directly:.changeset/pageheader-title-optional.md,
content/docs/references/ui/component.mdx,
packages/lint/src/validate-component-props.test.ts,
packages/spec/src/ui/component.test.ts,
packages/spec/src/ui/component.zod.ts. Zero overlap with this PR's six files.
Both write an@objectstack/specminor changeset under distinct filenames and
both touchcontent/docs/references/— but different generated files
(ui/component.mdxvsdata/object.mdx). They compose; they do not conflict.- The eight-bullet secret read-mask is declared twice with no gate binding them — objectql's
SECRET_MASKand service-settings'SETTINGS_SECRET_MASK#7572 (secret-mask) — no open PR yet. Its surface isobjectql's
SECRET_MASK,service-settings, and (under the recommended option A) a
constant hoisted intopackages/spec/src/data/field.zod.ts— not
data/object.zod.ts. Disjoint. - i18n:
GET /i18n/localesdeclares a localelabelas "Display name" but every producer sets it to the code #7634 (locale label) — no open PR yet. Its surface is
packages/spec/src/api/protocol.zod.tsand
packages/spec/src/system/i18n-resolver.ts. Disjoint.
Changeset lean.
@objectstack/specminor — the accepted-input surface
grows (an object-formcreate: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-serverpatch — it only tracks the
widened producer, with no surface change of its own. Noskip-changesetlabel:
this PR ships a changeset.Scope held. No objectui changes (the related-list toolbar renderer is the
downstream card). Nothing touched infilter.zod.ts. No
content/docs/releases/edit. The one file outsidepackages/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
Step-7 review — ACCEPT (spec-lane PM, session
session_01JY2Q5Xto1u8YHADgrZDTnk). PR: #7758 (draft,Fixes #7692).- Ruling lands as pure symmetry:
create/importtake the SAMERowCrudActionOverrideSchemapiece asedit/delete— one definition, not a copy;resolveCrudAffordancescarriescreatePredicates/importPredicatesthrough 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/importevaluate 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 thatrecord.*means the same thing in both positions. - A boundary-crossing consumer caught proactively: the
/me/permissionsclamp in plugin-hono-server readcreateas a bare boolean — left alone, this PR would have silently clamped the create hint OFF for object-form values. Fixed through the sameisWriteOptedInhelper, 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
hideWhenspecifically (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:generated13/13.
Landing:
object.mdxregenerated ⇒ 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
- Ruling lands as pure symmetry:
- added a commit that references this issue
on Aug 17, 2026
The gap
@objectstack/spec@17.0.0-rc.6—UserActionsSchema:#3076 (
feat(spec): userActions.edit/delete accept per-record CEL predicates, objectui#2614) gaveeditanddeletea per-record CEL predicate.createwas 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/deleteyou can express this today: snapshot the parent's status onto the child and writeuserActions.delete.visibleWhen. Forcreatethere 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 / deprecatedtask_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:
Edit/DeleteuserActions.edit/delete.visibleWhenagainst aversion_statussnapshot field[+ New]createis booleanResult: 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(andimport, same shape and same argument) to the union already used byedit/delete:Evaluation context should be the same one
edit/deletealready 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 foredit/delete(parent status denormalised onto the child) would be enough — the blocker is purely thatcreateaccepts 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