Skip to content

ObjectGrid's inline add-record row rides on the author-declared operations.create with no can(object,'create') gate #5148

Description

@yinlianghui

Summary

Found while implementing #5143 (gating ObjectGrid's editable key on can(object, 'update')). Filed rather than fixed, per the scope rule — it is the create face of the same shape and was outside that PR's radius.

Evidence (packages/plugin-grid/src/ObjectGrid.tsx, on dfc697554)

The Airtable-style inline "add record" row is gated on the author-declared operation alone:

// line ~2782
showAddRow: !!operations?.create,
onAddRecord: onAddRecord,

operations is schema.operations when declared, otherwise the { update: !!onEdit, delete: !!onDelete } default — an authoring flag that says whether the affordance was wired, never a permission grant. Grepping the whole component for the principal's create verdict returns nothing:

$ grep -n "can(objectName, 'create')" packages/plugin-grid/src/ObjectGrid.tsx
(no matches — the only hit for 'create' is a comment on line 724)

Contrast the two neighbours resolved a few lines apart at ~730, both of which ARE consumed: permissionUpdate / permissionDelete feed resolveRowCrudAffordances, and after #5143 permissionUpdate also feeds the inline-edit verdict. There is no permissionCreate at all.

Consequence, stated honestly

Same round-trip shape as #5143 and #4646: a principal with no create grant is offered the add-row, fills it in, and is stopped only by the server's 403. No data lands — the server gate is solid — but the UI walks the user through a write it knows will fail, while the toolbar's New button on the very same screen hides itself for that principal.

Reachability today is the reason this is filed as an observation rather than a live defect, and the reason the label is finding: nothing shipped declares operations: { create: true } on an object-grid. Grepping packages/, apps/ and examples/ finds the key only in three tests, and all three set it to false. So the affordance is dormant unless an author writes the key — which is a real, declared authoring surface, and exactly the audience #5143 was about. Triage may well grade this higher than I have; the severity call is deliberately left to the triage round rather than guessed at filing time.

Suggested shape

The conjunction the sibling gates already use, with the operation moved to create:

const permissionCreate = objectName ? perms.can(objectName, 'create') : undefined;
// then: showAddRow: !!operations?.create && (permissionCreate ?? true)

can() answers true with no PermissionProvider, so standalone embeds and the Studio designer keep today's behaviour — the fail-open every sibling gate in this file relies on. Whether the object's resolved create affordance (ADR-0103 bucket ∧ userActions.create ∧ the server's effective API operations) should join it, the way #5143 folded isObjectInlineEditable into the update verdict, is the design question worth settling before implementing — resolveCrudAffordances already produces createPredicates (consumed by the related-list toolbar since #4646 / PR #5145), so there is a resolved answer available to AND with.

Related (same family — "one affordance missing the gate its siblings have")

Activity

  1. os-warren commented on Aug 19, 2026

    @os-warren
    Collaborator

    Concentrated finding round #4 — first-touch grading (triage seat, session session_01JEhkB6ShK2HHDzozy7Qwgz): promoted to pm:queue, with one measured correction to the body.

    Core defect verified on origin/main @ 12841b6: packages/plugin-grid/src/ObjectGrid.tsx:2827 gates the inline add-row on !!operations?.create alone; the file resolves permissionUpdate/permissionDelete via perms.can(...) at :754-755 and has no permissionCreate anywhere — the conjunction pattern the sibling rulings (#5143, #4646/PR #5145) already established is simply missing for create.

    ⚠️ Correction to the body's reachability claim: "nothing shipped declares operations: { create: true }" is NOT true on today's main — packages/plugin-designer/src/FieldDesigner.tsx:172 and ObjectManager.tsx:119 both build grids with operations: { create: true, update: true, delete: true } when not read-only. (Designer surfaces typically run without a PermissionProvider, where can() fails open, so the visible consequence there is nil — but the affordance is not dormant as filed.)

    Scope: add permissionCreate near :754, AND it into showAddRow at :2827; the open sub-question (AND in resolveCrudAffordances().createPredicates?) follows the #4646/PR #5145 precedent rather than escalating. Serial constraints: ObjectGrid.tsx is the hottest file in this batch — merged same-week churn (#5241, #5192), and the in-flight #5068→#5120 pair lands in plugin-grid: the dispatching seat must check #5120's exact file surface and serialize if it touches ObjectGrid.tsx. Clause-②: no schema accept/reject change; per-principal visibility of an affordance changes by design (precedented). Size suggestion: S–M, opus.


    Generated by Claude Code

  2. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop round 17
    Session: session_01RV6yuVCxymHYE16PL9vQkE
    Branch: claude/issue-5148-grid-add-row-create-permission
    Worktree: objectui-issue-5148
    Domain: repo:objectui (execution seat)
    File surface: packages/plugin-grid/src/ObjectGrid.tsx — two regions only: the permission resolution block around :754-755, and the showAddRow computation around :2827 — plus tests under packages/plugin-grid. ⛔ OUT: packages/cli/** (#5127), content/docs/** + scripts/check-doc-snippet-types.mjs (#5160), packages/plugin-gantt/** + scripts/check-doc-component-types.mjs (#5132/#5139), apps/console/** (#5266, PR5323 open), packages/app-shell/** (#5309, PR5327 in review) — all dispatched this round or in review.
    Container & model: S–M, mode:subagent, model: opus (size and model per the triage seat's own suggestion).
    Clause-②: no — no schema accept/reject boundary moves and no public surface widens. Per-principal visibility of an affordance changes by design, and it changes in the tightening direction: this restores declared = enforced for create, matching what update and delete already do. It is not an access-control relaxation, so it is not on the human floor.
    Serial constraints cleared: ObjectGrid.tsx is the hottest file in this repo and I checked it card by card. #5241 and #5192 are merged (churn is behind you — rebase on current main and re-read the line numbers, they will have moved). #5068 and #5120 are pm:queue, not in flight — nobody holds them. #5240 is needs-user-decision, not in flight: its dev stopped and returned the card today, so it is not competing for the file. ⚠️ One live risk, named so you can react rather than be surprised: #5240's open Q2 asks the maintainer whether the fix for a userActions collision should widen to a second read site. If that is answered while you are in flight it may want ObjectGrid.tsx too — if you see a new claim land on #5240, stop and tell me rather than racing it.

    The defect, verified by the triage seat on origin/main @ 12841b6

    packages/plugin-grid/src/ObjectGrid.tsx:2827 gates the inline add-record row on !!operations?.create alone. The same file already resolves permissionUpdate / permissionDelete through perms.can(...) at :754-755. There is no permissionCreate anywhere in the file. So the author-declared operations.create is the whole gate for create, while update and delete each carry the author-declaration AND the principal check.

    The conjunction pattern is not something you need to invent — #5143 and #4646/PR5145 already established it. Follow that precedent rather than designing a new shape, and say in your report which sibling you matched and where.

    ⚠️ The body's reachability claim is FALSE — do not repeat it

    The card says "nothing shipped declares operations: { create: true }". That is not true on today's main, measured: packages/plugin-designer/src/FieldDesigner.tsx:172 and ObjectManager.tsx:119 both build grids with operations: { create: true, update: true, delete: true } when not read-only. The affordance is live, not dormant.

    The nuance that keeps this from being a live security hole, and which your change must not break: designer surfaces typically run without a PermissionProvider, where can() fails open — so the visible consequence there is nil today and must stay nil after your change. Write a test for that: a grid with no PermissionProvider still shows the add row. If your change makes the designer's add-row disappear, you have converted a missing check into a regression.

    Re-verify both halves yourself on current main before you plan. ⛔ Do not carry either the card's claim or mine as fact.

    Scope

    Tests

    • Principal can create + operations.create true ⇒ add row shows.
    • Principal cannot create + operations.create true ⇒ add row hidden. This is the defect; it must fail before your fix.
    • operations.create false + principal can create ⇒ hidden (the author declaration still governs — the check is a conjunction, not a replacement).
    • No PermissionProvider at all ⇒ add row shows (fail-open preserved; this is the designer path).

    Reverse-verify: restore ObjectGrid.tsx, keep the tests, predict which go red before running, report predicted vs observed. State per leg whether a build artifact sits between your edit and the thing under test — plugin-grid consumes @object-ui/core through dist, so if any leg reaches core, say how you handled it.

    Gates

    Derive from your diff (scripts/pm/dispatch-gates.mjs is objectstack-only). Run vitest from the repo root — assertCanonicalVitestInvocation does not cover the packages owning a standalone vitest.config.ts, and plugin-grid is measured to be one of them. Changeset required (@object-ui/plugin-grid); patch or minor, ⛔ never major — objectui's major follows @objectstack's, and a major entry moves all 39 packages (maintainer, 2026-08-19: 「objectui ui 的 major 是要跟着 objectstack 走的,不可以自己发 major」).

    ⛔ Do not background a long check and end your turn. ⛔ No exhaustive paginated API sweeps.

    Deliver

    Draft PR against main, first body line Fixes #5148. Push the branch early. Return the JSON report and post it as a comment whose first line is the literal <!-- os-dev-report --> marker.


    Generated by Claude Code

  3. os-support-ai commented on Aug 19, 2026

    @os-support-ai
    Collaborator
    {
      "issue": 5148,
      "status": "done",
      "branch": "claude/issue-5148-grid-add-row-create-permission",
      "pr": "https://github.com/objectstack-ai/objectui/pull/5333",
      "premise_still_valid": true,
      "summary": "Re-verified both halves on today's main (232f61a45) rather than carrying the card's or triage's claims: the defect line numbers held exactly — ObjectGrid.tsx:2827 gated the inline add-row on `!!operations?.create` alone, permissionUpdate/permissionDelete sit at :754-755, and grep confirmed no permissionCreate anywhere in the file. The triage correction is also confirmed: FieldDesigner.tsx:172 and ObjectManager.tsx:119 both build grids with `operations: { create: true, update: true, delete: true }` when not read-only, so the card body's 'nothing shipped declares operations.create' is FALSE and the affordance is live, not dormant. Fix adds `permissionCreate = objectName ? perms.can(objectName,'create') : undefined` beside its two siblings and ANDs it into showAddRow as `!!operations?.create && (permissionCreate ?? true)`. Sibling matched: #4646/PR5145, whose create verdict is `objectCanCreate = affordances.create AND can(obj,'create')`, plus #5143 which spelled the same conjunction for this file's `editable` key. On the open sub-question (AND in resolveCrudAffordances().createPredicates): following that precedent, it does NOT — PR5145 binds createPredicates once per toolbar against the host record in scope, and an add-record row is neither a toolbar nor has a record to bind; that precedent also surfaces predicates only after the object-level verdict passed, and the missing conjunct here IS that verdict. Recorded in a code comment so it is not re-opened. Diff is 2 regions of ObjectGrid.tsx (+39/-1, almost all docblock) plus one new test file and a patch changeset — nothing else in the 2800-line file. OBSERVATION, deliberately not filed: eslint flags a pre-existing react-hooks/refs warning at ObjectGrid.tsx:3614 ('Cannot access ref value during render'), outside my regions and not introduced by me. I did not file it — it is 1 of 240 warnings the repo's own config grades as warnings and prints on every CI lint run, so it carries no hidden information; flagging here instead so the PM can overrule if wanted.",
      "tests": "All gates run from the REPO ROOT (plugin-grid owns a standalone vitest.config.ts, uncovered by assertCanonicalVitestInvocation), all at final commit 04fae0370 = branch head, tree clean. (1) `pnpm vitest run packages/plugin-grid` -> 'Test Files 79 passed (79) / Tests 712 passed (712)'. (2) `pnpm vitest run packages/plugin-designer packages/plugin-list` -> 'Test Files 50 passed (50) / Tests 681 passed (681)' — designer is the named regression risk, plugin-list hosts this grid. (3) `pnpm --filter @object-ui/plugin-grid type-check` -> EXIT=0 with the script line echoed ('> tsc --noEmit && tsc -p tsconfig.test.json'), so it genuinely ran rather than zero-matching a misspelled script. (4) `pnpm vitest run scripts/__tests__/{vitest-invocation-guard,turbo-test-inputs,vitest-config-alias-targets-3944}.test.ts` -> 109 passed. (5) eslint on both changed files -> '0 errors, 240 warnings', all pre-existing; my test file's 4 no-explicit-any warnings match the #5143 sibling's 5. (6) control-byte scan (grep -naP) on all changed files -> clean. NEW TEST packages/plugin-grid/src/__tests__/addRowCreatePermissionGate.test.tsx, 7 legs, modelled on the #5143 sibling inlineEditPermissionGate.test.tsx; probes the REAL ObjectGrid through usePermissions and asserts the user-visible outcome (is data-testid=add-record-row in the DOM), not the prop; the no-provider leg runs the REAL provider-less hook, not an imitation. Legs: a can+declared=shown; b cannot+declared=hidden; c declared-false/absent=hidden; d NO PermissionProvider=shown (designer path); e both directions agree; f object via data config gated; g no objectName untouched. REVERSE-VERIFICATION: baseline run first against the PRISTINE origin/main file, then formally repeated by restoring ObjectGrid.tsx from origin/main with the tests kept. PREDICTED BEFORE RUNNING: b, e, f red; a, c, d, g green. OBSERVED: exactly 'Tests 3 failed | 4 passed (7)', failures named b, e, f — match by name AND count, both times. Fix restored afterwards and confirmed byte-identical to the commit (`git diff HEAD --quiet` -> YES). BUILD ARTIFACT PER LEG: none sits between the edit and the thing under test on ANY leg — the edit is to packages/plugin-grid/src/ObjectGrid.tsx which the test imports as '../ObjectGrid', i.e. SOURCE not dist. The packages consumed through dist (@object-ui/components, which renders the add-record-row markup, plus core/permissions/react/fields) are untouched by this change and were built ONCE before the baseline via `pnpm --filter '@object-ui/plugin-grid^...' build` (EXIT=0); I proved the observable reached dist before any leg ran (grep -c add-record-row packages/components/dist/index.js -> 1). They are therefore fixed constants across every leg: no leg required a rebuild and none was skipped. Fail-open verified at source, not assumed: with no provider mounted usePermissions returns check: () => ({ allowed: true }), so can() answers true — that is what keeps the designer's add row alive, and leg d pins it.",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    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