From d9b0e1e578bbb1e7a8d0c431539bc4efcf3dca5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:42:38 +0000 Subject: [PATCH 1/6] fix(fields): a user field's person object is always sys_user The user cell gated its read by the field's authored `reference` and the person picker targeted it; the spec now refuses a foreign `reference` on a `user` field and referenceTargetOf resolves every user field to sys_user, so both read the type's constant instead. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .changeset/25937-user-cell-sys-user.md | 15 ++++++++++ .../userCell.readGate-10535.test.tsx | 28 +++++++++++-------- ui/packages/fields/src/index.tsx | 16 ++++++----- ui/packages/fields/src/widgets/UserField.tsx | 13 +++++---- 4 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 .changeset/25937-user-cell-sys-user.md diff --git a/.changeset/25937-user-cell-sys-user.md b/.changeset/25937-user-cell-sys-user.md new file mode 100644 index 00000000000..61df9da6f6b --- /dev/null +++ b/.changeset/25937-user-cell-sys-user.md @@ -0,0 +1,15 @@ +--- +'@object-ui/fields': patch +--- + +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` + +Clause-②: no +Metadata-protocol: none + +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: + +- **`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. +- **`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. + +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`. diff --git a/ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx b/ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx index 587621ffc9b..7b9a213a738 100644 --- a/ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx +++ b/ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx @@ -25,9 +25,11 @@ * * - single and multi: a denied name and a denied image are not drawn; the * cell reads exactly as a stripping backend's row makes it read; - * - the person's object is the field's `reference`, and `sys_user` when it - * names none — the answer `UserField` gives the picker; a retired - * `reference_to` names nothing (objectui#11070 round 4); + * - the person's object is always `sys_user`, a constant of the `user` type + * (objectstack#24952) — the answer `UserField` gives the picker and the + * target the runtime's `referenceTargetOf` resolves: an authored foreign + * `reference` (a stored body saved before the spec refused it) is not read, + * and neither is a retired `reference_to` (objectui#11070 round 4); * - a policy that changes after mount relabels the SAME mounted cell; * - lit controls: fields the policy does NOT deny still print, and with no * 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 expect(gated.html).toBe(served.html); }); - it("the person's object is the field's `reference`, and `sys_user` when it names none", () => { - // `reference` is the spec's spelling and, since objectui#11070 round 4, the - // only one read. `UserFieldMetadata` does not declare it, hence the cast. + it("a foreign `reference` is not read — the person's object is `sys_user`, the `user` type's constant (objectstack#24952)", () => { + // The spec refuses this field, and `referenceTargetOf` resolves it to + // `sys_user` anyway, so a stored body saved before the refusal still + // reaches the cell. `UserFieldMetadata` does not declare `reference`, + // hence the cast. const field = { type: 'user', reference: 'crm_member' } as unknown as FieldMetadata; - const gated = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') }); + // The cell gates by the `sys_user` policy, the object the runtime resolves… + const gated = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') }); for (const secret of SECRETS) expect(gated.html).not.toContain(secret); + expect(gated.html).toBe(drawn({ value: AMY, policy: policyDenying('sys_user', 'name', 'image') }).html); - // A policy on `sys_user` says nothing about a field that points elsewhere. - const elsewhere = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') }); - expect(elsewhere.text).toContain('Amy Lin'); - expect(elsewhere.imgs).toEqual(['http://x/amy.png']); + // …and a policy on the authored object does not reach it. + const unreached = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') }); + expect(unreached.text).toContain('Amy Lin'); + expect(unreached.imgs).toEqual(['http://x/amy.png']); }); it('a retired `reference_to` names no person object — the cell reads `sys_user` (objectui#11070 round 4)', () => { diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 71bd5286f8b..2ac4abd3f41 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -3142,17 +3142,19 @@ export function UnresolvedReferenceMark({ * the viewer may read it (objectui#10535): with a loaded policy, the fields it * denies on the person's object are removed first ({@link withoutDeniedFields}, * `id` kept), and everything below reads that row — exactly as it reads the - * row a stripping backend serves. The person's object is the field's - * `reference`, and `sys_user` when it names none: the object `UserField` - * points the picker at. + * row a stripping backend serves. The person's object is always `sys_user`, + * a constant of the `user` type (objectstack#24952): the spec refuses a `user` + * field whose `reference` names any other object, and `referenceTargetOf` + * resolves every `user` field to `sys_user` whatever its carrier says — so a + * stored body that still carries a foreign `reference` is gated by the + * `sys_user` policy, the object the runtime expands it into and the object + * `UserField` points the picker at. The field's `reference` is not read. */ -export function UserCellRenderer({ value, field }: CellRendererProps): React.ReactElement { +export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement { // Called before any early return (rules of hooks). A policy that loads or // changes re-renders this cell through the context, and the drawing follows. const perms = usePermissions(); - const personObject = - (field as { reference?: string } | undefined)?.reference || - 'sys_user'; + const personObject = 'sys_user'; // THE FLOOR by name (objectui#8496) plus ONE extension: every falsy scalar. // `!value` alone never saw `[]` — a truthy empty array reached the diff --git a/ui/packages/fields/src/widgets/UserField.tsx b/ui/packages/fields/src/widgets/UserField.tsx index 53676322000..0ed065847e8 100644 --- a/ui/packages/fields/src/widgets/UserField.tsx +++ b/ui/packages/fields/src/widgets/UserField.tsx @@ -40,13 +40,16 @@ export function UserField(props: FieldWidgetComponentProps) { && ('reference' in raw.field || 'type' in raw.field); const meta = metaIsNested ? raw.field : raw; - // Ensure the picker always targets sys_user (even if the author omitted an - // explicit reference), presents user names by default, and defaults to the - // search-first PeoplePicker with sensible person display + candidate hygiene. - // `reference` is the only target spelling read (objectui#11070 round 4). + // The picker always targets sys_user, presents user names by default, and + // defaults to the search-first PeoplePicker with sensible person display + + // candidate hygiene. The target is a constant of the `user` type + // (objectstack#24952): the spec refuses a foreign `reference` on a `user` + // field and `referenceTargetOf` resolves every `user` field to `sys_user`, so + // an authored `reference` is not read — a stored body that still carries a + // foreign one picks users, as the runtime resolves it. const normalized = { ...(meta || {}), - reference: meta?.reference || 'sys_user', + reference: 'sys_user', displayField: meta?.displayField || meta?.reference_field || 'name', picker: meta?.picker ?? 'search', subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'], From 11a85a029bbc2f6db295afd5e6f0a65e28aa67d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:07:06 +0000 Subject: [PATCH 2/6] chore(sdui): re-record the manifest provenance for the fields source edit Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 5d728022f56..cd72e00f09f 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "7734fe6bc3763fe8318db624b9e0dd4c48b2dc7b15bf2a3fb1f78e46bb8d959e" + "registrySourcesSha256": "1e18ac67246c867ef922cca4415e2df0500fb3946592fbedb0857a5774770b50" } } From 11d090149e44b1ba5042feacba418250342eae72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:22:09 +0000 Subject: [PATCH 3/6] chore(sdui): re-record the manifest provenance after merging main Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..692440b0e55 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "491eba1b7151f58c70818b4544d0488af15876eda8cf9e4bc9e11bdf4da0798f" } } From 82439007ade23bd0049ef0dc76201aa643431dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:36:20 +0000 Subject: [PATCH 4/6] fix(plugin-grid): UserField no longer reads reference, so the read set drops user-editor Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ui/packages/plugin-grid/src/relationalMetaKeys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/plugin-grid/src/relationalMetaKeys.ts b/ui/packages/plugin-grid/src/relationalMetaKeys.ts index f9a6f4dde24..9a1e606dfdb 100644 --- a/ui/packages/plugin-grid/src/relationalMetaKeys.ts +++ b/ui/packages/plugin-grid/src/relationalMetaKeys.ts @@ -330,7 +330,7 @@ export interface RelationalMetaEntry { */ export const RELATIONAL_META_READ_SET: Readonly> = { // ── The relational target ──────────────────────────────────────────────── - 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." }, + 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." }, // ⭐ objectui#11070 round 4 removed the `reference_to` row. It is not a // verdict change: the key left this table because the three consumers // stopped reading it and the choke point stopped stamping it, and a row here From b7b9d521ad17d61729a383a4fd15c577cade23ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:37:57 +0000 Subject: [PATCH 5/6] chore(sdui): re-record the manifest provenance for the plugin-grid read-set edit Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 692440b0e55..dd8e5d301c3 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "491eba1b7151f58c70818b4544d0488af15876eda8cf9e4bc9e11bdf4da0798f" + "registrySourcesSha256": "7d06912a5b3b2ce291a99afbd5e6bc86f0ff90f8cd731678f1dff7f89c3a7a1a" } } From 02986dd2d6b8b2b2bbc5d90abbcb8b7a50a85ad7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 16:30:46 +0000 Subject: [PATCH 6/6] chore(sdui): re-record the fields and plugin-grid source digests after merging main Since the per-package sdui record split, the provenance for a registry source edit lives in scripts/sdui-manifest.sources/.json; the global record took main's side in the merge and the generator re-recorded the two packages this branch edits. The artefact sdui.manifest.json is byte-unchanged. Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- scripts/sdui-manifest.sources/fields.json | 2 +- scripts/sdui-manifest.sources/plugin-grid.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/sdui-manifest.sources/fields.json b/scripts/sdui-manifest.sources/fields.json index db4b65ba697..112d644e064 100644 --- a/scripts/sdui-manifest.sources/fields.json +++ b/scripts/sdui-manifest.sources/fields.json @@ -1 +1 @@ -{"package":"ui/packages/fields","sources":96,"sha256":"a80484e00e8bc69907a6efbec0af09bd7eb0d1a78b057a4f5092b90432d921cc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/fields","sources":96,"sha256":"14f77ca68180dba0ba0226c934cc41bbb0c4d317a1472fb3512d75cf3b4ab7ac","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/scripts/sdui-manifest.sources/plugin-grid.json b/scripts/sdui-manifest.sources/plugin-grid.json index ca52aaa8efa..086cb5905d6 100644 --- a/scripts/sdui-manifest.sources/plugin-grid.json +++ b/scripts/sdui-manifest.sources/plugin-grid.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-grid","sources":38,"sha256":"d34e7acc9010e7fc875131b416c4a83a2bac115c893c2067d3ccb85fcaf8bcd0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}