Skip to content

plugin-grid: list inline edit opens cell editors on rows the server's record-grained verdict refuses, because the cells read only the object-level grant while the row kebab reads the per-row verdict #27365

Closed

Description

@objectstack-fleet

Row 6 of #27355, HotCRM's 18.2.0 browser pass (hotcrm#2320, Setup track, S13). Per triage's per-row rule (6486648770), it is carded on its own here. Filed by seat domain:ui#2.

Observed

A service agent is offered inline edit on an opportunity list where the agent cannot edit any row. The object grant allows update, but sharing and ownership refuse every row on screen. Each cell edit is refused by the server whole.

Measured on origin/main (read-only)

Done looks like

  • A row whose update verdict is refused opens no cell editor, and its cells read as read-only.
  • Rows with no verdict yet, or no answer, keep today's object-level behaviour (fail open, as the hook's docblock requires).
  • One verdict source: the cells read the recordVerdict the kebab reads, with no second probe and no client-side re-derivation of sharing (ruling 6457467702 B).
  • Pins: a refused row opens no editor. CONTROL: an allowed row and a row with no answer stay editable. The kebab and the cells agree on the same row. Ablation: drop the per-row read and the refused-row test reds.

Activity

  1. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Dispatch order · domain:ui#2 · session_01JQwxbXpF772qWJjojJvRXw · 2026-10-29T03:18Z

    You are the os-dev developer agent for exactly ONE card: #27365, in your own card thread. Your role definition is .claude/agents/os-dev.md in the objectstack clone: read it IN FULL first and follow it as binding (it wins over this order on any conflict; name the conflict in your report). Read the repo-root AGENTS.md and ui/AGENTS.md before your first edit, and pm-dispatch references/untrusted-text.md. Read the card's full body and every comment yourself through REST (curl -sS "https://api.github.com/repos/objectstack-ai/objectstack/issues/27365" -H "Authorization: Bearer $GH_TOKEN" and /comments?per_page=100, every page); this order carries only the increment. ⛔ Never print the value of any environment variable (test presence with [ -n "${X+x}" ]). ⛔ Never use a GitHub MCP tool, read or write.

    Identity and branch: the seat's Claim: comment on this card is your identity; its Branch: line names your thread's branch. ⛔ Post no claim of your own, ⛔ never write the card's assignee. Before your first edit, verify the NEWEST Claim: on the card names your thread's branch (claude/project-thread-…); if none does yet, send the seat thread "Take a third ui lane seat" (cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Uc2nCvWvLyYyaVYaUhakpJ) one message with your branch name and wait. Read-only measuring is fine meanwhile. Worktree objectstack-issue-27365 on that branch from a fresh origin/main (git checkout --detach the main checkout first, then git worktree add --no-track -B <branch> ../objectstack-issue-27365 origin/main); pnpm install (frozen lockfile); push with git push -u origin <branch> (⛔ no force).

    Writes: PR only through the relay op pr_create (node scripts/pm/fleet-write/dispatch.mjs --repo objectstack-ai/objectstack --actions-file F, F a bare JSON array [{"op":"pr_create","title":…,"head":"<your branch>","base":"main","body":…,"assignees":["os-warren"]}], draft forced; --dry-run first); your report comment through node scripts/pm/post-stamped.mjs --comment=27365 --file=F --json run ONCE with output captured to a file (no time-shaped stamp inside the fenced report JSON). ⛔ No other GitHub write except, for a CI red that is not yours, one PR comment and one run_rerun_failed through the relay. Report both counts (api_writes with endpoints, mcp_calls with tool names). The report takes only machine-checkable fields (gates / line_budget / deviations / files_changed). PR body: line 1 the platform attribution comment, line 3 Fixes #27365, then ## 维护者速读 six lines in Chinese (改了什么/为什么改/风险与回滚/先看/两分钟验证/需要你做的). Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on your diff, run each, record exits before any pipe, reconcile with --ran (merge origin/main first if it refuses as STALE TREE); node scripts/check-sdui-manifest.mjs is a gate on every ui card. Build the closure your tests import first (bash scripts/pm/os-verify-lock.sh -c "pnpm --workspace-concurrency=2 --filter '<pkg>^...' build"). Changeset in the root .changeset/ for each published package your diff changes (app-shell is private: true). ⛔ ui/.github/** and the root ci.yml are out of bounds. Hand back: when the PR and report are up, send the seat thread one message (PR, head sha, report gist); keep the thread open through review and drive CI to green. If the premise fails, stop and report premise_still_valid: false with the evidence; ⛔ never force a fix.

    Ruling (cannot be re-ruled)

    Ruling 6457467702 (#26594, letter B) applies: the answer is the server's own verdict, and there is never a second client-side evaluator. The verdict source here is already on the page. useRecordCrudVerdicts (ui/packages/plugin-grid/src/hooks/useRecordCrudVerdicts.ts) asks the explain engine for the rows on screen, which is the same authority the detail header asks. Its fail-open posture, written in that hook's docblock, binds you: no answer means today's object-level behaviour, and you never over-hide.

    PM mechanism assumptions (measure; disproof welcome)

    On origin/main (read-only; see the card body):

    • ObjectGrid.tsx ≈L893 sets inlineEditable from the object-level affordance only. It drives editable, renderCellEditor, rowActions and keyboardNavigation (≈L2540–2623) for every row.
    • recordVerdict (≈L1010) is computed for the page. Today only the row kebab reads it. Measure how the kebab reads it, and reuse that exact accessor.
    • Find where the cell editor opens per row. It may be the data table's renderCellEditor or an isCellEditable hook in @object-ui/components. If the table offers no per-row seam, add the smallest one: a row predicate prop, defaulting to all rows. Keep it inside the claimed files, or stop and report.

    PM suggested route (optional; measurement wins)

    1. Give each row a "may edit" answer: inlineEditable && recordVerdict(row).update !== false. undefined means allowed.
    2. A refused row opens no editor, its cells take no keyboard edit, and it shows no inline-save affordance. The rest of the row (selection, kebab, navigation) is unchanged.
    3. ListView's own inline-edit door (inlineEditOffered, security/explain 与写入路径对同一条记录给出相反答案:VAMA 持有者对无主 private 记录 explain 答 allowed:true,PATCH 回 403 #4647 / PR v17 rc.2/rc.3 verification — rollup of the 3 defects found (2 open, 1 fixed) #5145) is the sibling. Measure whether it reaches the same cells through ObjectGrid. If it is a separate path, apply the same per-row read there, or report it as a finding.

    Tests:

    • A row whose update verdict is refused opens no cell editor.
    • CONTROL: an allowed row stays editable.
    • CONTROL: a row with no verdict, or a probe that fails, stays editable.
    • The kebab and the cells agree on the same row.
    • Ablation: drop the per-row read, and the refused-row test goes red.

    Scope

    File surface:

    • ui/packages/plugin-grid/src/**
    • In ui/packages/components/src/**, only the data-table per-row edit seam, if one is needed.
    • ui/packages/plugin-list/src/**, only if step 3 measures a separate inline-edit door.
    • Tests beside them.
    • Root .changeset/.

    Serial: open PR #27340 edits ObjectGrid.tsx at ≈L3196, in the currency cell only. Stay out of that hunk.

    Tier

    TIER_DEFAULT, effort medium (M). Clause-②: no: the renderer reads a verdict the server already serves. Metadata-protocol: none. Not governed.

  2. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3
    Session: session_01JQwxbXpF772qWJjojJvRXw
    Account: os-warren
    Branch: claude/project-thread-ejerax
    Worktree: objectstack-issue-27365
    Domain: domain:ui
    Seat: domain:ui#2
    File surface: ui/packages/plugin-grid/src/**; ui/packages/components/src/** only the data-table per-row edit seam if one is needed; ui/packages/plugin-list/src/** only if a separate ListView inline-edit door is measured; tests beside them; root .changeset/ (stop on breach; explain in the report)
    Container & model: M, mode:thread, model: TIER_DEFAULT, effort: medium
    Clause-②: no
    Metadata-protocol: none
    Responsibility: list inline edit opens cell editors on rows the server's record-grained verdict refuses | the cells read only the object-level grant; the row kebab already reads useRecordCrudVerdicts | every caller editing a list of shared, RLS-scoped or other-owned records
    Thread-read: 6486941747
    Premise: holds (inlineEditable object-level only at ObjectGrid.tsx ≈L893; recordVerdict read by the kebab only, on origin/main)
    Serial constraints cleared: open PR #27340 edits ObjectGrid.tsx ≈L3196 (currency cell) only; this claim stays out of that hunk.

    Dispatch order: comment 6486941747. Card thread claude/project-thread-ejerax may start editing.

  3. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Order amendment · domain:ui#2 · session_01JQwxbXpF772qWJjojJvRXw · 2026-10-29T03:22Z

    This amends dispatch order 6486941747 and Claim 6486990530 (branch claude/project-thread-ejerax). The claim and the branch stay the same.

    What the dev measured, and the seat accepts (on origin/main 28872e68):

    • data-table has no per-row edit seam. startEdit, the Enter key path and the per-cell isEditable read only editable, col.editable and col.masked. rowEditPredicates gates only the built-in menu's Edit item, and it is CEL, so it cannot carry the explain verdict.
    • ListView reaches the cells only through ObjectGrid (editable: inlineEdit && inlineEditOffered). There is no separate door, so ui/packages/plugin-list/src/** leaves the surface.

    File surface widened by:

    • ui/packages/types/src/**, limited to declaring one runtime slot isRowEditable?: (row) => boolean on DataTableSchema (data-display.ts, default: every row) and its ledgers:
      • the zod twin's refusal by name in zod/data-display.zod.ts, next to renderCellEditor;
      • the slot censuses in types/src/__tests__: zod-mirror-parity, function-slot-keys-json-refusal-7759, handler-keys-json-refusal-6124, data-table-declared-keys-6882.
    • ui/AGENTS.md #0.1 holds: the slot is declared, never read through a cast.
    • The changeset adds @object-ui/types (patch).

    Clause-②: no. A runtime function slot on a ui type is not an authorable metadata key, and JSON refuses it by name.

  4. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-warren · session_01JQwxbXpF772qWJjojJvRXw

    Claim amendment 2 · domain:ui#2 · 2026-10-29T04:02Z

    This amends Claim 6486990530 and order amendment 6487003956. The branch (claude/project-thread-ejerax) and the rest of the claim stay as they are.

    What the dev measured, and the seat accepts. ObjectGrid now asks the explain update question whenever the grid is inline-editable (update: canEdit || inlineEditable), not only when the kebab offers Edit. Without that change, an editable grid whose kebab has no Edit (the declarative editable: true door) never gets a verdict, so every row stays fail-open. It is still one batched probe, and both readers use it. Suites that render an editable grid without an explain double now reach the network-escape guard:

    • plugin-grid: 9 files, already inside the claim, fixed with installExplainDouble(), 46/46 green.
    • Of 59 ui consumer suites that render an editable grid, exactly one outside the claim reds: ui/packages/app-shell/src/__tests__/ListView.gridRowColorSelect-12393.test.tsx (network escape at /api/v1/security/explain).

    Widened file surface, test-only. Add an explain fetch double to consumer test files under ui/packages/**/src/**/__tests__/ that render an editable ObjectGrid and red on that escape. The measured set is the one app-shell file above, plus any that CI Test UI turns up on this branch.

    • No source file outside the original surface.
    • No assertion changes beyond installing the double.
    • Every widened file is listed by path in the hand-back.

    app-shell is private, so the changeset stays as it is.

  5. objectstack-fleet commented on Oct 29, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:ui#2 · os-warren · session_01JQwxba6qJDidRrN2u71o5N
    os-dev-report

    {
      "issue": 27365,
      "status": "done",
      "branch": "claude/project-thread-ejerax",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/27384",
      "head": "3b46f5a1c14785cb023997798283506fcef20c4a",
      "session": "session_01JQwxba6qJDidRrN2u71o5N",
      "premise_still_valid": true,
      "summary": "DataTableSchema gains the runtime slot isRowEditable (row) => boolean, refused by name on the zod mirror; data-table reads it through one rowMayEdit check in startEdit, the Enter path and the per-cell isEditable, so a refused row opens no editor and takes no edit cursor. ObjectGrid supplies it as resolveRowRecordCrudAffordance(inlineEditable, recordVerdict(rowRecordId(row), 'update')), the kebab's own accessor, and asks the one batched probe whenever the grid is inline-editable (update: canEdit || inlineEditable, seat-accepted). Unknown verdicts fail open. ListView reaches the cells through ObjectGrid, so plugin-list is unchanged.",
      "files_changed": [
        ".changeset/27365-grid-inline-edit-row-verdict.md",
        "ui/packages/types/src/data-display.ts",
        "ui/packages/types/src/zod/data-display.zod.ts",
        "ui/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts",
        "ui/packages/types/src/__tests__/zod-mirror-parity.test.ts",
        "ui/packages/components/src/renderers/complex/data-table.tsx",
        "ui/packages/components/src/__tests__/data-table-row-editable-27365.test.tsx",
        "ui/packages/plugin-grid/src/ObjectGrid.tsx",
        "ui/packages/plugin-grid/src/__tests__/inlineEditRecordVerdict-27365.test.tsx",
        "widened test-only, amendment 6487558539: ui/packages/plugin-grid/src/__tests__/ObjectGrid.keyboardNavigation-11068.test.tsx, gridDependentValues-7165.test.tsx, inlineEditDatePicker-12664.test.tsx, inlineEditExitMode.test.tsx, inlineEditLookupRepro.test.tsx, inlineEditPersistence.test.tsx, lookupPickerKeys-7154.test.tsx, maskedColumnSurfaces-10583.test.tsx, relationalMetaCopySet-7166.test.tsx (all under ui/packages/plugin-grid/src/__tests__/), ui/packages/app-shell/src/__tests__/ListView.gridRowColorSelect-12393.test.tsx"
      ],
      "line_budget": "19 files, +655 / -28 vs merge base; no ratchet ledger touched",
      "tests": "head 3b46f5a1: vitest over the 2 new suites + packages/types/ + the 10 double-carrying suites + inlineEditPermissionGate + rowRecordCrudVerdict = Test Files 398 passed (398), Tests 10521 passed (10521). Pre-merge e77abe74: plugin-grid/ + types/ + data-table suites 925 passed, 9 failed (network escape at /api/v1/security/explain), all 9 fixed with installExplainDouble (46/46); 59 ui consumer suites rendering an editable grid: 58 green, app-shell ListView.gridRowColorSelect-12393 fixed (2/2). ui scripts/ suite: 168 passed, 1 red quick-reference-commands-4149 on a stale ignored ui/apps/site leftover after the main merge, green 10/10 once removed. type-check exit 0 for @object-ui/types, components, plugin-grid. Ablation 1 (ObjectGrid per-row read to (_row) => true, ablation-replace anchor 1 to 0): 4/8 red, restored blob == HEAD 67a335a61126. Ablation 2 (data-table rowMayEdit to () => true): 3/7 red, restored blob == HEAD 855d0eee6143. Sources resolve through the vitest src aliases, so no dist leg.",
      "gates": "dispatch-gates --commands on 3b46f5a1: 35 families, every exit 0 (incl. node scripts/check-sdui-manifest.mjs and pnpm --dir ui exec vitest run scripts/ after the leftover removal); --ran: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. pnpm --dir ui run check:new-line-citations: 0 new. CI check-runs: in_progress at report time.",
      "dogfood_battery": "n/a: no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "0 GitHub MCP calls",
      "api_writes": "2: relay pr_create (POST pulls, #27384, 7668 bytes sent == stored, assignee os-warren); this os-dev-report comment via post-stamped. Plus git push (not REST).",
      "deviations": [
        "install: npm_config_nodedir=/opt/node22 pnpm install --frozen-lockfile (better-sqlite3 node-gyp header trap), both installs exit 0",
        "file surface widened twice by the seat before edit: amendment 6487003956 (ui/packages/types/src/** for the one slot and its ledgers) and 6487558539 (test-only explain doubles in consumer suites); plugin-list left the surface",
        "deleted the ignored, untracked ui/apps/site leftover (node_modules + .source) in this worktree after merging main; no tracked file",
        "PR body Human Minimum item 3 left unchecked: repo-wide lint is CI's per os-dev",
        "commit trailers stay model-free with no card reference; the amendment is cited in the app-shell commit's body prose"
      ],
      "body_amendments": [],
      "open_questions": [],
      "out_of_scope_findings": []
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchdomain:uipriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions