You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
userActions: z.ZodOptional<z.ZodObject<{create: z.ZodOptional<z.ZodBoolean>;// ← boolean onlyimport: z.ZodOptional<z.ZodBoolean>;// ← boolean onlyedit: 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:
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.
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.
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).
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.
{
"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/specminor — 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-serverpatch — 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.
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.
Activity
claude commentedon Aug 11, 2026
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
os-zhuang commentedon Aug 11, 2026
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
os-zhuang commentedon Aug 11, 2026
Claim: PM loop round 2 (spec lane)
Session:
session_01JY2Q5Xto1u8YHADgrZDTnkBranch:
claude/issue-7692-useractions-create-import-predicateWorktree: cloud dev session (own container)
Domain:
domain:specFile 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: opusSerial 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-zhuang commentedon Aug 11, 2026
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 itschanged-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 andboth touch
content/docs/references/— but different generated files(
ui/component.mdxvsdata/object.mdx). They compose; they do not conflict.SECRET_MASKand service-settings'SETTINGS_SECRET_MASK#7572 (secret-mask) — no open PR yet. Its surface isobjectql'sSECRET_MASK,service-settings, and (under the recommended option A) aconstant hoisted into
packages/spec/src/data/field.zod.ts— notdata/object.zod.ts. Disjoint.GET /i18n/localesdeclares a localelabelas "Display name" but every producer sets it to the code #7634 (locale label) — no open PR yet. Its surface ispackages/spec/src/api/protocol.zod.tsandpackages/spec/src/system/i18n-resolver.ts. Disjoint.Changeset lean.
@objectstack/specminor — the accepted-input surfacegrows (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-serverpatch — it only tracks thewidened producer, with no surface change of its own. No
skip-changesetlabel: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. Nocontent/docs/releases/edit. The one file outsidepackages/spec—plugin-hono-server's clamp — is the consumer half of this change, not anunrelated 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 dispatchcontract 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
os-zhuang commentedon Aug 11, 2026
Step-7 review — ACCEPT (spec-lane PM, session
session_01JY2Q5Xto1u8YHADgrZDTnk). PR: #7758 (draft,Fixes #7692).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.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./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.hideWhenspecifically (green-for-the-wrong-reason ruled out); the 2 deliberate greens recorded as what they are.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
feat(spec): userActions.create/import accept the edit/delete CEL pred…