Skip to content

fix(console): in-place edit and list inline edit honour the caller's field write grant, and the owner field the transfer grant (objectui#12103) - #12117

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-12103-field-write-grants
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-12103-field-write-grants

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12103

Clause-②: yes

Refs: #12082 (the affordance-to-grant family). #12082 is not addressed here: its object-level census backlog is untouched, including the record overlay's OVERLAY_INLINE_EDIT entries.

Measured first: the server's per-caller answer agrees with enforcement

The card's stop rule did not fire. Measured on a live backend before any code: objectstack main at bf515e72, examples/app-showcase, objectstack dev --seed-admin --fresh on a private port, run from a separate read-only objectstack worktree. The test user holds the contributor position, so their sets are showcase_contributor, showcase_member_default and member_default. Their field entry is { readable: true, editable: false }, and the object carries no transfer grant.

Probe as that user Raw answer
GET /api/v1/auth/me/permissions objects.showcase_project allowEdit: true, allowTransfer: false, modifyAllRecords: false
same answer, fields["showcase_project.budget"] {"readable": true, "editable": false}
PATCH /api/v1/data/showcase_project/ID with {"budget":123} 403 [Security] Field write denied: not permitted to edit [budget] on 'showcase_project'
PATCH with {"owner_id": "REP_ID"} 403 'owner_id' on 'showcase_project' is system-managed — changing record ownership on update requires the transfer grant (allowTransfer or modifyAllRecords)
CONTROL PATCH with {"name": "…"} 200
CONTROL, admin: PATCH with {"owner_id": "REP_ID"} 200 (admin's entry: allowTransfer: true)

So /me/permissions reports editable: false and allowTransfer: false, and enforcement refuses both writes. The client can follow an answer the server already gives, and no half of this belongs to objectstack.

What changed

The family's direction is one map from each affordance to the grant it reads. This PR therefore adds no per-component permission logic. Each surface asks its own row's field question through resolveFieldAffordance.

  • @object-ui/core, AFFORDANCE_GRANTS. recordEdit and listInlineEdit gain field: 'write'. That is the same update question editFormFields asks, so the record page's in-place edit and a list's in-cell edit ask each field exactly what an edit form asks through fieldWriteGate. The test under a … grant each answers every field exactly as an edit form's gate does pins that it is the same predicate.
  • @object-ui/plugin-detail, DetailSection (the details body) and HeaderHighlight (the highlights strip). Each field's fieldEditable now ANDs in resolveFieldAffordance('recordEdit', perms, objectName, field). A refused field shows no pencil, does not enter the session on double-click, and stays a read display in edit mode. The object-level gate (onEnterInlineEdit / inline.canEdit) is unchanged. With no objectName, or no provider mounted, nothing is asked.
  • @object-ui/plugin-grid, ObjectGrid. The column enrichment pass that already marks read-only, computed or binary columns editable: false also marks a column whose field the caller may not write, using resolveFieldAffordance('listInlineEdit', …). The cell keeps its read display while the rest of the row edits. inlineEditable, the object-level listInlineEdit verdict, still decides whether the grid edits at all. So the 5330afd default (an absent userActions.editInline reads on, decided in ListView's inlineEditOffered) composes unchanged: it decides whether the toggle is offered, and this narrows single columns inside an editing grid.
  • @object-ui/permissions, the owner field. The server's guard keys the owner field by name: owner_id, which the spec spells as the OWNER_ID key of SystemFieldName. No field type and no object-level owner declaration is read there; ownership only decides injection. So the field is identified by the spec's constant, and the console carries no literal. MePermissionsProvider.checkField(object, owner, 'write') now also requires the transfer grant, read with the spec's own predicate objectPermissionGrants(objPerm, 'allowTransfer'), which is allowTransfer or modifyAllRecords. The write question is documented as "the server's update rule", and the owner's update rule includes the transfer grant. Fixing it in the resolver therefore covers every write asker (edit forms, in-place edit, in-cell edit), and no affordance row has to remember it. create is not narrowed, because on insert the server stamps an empty owner to the caller and accepts the caller's own id. MePermissionsResponse declares the allowTransfer? bit the endpoint already serves. @object-ui/permissions gains a direct @objectstack/spec ^17.7.0 dependency for @objectstack/spec/security and @objectstack/spec/system, which adds +3 lockfile lines.
  • Docs: the @object-ui/core and @object-ui/permissions READMEs. Changeset: .changeset/12103-field-write-grants.md (core and permissions minor, plugin-detail and plugin-grid patch).
  • Census (affordanceGrantMap-12082.test.tsx). EXPECTED_FIELD_GRANT gains recordEdit and listInlineEdit. Two new blocks cover the edit-form-equivalence pins and the owner/transfer matrix: absent and false refuse; allowTransfer and modifyAllRecords open; CONTROL: the grant moves no other field; an explicit editable: false on the owner still refuses; the insert question is not narrowed; fail-open with no provider. The InlineEditSaveBar … updateVia :: update write-site entry now also names DetailSection.tsx and HeaderHighlight.tsx as readers of recordEdit. No write call site was added or moved. The three Setup entries from feat(app-shell,fields,console): Setup's positions and permission sets read the registry, through the metadata-admin pages' environment scope (part of objectui#7611) #12089 are untouched, and none of that PR's files is touched.

File surface: three additions beyond the claim, with the reason

  • packages/plugin-detail/src/HeaderHighlight.tsx: the highlights strip is the same in-place edit session as the details body, with one draft and one Save. Leaving it ungated would leave the card's own defect reachable on the same page. All four bounded-fix conditions hold: the same defect class, the same one-call shape as DetailSection, no other open PR touches the file (read on the 4 open PRs), and the same gate family.
  • packages/permissions/package.json and pnpm-lock.yaml: the spec imports above.
  • packages/core/README.md and packages/permissions/README.md: AGENTS.md commandment 2 (docs-driven).

Clause-② inventory (for the seat's contract review)

  • AFFORDANCE_GRANTS.recordEdit and AFFORDANCE_GRANTS.listInlineEdit gain field: 'write'. No row is added or removed.
  • FieldAffordance (derived type) widens by 'recordEdit' | 'listInlineEdit'.
  • MePermissionsResponse['objects'][string] gains allowTransfer?: boolean.
  • checkField's action union is unchanged ('read' | 'write' | 'create'). Its write answer narrows for the owner field only.
  • @object-ui/permissions gains a runtime dependency on @objectstack/spec.

Evidence (head 47a8eee)

  • Type-check, every output echoing type-check, exit 0: @object-ui/core, @object-ui/permissions, @object-ui/plugin-detail, @object-ui/plugin-grid and @object-ui/plugin-form. Each package's tsconfig.test.json --listFiles includes its new test file.
  • New pins, all through the real MePermissionsProvider and the measured envelope: affordanceGrantMap-12082.test.tsx, MePermissionsProvider.ownerTransfer-12103.test.tsx, DetailSection.fieldWriteGrant-12103.test.tsx (details body and highlights strip) and inlineEditFieldWriteGrant-12103.test.tsx (grid). Result: Test Files 4 passed (4) / Tests 66 passed (66).
  • Union, narrowed (repo root, no --): 35 files and 381 tests passed (grid inline-edit, permission, FLS and column suites, plugin-form map and field-gate pins, the whole packages/permissions/), then 31 files and 240 tests passed (ListView permission, inline and FLS suites, app-shell ObjectView.objectBoundActions-7234 and RecordDetailView.expandFls-7230, every DetailSection.*, HeaderHighlight.* and RecordHighlightsRenderer.*, DetailView.permissions, InlineEditSaveBar). The whole of packages/permissions/ plus packages/plugin-detail/ also ran at bccbfc5: Test Files 262 passed | 1 skipped (263). The only later changes are a comment rewording and the test-file typing fix.
  • NOT MEASURED: the whole packages/plugin-grid/ suite. Reason: one run exceeded the foreground cap (killed at 590 s, no result). It is declared to CI. The grid files above are the narrowed set.
  • Ablations, each committed first and run through ablation-replace.mjs. Every leg reported ok mutation landed and ok restored: blob == HEAD … git diff HEAD is empty. Vitest aliases these packages to src, so no rebuild was needed.
    • DetailSection gate replaced by true: 3 red (refused field, owner, edit mode); the strip pins and the controls stayed green.
    • HeaderHighlight gate replaced by true: 3 red (refused highlight, owner, edit-mode display); the CONTROL stayed green.
    • ObjectGrid field question removed: 2 red (refused field, owner); the controls stayed green.
    • Provider owner rule disabled: 6 red across all 4 files.
  • Gates, exit 0: check:metadata-write-doors, check:handler-key-reads, check:new-line-citations (VERDICT … 0 new citation(s)), check:control-bytes, check:changeset-claims, check:pending-changeset-literals, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:esm-specifiers, check:self-import, check:phantom-deps, check:unused-deps, check:spec-symbols, check:installed-pin-claims, check:lockfile-integrity, check:lockfile-dedupe, check:pre-install-import-graph, check:side-effects-array, check:unreferenced-sources, check:doc-fences, check:doc-types, check-changeset-presence.mjs and check-changeset-no-major.mjs.
  • NOT MEASURED (prerequisite: a whole-tree build): check:readme-exports (its own line: "the population COLLAPSED -- this run proves nothing"), check:doc-snippets ([unbuilt-package]) and check:spec-floors. In check:spec-floors, all 14 findings are [no-artifact] for unbuilt packages. The built @object-ui/core and @object-ui/permissions raised no finding, and the gate read the 17.7.0 ./security and ./system entries. The README edits add no fenced block.
  • Eager closure (console vite build at base 5330afd and at 2ff9d43; the last commit changes a test file only): base 3,246,298 bytes, head 3,246,484 bytes gzipped, +186 bytes. The chunk count stays at 290, and vendor-objectstack stays at 1561.8 KB. The spec modules were already in that chunk. Gate: Console eager closure is 3170.4 KB gzipped … headroom: 34.2 KB, exit 0.
  • Lint, narrowed to the 10 touched .ts/.tsx files. ① The population comes from eslint's own resolution: --print-config resolves a config for every file, and none is ignored. ② --format json reports 10 files. ③ Invariance: eslint.config.js enables no type-aware linting (no projectService and no parserOptions.project), so the diff cannot move a verdict on an untouched file. With inline config (how the package lint scripts run): 0 errors. With --no-inline-config: 1 error, react-hooks/static-components in ObjectGrid.tsx, which is identical at base (the same rule, with an inline disable). Warning counts per file are identical to base.

Acceptance notes

  • check:spec-symbols refused a citation the spec does declare. A dotted citation of OWNER_ID on SystemFieldName next to a spec mention reads as "a key the spec does not declare". The spec exports SystemFieldName as a const and as a value-union type; the gate's memberSetOfSymbol fills authored from the declared type's members only (here the string members), so the const's keys never count. The comment was reworded instead. Carrier: none (承接者:无). This is a read of the gate's code, not a filed defect.
  • Edit forms strip owner_id from every payload by name (SERVER_OWNED_FIELD_NAMES in plugin-form's sanitize.ts). A transfer-holder whose form layout draws the owner field therefore gets an enabled input whose change Save drops. This was not changed and not measured here (a read-only inference). The in-place and in-cell paths do write it, and the server accepts it from a transfer-holder (the admin control above). Carrier: none.
  • The role-based PermissionProvider has no transfer concept, so its checkField is unchanged. The console mounts MePermissionsProvider.

Generated by Claude Code

…ld write grant, and the owner field asks the transfer grant (objectui#12103)

The record page's in-place edit (details body and highlights strip) and a
list's in-cell edit now ask each field the `write` question of their row in
the affordance-to-grant map (`recordEdit`, `listInlineEdit` gain
`field: 'write'`), the same question an edit form asks through
`fieldWriteGate`. A field the caller's permission set marks
`editable: false` stays a read display / read cell.

`MePermissionsProvider.checkField(o, owner, 'write')` now also needs the
transfer grant (`objectPermissionGrants(objPerm, 'allowTransfer')` from
`@objectstack/spec/security`), because the server refuses an update that
writes the owner field (`SystemFieldName.OWNER_ID`) without it.

Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Co-authored-by: Claude <noreply@anthropic.com>
…, and the owner field's transfer grant (objectui#12103)

- The map's enumeration pin holds `recordEdit` and `listInlineEdit` as field
  rows, pins that they answer every field as an edit form's
  `fieldWriteGate` does, and pins the owner field's transfer grant on every
  update-question row (absent / false / allowTransfer / modifyAllRecords,
  the explicit field refusal, the untouched insert question, fail-open).
- The write census names the details body and the highlights strip as
  readers of the `recordEdit` row behind the in-place edit's save.
- Surface pins through the real MePermissionsProvider and the measured
  envelope: the details body and the highlights strip, and ObjectGrid's
  in-cell edit, each with an editable-field control.
- The changeset.

Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Co-authored-by: Claude <noreply@anthropic.com>
…it-mode pin (objectui#12103)

Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Co-authored-by: Claude <noreply@anthropic.com>
…dName constant spells it (objectui#12103)

check:spec-symbols reads a dotted citation of SystemFieldName beside a spec mention as a key the spec does not declare, because the spec exports SystemFieldName as both a const and a value-union type and the gate counts only the type's members.

Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Co-authored-by: Claude <noreply@anthropic.com>
…onfig (objectui#12103)

Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 290 chunks) 3170.4 KB 3204.6 KB
Main entry chunk (gzip) 74.1 KB 350 KB
Entry file index-gHx5iK4J.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 19.75KB 7.29KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 41.19KB 11.12KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 586.66KB 141.23KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 240.39KB 67.09KB
fields (index.js) 269.55KB 68.24KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.66KB 15.64KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.52KB 47.99KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.53KB 49.80KB
plugin-detail (index.js) 249.33KB 65.74KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.59KB 46.00KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 249.52KB 69.17KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 120.09KB 30.25KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.06KB 11.80KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.07KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 47a8eee5ec230e036a83adfabf43d4b9b4ed9d14
Local-runs: none

Inputs read: card objectui#12103 (body and all three comments: the unlock scan, the Claim, the os-dev-report), the triage directions on objectui#12082 the card's thread cites (comments 6093495104 and 6102063887), PR #12117 (body, 15-file list, the diff against main at the head), the check-runs on the head, and the head tree and @objectstack/spec source through REST where a derived judgment needed a definition. Nothing built, run or re-run.

① Derived judgments

Public surface, each change named and judged:

  1. AFFORDANCE_GRANTS.recordEdit and AFFORDANCE_GRANTS.listInlineEdit (exported from the @object-ui/core entry via export * from './utils/affordanceGrants.js') gain field: 'write'. No row added, removed or renamed. The as const satisfies Record of AffordanceGrantRow shape already declared field? optional, so the row type is unchanged and each row's literal type gains one property. Widening. RIGHT: write is the question editFormFields already asks through fieldWriteGate, and the pin under a ... grant each answers every field exactly as an edit form's gate does holds the two rows to that same predicate for the measured envelope, including the owner.
  2. FieldAffordance (derived: the keys whose row extends { field: FieldAffordanceGrant }) widens by 'recordEdit' | 'listInlineEdit'. Additive on a parameter type; no consumer narrows. RIGHT. The census expectation table EXPECTED_FIELD_GRANT and the FIELD_ROWS equality pin are updated in step, so the enumeration pin still fails on a row added without its table entry.
  3. resolveAffordance (the object-level verdict) reads row.crud and row.grant only; row.field is read solely by resolveFieldAffordance. So the object-level verdicts for recordEdit and listInlineEdit are byte-for-byte unchanged: the header Edit CTA, onEnterInlineEdit, inline.canEdit and the grid's inlineEditable toggle decide as before. RIGHT, and this is what keeps the 5330afd default (an absent userActions.editInline reads on) composing unchanged, as the PR body claims.
  4. MePermissionsResponse['objects'][string] gains allowTransfer?: boolean. Optional property on an exported type the endpoint already serves. Widening. RIGHT.
  5. PermissionContextValue.checkField signature unchanged ('read' | 'write' | 'create'). Its write ANSWER for the owner field narrows in MePermissionsProvider: refused unless objectPermissionGrants(objPerm, 'allowTransfer'), which the spec defines as allowTransfer === true || modifyAllRecords === true (read at packages/spec/src/security/permission.zod.ts, introduced in spec 17.5.0 per its CHANGELOG, so present in the installed 17.7.0). Order of checks judged RIGHT: the owner check precedes the explicit field entry, so an editable: true entry cannot open a transfer the server refuses, and an editable: false entry still refuses with the grant; the wildcard '*' entry is read as the object fallback already does; an unmentioned object keeps the authentication-gated default (closed when signed in, open when anonymous), the same default the lines below it already apply. create and read untouched. This is a behaviour fix following a refusal the server already issues (the card's own 403 "requires the transfer grant"), not an accept-set narrowing: no consumer could complete the write the old true answer offered.
  6. The owner field is identified as SystemFieldName.OWNER_ID from @objectstack/spec/system ('owner_id', exported through system/index.ts via export * from './constants'). RIGHT: the spec's constant, no console literal; keyed by name as the server's guard is.
  7. @object-ui/permissions gains "@objectstack/spec": "^17.7.0" in dependencies. dependencies is not one of the eight publish-contract fields, and the spec is already in this package's install closure through @object-ui/types (^17.7.0); the range matches types and plugin-form, and the lockfile resolves one 17.7.0. RIGHT; the +3 lockfile lines are that importer entry only.
  8. DetailSection and HeaderHighlight (@object-ui/plugin-detail) call usePermissions() unconditionally at hook level and AND resolveFieldAffordance('recordEdit', perms, objectName, field.name) into fieldEditable, guarded by !objectName ||. With no provider, usePermissions() answers the frozen NO_PROVIDER_PERMISSIONS (isLoaded: false) and resolveFieldAffordance returns true without asking, so a bare embed is unchanged. @object-ui/permissions was already a declared peer of plugin-detail and already imported by DetailView. The gate REACHES the console: DetailView passes objectName={schema.objectName} to HeaderHighlight and to every DetailSection it renders. RIGHT. Incidental and acceptable: RecordDetailPanel renders DetailView with objectName, so the record overlay's body gets the same field gate; its object-level OVERLAY_INLINE_EDIT census entry stays unmapped for objectui#12082, untouched here as the PR body says.
  9. ObjectGrid (@object-ui/plugin-grid): the column enrichment pass is a plain generateColumns().map(...) in the render body, not a memo, so it re-evaluates when the provider's context value changes; perms (usePermissions()) and objectName (resolveRecordSourceObjectName(schema, dataConfig) ?? schema.objectName) are already in scope above it; the field is col.accessorKey, the same key the pass already uses for objectFields. It only forces editable: false, never opens a column. RIGHT.
  10. Census (affordanceGrantMap-12082.test.tsx): the InlineEditSaveBar ... updateVia :: update entry names DetailSection.tsx and HeaderHighlight.tsx as readers of recordEdit, and the pin every mapped site names rows of the map ... each read by one of the files it names verifies both files read that row. WriteSite's mapped arm already declares note?: string, so the as WriteSite cast hides nothing. No unmapped entry added or removed; the three Setup entries of objectui#12089 untouched. RIGHT.
  11. Governance and size: no governed surface in the 15-file list; 721 additions and 15 deletions, under the 3,000-line class. The Governed Surface Queue Guard check-run is success.

Measure-first (the card's stop rule): the PR body records the live probe on objectstack main bf515e72 (/me/permissions answers editable: false and allowTransfer: false; both PATCHes 403; the controls 200), so the server's per-caller answer agrees with enforcement and no half returns to triage. Judged consistent with the card's own HotCRM measurement.

② Semver level

Changeset .changeset/12103-field-write-grants.md: @object-ui/core minor, @object-ui/permissions minor, @object-ui/plugin-detail patch, @object-ui/plugin-grid patch. No major (the fixed-group rule; Changeset Bump Policy and Changeset Fixed Group Check are success). The fixed group therefore moves one minor, which is what items 1, 2 and 4 of ① publish. The test-only edit to plugin-form needs no entry. Changeset Declaration (check-changeset-presence) is success.

Clause-②: the PR body and the Claim both say yes; the changeset body carries Clause-②: yes (widening) with exactly one arm and no ADR-0087 marker (nothing breaking). RIGHT: the public surface is enlarged (①.1, ①.2, ①.4) and nothing an author can write is removed or renamed, so yes with at least minor is the correct declaration, and (narrowing) would be wrong: the owner-field answer (①.5) changes a runtime verdict to match a refusal the server already issues, and the changeset body discloses it in plain text. The body also states what is unchanged (no provider mounted; the server still enforces).

③ Boundary flags

Open question (the dev's one): keep the owner transfer rule in MePermissionsProvider.checkField (A) or add a map row with a transfer grant (B). ANSWER: A, as implemented. The transfer grant is the server's per-field update rule for one field, not a distinct affordance; the map's rows are affordance-to-grant, and B would need a transfer verb the spec's PermissionAction does not declare plus a can() mapping in every provider. In the resolver, every write asker (editFormFields through fieldWriteGate, recordEdit, listInlineEdit) inherits it with no row to forget, and the owner matrix pins in affordanceGrantMap-12082.test.tsx and MePermissionsProvider.ownerTransfer-12103.test.tsx hold it. The rule is written on the PermissionContextValue.checkField contract and in the map's header, so a future row asking write inherits it.

Deviations, each answered:

  • File surface beyond the Claim. HeaderHighlight.tsx: ACCEPTED, the strip shares the details body's edit session and Save, so leaving it would leave the card's defect reachable on the same page; same defect class, same one-call shape. packages/permissions/package.json and pnpm-lock.yaml: ACCEPTED per ①.7. The two READMEs: ACCEPTED (docs beside the code they describe).
  • Mechanism assumption 3 falsified (the server keys the owner by name, not type or ownership): ACCEPTED; the console reads the spec constant (①.6).
  • Suggested route not taken: answered above (A).
  • Measured on app-showcase (showcase_project.budget, contributor position) rather than HotCRM 17.7.0: ACCEPTED; the permission shape is the card's, and the stop rule was exercised.
  • Whole packages/plugin-grid/ suite NOT MEASURED locally (foreground cap): the head's Test (shard N/8) check-runs are the measurement. At this record's reading they are in_progress, as are Type Check and Spec Main Shape Gate; 31 runs success, 3 skipped by design, none failed. The seat confirms every check green separately before the queue; this record's ①②③ are judged on the diff.
  • Commit trailers in the model-free pair: fine.
  • One comment reworded for check:spec-symbols: ACCEPTED (see the first finding below).

Out-of-scope findings, each answered or escalated:

  • check:spec-symbols refuses the dotted citation SystemFieldName.OWNER_ID beside a spec mention because memberSetOfSymbol fills authored from the declared type's members only, and the const's keys never count. ESCALATED to the seat: this is a reproducible tooling false positive, not "carrier: none"; file it as a gate finding with that one-line repro. Non-blocking here.
  • Edit forms strip owner_id by name (SERVER_OWNED_FIELD_NAMES in plugin-form's sanitize.ts), so a transfer-holder whose form layout draws the owner gets an enabled input whose change Save drops. ESCALATED to the seat for a repro and a card: an input offered whose write is silently dropped is a public-door defect of the "advertised, not delivered" class. This PR narrows who sees that input and worsens nothing.
  • The role-based PermissionProvider has no transfer concept and its checkField is unchanged. ANSWERED, accepted: PermissionContextValue.checkField's new TSDoc states the owner rule with its server premise ("the server refuses an update that writes the owner"), which the role-based provider, never backed by /me/permissions, does not meet; the console mounts MePermissionsProvider. A disclosed limitation, not a card.

Implemented-by: claude/issue-12103-field-write-grants
Reviewed-by: session_01AswpQDLCKiZos2jCXknwKz

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 04:07
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 04:07
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 5f75cfa Oct 11, 2026
47 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12103-field-write-grants branch October 11, 2026 04:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants