Skip to content

Commit 9598405

Browse files
fix(fields): a user field's person object is always sys_user (user cell and picker) (#26050)
Fixes #25937 Clause-②: no Metadata-protocol: none ## What A `user` field's person object is now always `sys_user` in the console, matching the spec contract from #24952 (PR #25074): `FieldSchema` refuses a `user` field whose `reference` names any object but `sys_user`, and `referenceTargetOf` resolves every `user` field to `sys_user` whatever the field carries. - `ui/packages/fields/src/index.tsx`, `UserCellRenderer`: the read gate's object was `field.reference || 'sys_user'`. It is now the constant `'sys_user'`, and the renderer no longer reads `field` at all. The JSDoc is rewritten: it said the person's object is "the field's `reference`". - `ui/packages/fields/src/widgets/UserField.tsx`: the picker's target was `meta?.reference || 'sys_user'`. It is now `'sys_user'`, with the comment rewritten to match. - `ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx`: the `crm_member` case flips. A `{ type: 'user', reference: 'crm_member' }` field is now gated by the `sys_user` policy, and its drawing is byte-equal to a plain `{ type: 'user' }` field under the same policy. A `crm_member` policy no longer reaches it. The header bullet and the test title no longer say the person's object is the field's `reference`. The retired `reference_to` case is unchanged and still passes. - `scripts/sdui-manifest.record.json`: re-recorded with `node scripts/gen-sdui-manifest-node.mjs`, because `ui/packages/fields/src` is a registry source the record digests. - `.changeset/25937-user-cell-sys-user.md`: `@object-ui/fields` patch. The route is the type's constant, not a call to `referenceTargetOf`. That helper reads the field's reference carrier first and throws when the carrier is unreadable (`referenceCarrierOf`), so it does not belong in a render path. objectui also does not import the spec's field helpers. ## Behaviour change to note: which policy gates a stored foreign `reference` The renderer read gate (`withoutDeniedFields`) is keyed by object name. A stored object saved before the spec refusal can still carry a `user` field with a foreign `reference`, for example `crm_member`. The cell used to gate that field by the `crm_member` policy. It now gates it by the **`sys_user` policy**. That is the object the runtime expands the field into (`referenceTargetOf`), so the cell now hides exactly what the served row's own object denies. The picker also searches `sys_user` for such a field now, not the foreign object. This is the card's intent: a link to another object is a `lookup`. ## Verification All readings were taken at head `11a85a02`, after `main` was merged in at `c8cb510a`, on a shared box. - `cd ui && pnpm exec vitest run --maxWorkers=2 packages/fields/` under the verify lock: **Test Files 277 passed | 1 skipped (278), Tests 4290 passed | 7 skipped (4297)**, VERDICT command-exit 0. - `pnpm --filter @object-ui/fields type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the test config compiles `__tests__`): VERDICT command-exit 0. - `eslint` over the three edited ui files: 0 errors. The 152 warnings were already there. - Ablation, one leg, with the fix committed first. `scripts/ablation-replace.mjs` put the old read back in place of `const personObject = 'sys_user';`, that is `field.reference || 'sys_user'`. The tool reported the anchor going 1 to 0 and the blob going `2ac4abd3f41c` to `644362b7ff75`. Result: `userCell.readGate-10535.test.tsx` **1 failed | 6 passed**. The flipped case failed at `:164`: `expected … not to contain 'Amy Lin'`, because the `crm_member` field was no longer gated by the `sys_user` policy. On restore, the blob was back to the HEAD blob `2ac4abd3f41c` and `git diff HEAD` was empty. The green leg at the same head: 7 of 7 passed. - The test imports `../index` (source), so the ablation needs no `dist/` rebuild. - `node scripts/gen-sdui-manifest-node.mjs` (under the lock, exit 0) re-recorded only `inTree.registrySourcesSha256`. `sdui.manifest.json` is byte-identical: sha256 `1f6e1ccb925a…`, 106 components. `node scripts/check-sdui-manifest.mjs` passes. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` gave 46 commands, and all 46 exited 0. `pnpm check:type-check-debt` hit a 300 s local timeout on its first run and passed on the rerun, with no surplus. `--ran` reconciliation: 46 derived, 46 run, 0 NOT-MEASURED, 0 UNRUN. - `node scripts/check-changeset-fixed.mjs`: in sync. Its roster sits under `.changeset/`, which this PR touches. - Full dogfood: not run. The title has no `!` and `Clause-②` has no `(narrowing)` arm. ## Acceptance notes - `LookupCellRenderer`'s comment in `ui/packages/fields/src/index.tsx` still lists `UserField` among the readers that read `reference`. That is now stale, because `UserField` fixes the target. The comment sits outside this card's declared file region (`UserCellRenderer` only), so this PR leaves it alone. Carrier: none. - `RecordMetaFooter`'s `UserRef` (`ui/packages/plugin-detail`) still builds `reference: fieldDef?.reference || 'sys_user'` for audit fields. When the field is typed `user`, the cell renderer now ignores that `reference`, so nothing visible changes. Observation only. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b239a01 commit 9598405

7 files changed

Lines changed: 52 additions & 26 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
fix(fields): a `user` field's person object is always `sys_user` — the user cell and the person picker no longer read an authored `reference`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A `user` field's target is a constant of its type: `@objectstack/spec` refuses a `user` field whose `reference` names any object but `sys_user`, and `referenceTargetOf` resolves every `user` field to `sys_user` whatever the field carries. The console now agrees with it in two places:
11+
12+
- **`UserCellRenderer`** (read-only cell): the field-level read gate on the person's name and avatar is keyed by `sys_user`. A stored object saved before the spec refused a foreign `reference` (for example `reference: 'crm_member'`) used to gate the cell by that object's policy; it is now gated by the `sys_user` policy, the object the runtime expands the field into.
13+
- **`UserField`** (person picker): the picker targets `sys_user` even when the field carries a foreign `reference`, so it offers users, matching what the runtime stores and resolves.
14+
15+
What to do: nothing for a spec-valid `user` field (no `reference`, or `reference: 'sys_user'`). A field that links to another object is a `lookup`.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/fields","sources":96,"sha256":"a80484e00e8bc69907a6efbec0af09bd7eb0d1a78b057a4f5092b90432d921cc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/fields","sources":96,"sha256":"14f77ca68180dba0ba0226c934cc41bbb0c4d317a1472fb3512d75cf3b4ab7ac","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"d34e7acc9010e7fc875131b416c4a83a2bac115c893c2067d3ccb85fcaf8bcd0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
*
2626
* - single and multi: a denied name and a denied image are not drawn; the
2727
* cell reads exactly as a stripping backend's row makes it read;
28-
* - the person's object is the field's `reference`, and `sys_user` when it
29-
* names none — the answer `UserField` gives the picker; a retired
30-
* `reference_to` names nothing (objectui#11070 round 4);
28+
* - the person's object is always `sys_user`, a constant of the `user` type
29+
* (objectstack#24952) — the answer `UserField` gives the picker and the
30+
* target the runtime's `referenceTargetOf` resolves: an authored foreign
31+
* `reference` (a stored body saved before the spec refused it) is not read,
32+
* and neither is a retired `reference_to` (objectui#11070 round 4);
3133
* - a policy that changes after mount relabels the SAME mounted cell;
3234
* - lit controls: fields the policy does NOT deny still print, and with no
3335
* policy loaded (no provider: `isLoaded` is false) the row is drawn as
@@ -151,17 +153,21 @@ describe('UserCellRenderer — the name and the avatar are drawn from the fields
151153
expect(gated.html).toBe(served.html);
152154
});
153155

154-
it("the person's object is the field's `reference`, and `sys_user` when it names none", () => {
155-
// `reference` is the spec's spelling and, since objectui#11070 round 4, the
156-
// only one read. `UserFieldMetadata` does not declare it, hence the cast.
156+
it("a foreign `reference` is not read — the person's object is `sys_user`, the `user` type's constant (objectstack#24952)", () => {
157+
// The spec refuses this field, and `referenceTargetOf` resolves it to
158+
// `sys_user` anyway, so a stored body saved before the refusal still
159+
// reaches the cell. `UserFieldMetadata` does not declare `reference`,
160+
// hence the cast.
157161
const field = { type: 'user', reference: 'crm_member' } as unknown as FieldMetadata;
158-
const gated = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') });
162+
// The cell gates by the `sys_user` policy, the object the runtime resolves…
163+
const gated = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') });
159164
for (const secret of SECRETS) expect(gated.html).not.toContain(secret);
165+
expect(gated.html).toBe(drawn({ value: AMY, policy: policyDenying('sys_user', 'name', 'image') }).html);
160166

161-
// A policy on `sys_user` says nothing about a field that points elsewhere.
162-
const elsewhere = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') });
163-
expect(elsewhere.text).toContain('Amy Lin');
164-
expect(elsewhere.imgs).toEqual(['http://x/amy.png']);
167+
// …and a policy on the authored object does not reach it.
168+
const unreached = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') });
169+
expect(unreached.text).toContain('Amy Lin');
170+
expect(unreached.imgs).toEqual(['http://x/amy.png']);
165171
});
166172

167173
it('a retired `reference_to` names no person object — the cell reads `sys_user` (objectui#11070 round 4)', () => {

‎ui/packages/fields/src/index.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3142,17 +3142,19 @@ export function UnresolvedReferenceMark({
31423142
* the viewer may read it (objectui#10535): with a loaded policy, the fields it
31433143
* denies on the person's object are removed first ({@link withoutDeniedFields},
31443144
* `id` kept), and everything below reads that row — exactly as it reads the
3145-
* row a stripping backend serves. The person's object is the field's
3146-
* `reference`, and `sys_user` when it names none: the object `UserField`
3147-
* points the picker at.
3145+
* row a stripping backend serves. The person's object is always `sys_user`,
3146+
* a constant of the `user` type (objectstack#24952): the spec refuses a `user`
3147+
* field whose `reference` names any other object, and `referenceTargetOf`
3148+
* resolves every `user` field to `sys_user` whatever its carrier says — so a
3149+
* stored body that still carries a foreign `reference` is gated by the
3150+
* `sys_user` policy, the object the runtime expands it into and the object
3151+
* `UserField` points the picker at. The field's `reference` is not read.
31483152
*/
3149-
export function UserCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
3153+
export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
31503154
// Called before any early return (rules of hooks). A policy that loads or
31513155
// changes re-renders this cell through the context, and the drawing follows.
31523156
const perms = usePermissions();
3153-
const personObject =
3154-
(field as { reference?: string } | undefined)?.reference ||
3155-
'sys_user';
3157+
const personObject = 'sys_user';
31563158

31573159
// THE FLOOR by name (objectui#8496) plus ONE extension: every falsy scalar.
31583160
// `!value` alone never saw `[]` — a truthy empty array reached the

‎ui/packages/fields/src/widgets/UserField.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@ export function UserField(props: FieldWidgetComponentProps<any>) {
4040
&& ('reference' in raw.field || 'type' in raw.field);
4141
const meta = metaIsNested ? raw.field : raw;
4242

43-
// Ensure the picker always targets sys_user (even if the author omitted an
44-
// explicit reference), presents user names by default, and defaults to the
45-
// search-first PeoplePicker with sensible person display + candidate hygiene.
46-
// `reference` is the only target spelling read (objectui#11070 round 4).
43+
// The picker always targets sys_user, presents user names by default, and
44+
// defaults to the search-first PeoplePicker with sensible person display +
45+
// candidate hygiene. The target is a constant of the `user` type
46+
// (objectstack#24952): the spec refuses a foreign `reference` on a `user`
47+
// field and `referenceTargetOf` resolves every `user` field to `sys_user`, so
48+
// an authored `reference` is not read — a stored body that still carries a
49+
// foreign one picks users, as the runtime resolves it.
4750
const normalized = {
4851
...(meta || {}),
49-
reference: meta?.reference || 'sys_user',
52+
reference: 'sys_user',
5053
displayField: meta?.displayField || meta?.reference_field || 'name',
5154
picker: meta?.picker ?? 'search',
5255
subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'],

‎ui/packages/plugin-grid/src/relationalMetaKeys.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ export interface RelationalMetaEntry {
330330
*/
331331
export const RELATIONAL_META_READ_SET: Readonly<Record<string, RelationalMetaEntry>> = {
332332
// ── The relational target ────────────────────────────────────────────────
333-
reference: { verdict: 'spec', readers: ALL_THREE, note: "FieldSchema.reference — the served spelling for a lookup's target object, and since objectui#11070 round 4 the only one any consumer reads." },
333+
reference: { verdict: 'spec', readers: CELL_AND_LOOKUP_EDITOR, note: "FieldSchema.reference — the served spelling for a lookup's target object, and since objectui#11070 round 4 the only one any consumer reads." },
334334
// ⭐ objectui#11070 round 4 removed the `reference_to` row. It is not a
335335
// verdict change: the key left this table because the three consumers
336336
// stopped reading it and the choke point stopped stamping it, and a row here

0 commit comments

Comments
 (0)