This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Repository navigation
Commit 5f75cfa
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)
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 #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](https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent a21ff9a commit 5f75cfa
15 files changed
Lines changed: 721 additions & 15 deletions
File tree
- .changeset
- packages
- core
- src/utils
- permissions
- src
- __tests__
- plugin-detail/src
- __tests__
- plugin-form/src
- plugin-grid/src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
191 | 197 | | |
192 | 198 | | |
193 | 199 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
50 | 60 | | |
51 | 61 | | |
52 | 62 | | |
| |||
120 | 130 | | |
121 | 131 | | |
122 | 132 | | |
123 | | - | |
124 | | - | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
125 | 139 | | |
126 | 140 | | |
127 | 141 | | |
| |||
133 | 147 | | |
134 | 148 | | |
135 | 149 | | |
136 | | - | |
137 | | - | |
| 150 | + | |
| 151 | + | |
138 | 152 | | |
139 | 153 | | |
140 | 154 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
148 | 148 | | |
149 | 149 | | |
150 | 150 | | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
151 | 159 | | |
152 | 160 | | |
153 | 161 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
| 40 | + | |
| 41 | + | |
41 | 42 | | |
42 | 43 | | |
43 | 44 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| 22 | + | |
| 23 | + | |
22 | 24 | | |
23 | 25 | | |
24 | 26 | | |
| |||
42 | 44 | | |
43 | 45 | | |
44 | 46 | | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
45 | 54 | | |
46 | 55 | | |
47 | 56 | | |
| |||
307 | 316 | | |
308 | 317 | | |
309 | 318 | | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
310 | 336 | | |
311 | 337 | | |
312 | 338 | | |
| |||
318 | 344 | | |
319 | 345 | | |
320 | 346 | | |
321 | | - | |
322 | 347 | | |
323 | 348 | | |
324 | 349 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
24 | 28 | | |
25 | 29 | | |
26 | 30 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| 30 | + | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | 34 | | |
| |||
167 | 169 | | |
168 | 170 | | |
169 | 171 | | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
170 | 178 | | |
171 | 179 | | |
172 | 180 | | |
| |||
378 | 386 | | |
379 | 387 | | |
380 | 388 | | |
381 | | - | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
382 | 399 | | |
383 | 400 | | |
384 | 401 | | |
| |||
0 commit comments