Skip to content

Commit a491b64

Browse files
fix(fields): the position-ref grant picker stops offering the audience anchors (objectui#12200) (#12219)
Fixes #12200 Clause-②: no The `position-ref` grant picker (`@object-ui/fields`, `RegistryRefField`) stops offering the `everyone` and `guest` audience anchors. A grant cannot name one: every authenticated or anonymous principal holds an anchor implicitly (ADR-0090 D5/D9), and the server refuses a `sys_user_position` row that names one. The on-record measurement is objectstack-ai/objectstack#22967's browser check (comment 6120504736): 16 positions listed, `everyone` among them. ## What changes - `position-ref` leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`, read from `@objectstack/spec` and never copied, so an anchor the spec adds is left out with no change here. - A row that already stores an anchor still shows it, by its label beside its name, as its own option. It is not flagged "not found", because the registry does list it. When the row holds another value, no anchor is offered. Read-only shows the same label. - A registry that lists nothing but anchors gives the typed-name box, the LOADED-but-empty arm, now judged on what a grant can name. - `permission-ref` is unchanged: a permission set named like an anchor is listed and written as before. - There is no new prop, widget option, type member or i18n string. There is one `@object-ui/fields` patch changeset. ## H1: the mark, measured - `@objectstack/spec` exports `AUDIENCE_ANCHOR_POSITIONS` from both its root entry and `./identity`. The declaration is `declare const AUDIENCE_ANCHOR_POSITIONS: readonly ["everyone", "guest"];`. - `@object-ui/fields` declares `^17.6.0`; the lockfile installs **17.7.0**. The 17.7.0 `dist/identity/index.d.ts` re-exports it (`b as AUDIENCE_ANCHOR_POSITIONS`). A runtime import of `@objectstack/spec/identity` answers `["everyone","guest"]`. - The range floor, the **17.6.0** registry tarball, exports it from `dist/index.d.mts` and `dist/identity/index.d.ts` too. - The registry marks no item itself. `PositionSchema` has no anchor key, and the meta read gives `{ name, label }` headers. The spec constant IS the mark, so no name is hard-coded here and no server-side companion is owed. - On the server, the grant refusal is `plugin-security`'s `delegated-admin-gate.ts` invariant ("audience anchor is implicit — it cannot be assigned via sys_user_position"), on objectstack `main` and on objectstack-ai/objectstack#22939's head. That gate keeps its own `new Set(['everyone', 'guest'])` and does not import the constant (see Acceptance notes). The catalog-activation gate in `packages/runtime` does import it. ## H2: which import, measured three ways `RegistryRefField.tsx` IS in the console's eager closure. The console's chunk grouping puts `packages/fields` into the eager `ui-components` chunk (the gate's chunk-membership line), and the widget's own string `fields.registryRef.loadFailed` sits in `ui-components-*.js`, which `eager-closure.json` lists. `@objectstack/spec/identity` is a lazy chunk of its own (`identity-*.js`, not in the eager list). | route | eager closure, gzip bytes vs base | `ui-components` vs its ceiling | `check:eager-closure` | `check:spec-floors`, `@object-ui/fields` | |---|---|---|---|---| | base `9646e2e` | | 281.5 KB / 282.2 KB | exit 0 | not built | | static `import … from '@objectstack/spec/identity'` (counterfactual, restored) | +6953 | 288.3 KB, over by 6.1 KB | exit 1 | not run | | lazy `import('@objectstack/spec/identity')` (commit `8bd8a14`) | +174 | 281.7 KB | exit 0 | `opaque-subpath` finding, exit 1 | | static import from the root `@objectstack/spec` (this PR, `aa16a9c`) | −5 (noise) | 281.6 KB | exit 0 | judged clean (308 pairs judged, against 307 for the lazy build) | Why `check:spec-floors` decides it: `changeset:publish` runs `scripts/check-spec-range-floors.mjs` before `changeset publish`, and so does the nightly `spec-range-floors.yml`. The gate reads symbol names from static imports only. A dynamic `import()` leaves the subpath unjudged, and the gate reports that as a finding, so the lazy route would have reddened the publish lane. So the PR takes a third route that the dispatch's static-or-lazy framing did not name. It imports statically from the root entry, as `@object-ui/auth` imports `BUILTIN_MEMBERSHIP_ROLES`. It keeps the suggested shape: a `Set` built from `AUDIENCE_ANCHOR_POSITIONS`, keyed on `registryType === 'position'`, one small helper (`nonGrantableNames`) beside the widget. The anchor set is known on the first render, so no catalog arm changes shape. Commit `aa16a9c` says why the lazy commit was replaced. ## H3: every `position-ref` field - objectstack `main` `6a40a09ed`: none. `git grep -c position-ref` answers 0; the control, `widget: '…-ref'` on the same tree, finds two `object-ref` declarations. - objectstack-ai/objectstack#22939 head `7de0f00c9`: exactly one, **`sys_user_position.position`** (`plugin-security`, `sys-user-position.object.ts`), beside `permission-ref` on `sys_user_permission_set.permission_set`. The server refuses an anchor there, so no field declares `position-ref` where an anchor is a valid value. - objectui: no object field declares it; only the docs examples do. ## Tests, at `aa16a9c` - New `RegistryRefField.audienceAnchors-12200.test.tsx` has 5 pins: the anchors are left out, an anchors-only registry gives the text box, a stored `everyone` shows unflagged as its own option (H4), a read-only stored anchor shows its label, and `permission-ref` lists and writes a set named `everyone` (H5). - `pnpm exec vitest run` over the six files that render the widget, run as one command: `Test Files 6 passed (6)`, `Tests 311 passed (311)`. The files are the two `RegistryRefField` suites, `widget-aria-invalid-registry-e2e`, `widget-dom-leak-e2e`, `plugin-form`'s `objectFormRegistryNameWidget-7611` and `components`' `form-data-source-wiring`. - `pnpm --filter @object-ui/fields type-check` exits 0. `tsconfig.test.json` lists the new test file. - Reverse verification ran on the committed fix. Each mutation went through `ablation-replace.mjs`, and each restore was proven by blob == HEAD with an empty `git diff HEAD`: - the filter off: 3 failed / 12 passed; - the stored anchor's own option off: 1 failed / 14 passed; - a partial list (`[AUDIENCE_ANCHOR_POSITIONS[0]]`): 3 failed / 12 passed. - Gates, run after the last commit: `check:eager-closure` 0, `check:phantom-deps` 0, `check:vi-mock-specifiers` 0, `check:vi-mock-inherit` 0, `check:vi-mock-override-shape` 0, `check:spec-symbols` 0, `check:esm-specifiers` 0, `check-changeset-presence.mjs` 0, `check:changeset-claims` 0, `check:pending-changeset-literals` 0, `check:control-bytes` 0, `check:new-line-citations` 0. - `check:spec-floors` exits 1 locally, and every one of its 15 findings is `no-artifact` for a package this tree did not build. `@object-ui/fields` was built and has no finding. A Node ESM import of the built `@object-ui/fields` dist loads. - ESLint over the two changed sources: 2 files, 0 errors, 2 `no-explicit-any` warnings (test casts, as in the 7611 suite). Type-aware linting is not configured, so the result for untouched files cannot move. ## Acceptance notes - **Docs.** `content/docs/guide/fields.md` and `packages/fields/README.md` describe `position-ref` as listing "the `position` registry" and do not mention the exclusion. They are outside this claim's file surface; one sentence in each would cover it. - **The server keeps a second copy of the list.** `delegated-admin-gate.ts`'s `ANCHOR_POSITIONS` is a literal `['everyone', 'guest']`, not the spec constant. The values agree today. Noted, not filed. - **`check:spec-floors` reads no dynamic `import()`.** A symbol taken that way is never judged against the floor. app-shell's lazy `/identity` imports (`audienceAnchorsOf`, `clientValidation`) pass only because `FlowReferenceField.tsx` imports that subpath statically. Noted, not filed. - **Headroom.** `ui-components` has 0.7 KB of per-chunk headroom before and after this PR. The next growth of `@object-ui/fields` meets that ceiling. - **Browser.** No re-check was run after this change; it needs objectstack-ai/objectstack#22939's server. Dev run for objectui#12200, seat `domain:ui#1`, session `https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK`. --- _Generated by [Claude Code](https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c437b0 commit a491b64

5 files changed

Lines changed: 194 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitly (ADR-0090 D5/D9), and the server refuses a `sys_user_position` row that names one, so a pick of either was refused on save. The picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`, read from `@objectstack/spec`, so an anchor the spec adds is left out too. A registry that lists nothing but anchors gives the typed-name box, as an empty one does. A row that already stores an anchor still shows it, by its label beside its name, as its own option, unflagged. `permission-ref` is unchanged: a permission set named like an anchor is listed as before.

‎content/docs/guide/fields.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ record form lists that registry instead of asking for a typed name:
173173
Like every `widget` value, the name is looked up as `field:<widget>`. Each item
174174
reads as its label beside its name, and the value written is the name alone: no
175175
label, no id. A stored name the registry does not list stays on screen, flagged
176-
*not found*, so a renamed or removed item is visible rather than dropped. The
176+
*not found*, so a renamed or removed item is visible rather than dropped.
177+
`position-ref` leaves out the audience anchors, the spec's
178+
`AUDIENCE_ANCHOR_POSITIONS` (`everyone`, `guest`), because the server refuses an
179+
anchor as a grant target; a stored anchor still shows as its own option. The
177180
list comes from the data source's `listMetadataItems(type)`. When the adapter
178181
has none, the list is empty, or the load fails, the field is a text box that
179182
takes the typed name; a failure is shown with its cause, and the box stays

‎packages/fields/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,10 @@ A `text` field that stores the machine name of one registry item declares
273273
`field:<widget>`). The widget lists that registry through the data source's
274274
`listMetadataItems(type)`, shows each item's label beside its name, and writes
275275
the name only. A stored name the loaded registry lacks stays, flagged *not
276-
found*. When the adapter cannot list a registry, the list is empty, or the
276+
found*. `position-ref` leaves out the audience anchors (the spec's
277+
`AUDIENCE_ANCHOR_POSITIONS`: `everyone`, `guest`), because the server refuses
278+
an anchor as a grant target; a stored anchor still shows as its own option.
279+
When the adapter cannot list a registry, the list is empty, or the
277280
load fails, it is a text box that takes the typed name (a failure is shown with
278281
its cause). See
279282
[`content/docs/guide/fields.md`](../../content/docs/guide/fields.md).

‎packages/fields/src/widgets/RegistryRefField.tsx‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import React from 'react';
1010
import { AlertTriangle } from 'lucide-react';
1111
import { Combobox, EmptyValue, Input, cn } from '@object-ui/components';
1212
import { SchemaRendererContext } from '@object-ui/react';
13+
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec';
1314
import type { DataSource } from '@object-ui/types';
1415
import type { FieldWidgetComponentProps } from './types.js';
1516
import { toDomProps } from './toDomProps.js';
@@ -45,11 +46,20 @@ import { useFieldTranslation } from './useFieldTranslation.js';
4546
* - LOADING — the stored name in a disabled box;
4647
* - FAILED — a notice naming the failure and its cause, beside a text box that
4748
* stays enabled, so a failed catalog does not also block the edit;
48-
* - LOADED — empty: the same text box as IDLE; otherwise a searchable picker.
49-
* A stored name the catalog does not list stays selectable as its own
50-
* option, flagged "not found", so it is neither dropped nor read as a
51-
* listed item. The flag is a claim about the registry's answer, so it is
52-
* made only once the catalog has answered.
49+
* - LOADED — nothing a grant can name: the same text box as IDLE; otherwise
50+
* a searchable picker. A stored name the catalog does not list stays
51+
* selectable as its own option, flagged "not found", so it is neither
52+
* dropped nor read as a listed item. The flag is a claim about the
53+
* registry's answer, so it is made only once the catalog has answered.
54+
*
55+
* The picker OFFERS only what a grant can name (objectui#12200). A position
56+
* grant cannot name an audience anchor — `everyone` or `guest` (ADR-0090
57+
* D5/D9), held implicitly by every authenticated or anonymous principal — and
58+
* the server refuses a `sys_user_position` row that does. So `position-ref`
59+
* leaves the anchors out of its options ({@link nonGrantableNames}). A stored
60+
* anchor is still the row's value: it stays on screen as its own option, by its
61+
* label beside its name, and it is not flagged "not found", because the
62+
* registry does list it.
5363
*
5464
* ⛔ No arm writes anything but a name the person picked or typed: no label,
5565
* no id, no first-item default.
@@ -90,6 +100,30 @@ function failureMessage(err: unknown): string {
90100
return typeof err === 'string' ? err : '';
91101
}
92102

103+
const AUDIENCE_ANCHORS: ReadonlySet<string> = new Set(AUDIENCE_ANCHOR_POSITIONS);
104+
const NO_EXCLUSIONS: ReadonlySet<string> = new Set();
105+
106+
/**
107+
* The names of a registry type that a grant cannot name, which the picker
108+
* therefore does not offer. For `position`, the audience anchors: the list is
109+
* the spec's own `AUDIENCE_ANCHOR_POSITIONS`, read and never copied, so an
110+
* anchor the spec adds is left out with no change here. Every other type
111+
* excludes nothing: a permission set named like an anchor is an ordinary set.
112+
*
113+
* Imported statically from the spec's ROOT entry, as `@object-ui/auth` imports
114+
* `BUILTIN_MEMBERSHIP_ROLES`, and not from `@objectstack/spec/identity`. The
115+
* console builds this widget into an eager chunk (`ui-components`), so a
116+
* static `/identity` import would carry that subpath's whole module into every
117+
* page load; `pnpm check:eager-closure` is the instrument that weighs it. A
118+
* lazy `import()` keeps that module out of the eager closure, but out of
119+
* `pnpm check:spec-floors` too: that gate judges the `@objectstack/spec` floor
120+
* from static imports only, and on the publish lane it reports a subpath it
121+
* cannot judge as a finding.
122+
*/
123+
function nonGrantableNames(type: string): ReadonlySet<string> {
124+
return type === 'position' ? AUDIENCE_ANCHORS : NO_EXCLUSIONS;
125+
}
126+
93127
/** A settled answer, with the inputs it answers for. */
94128
interface SettledCatalog {
95129
dataSource: unknown;
@@ -156,6 +190,7 @@ export function RegistryRefField({
156190
const { t } = useFieldTranslation();
157191
const dataSource = props.dataSource ?? ctx?.dataSource ?? null;
158192
const catalog = useRegistryCatalog(dataSource, registryType);
193+
const excluded = nonGrantableNames(registryType);
159194
const current = typeof value === 'string' ? value : '';
160195
const testId = `registry-ref-${registryType}`;
161196

@@ -225,11 +260,17 @@ export function RegistryRefField({
225260
);
226261
}
227262

228-
if (!listed || listed.length === 0) return typedName;
263+
// What the picker offers: the listed items a grant can name.
264+
const offered = (listed ?? []).filter((item) => !excluded.has(item.name));
265+
if (offered.length === 0) return typedName;
229266

230-
const options = [...listed]
267+
const options = offered
231268
.sort((a, b) => a.name.localeCompare(b.name))
232269
.map((item) => ({ value: item.name, label: itemText(item) }));
270+
// A stored name the picker does not offer stays its own option, so it is
271+
// neither dropped nor replaced: a listed name a grant cannot name by its
272+
// label beside its name, a name the registry lacks flagged "not found".
273+
if (entry && excluded.has(entry.name)) options.unshift({ value: current, label: itemText(entry) });
233274
if (notFound) options.unshift({ value: current, label: notFoundText });
234275

235276
// DOM pass-through onto the combobox trigger, the widget's focusable control
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `position-ref` is a grant picker, and a grant cannot name an audience anchor
11+
* (objectui#12200). `everyone` and `guest` (ADR-0090 D5/D9) are held
12+
* implicitly by every authenticated or anonymous principal, the server refuses
13+
* a `sys_user_position` row that names one, and yet the position registry
14+
* lists both beside the assignable positions. These pins hold:
15+
*
16+
* - the picker leaves out exactly the spec's `AUDIENCE_ANCHOR_POSITIONS`;
17+
* - a registry that lists nothing BUT anchors offers no picker;
18+
* - a stored anchor still shows, by its label beside its name, unflagged, and
19+
* is not offered when the row holds another value;
20+
* - `permission-ref` filters nothing: a permission set named like an anchor is
21+
* an ordinary set.
22+
*/
23+
import { describe, it, expect, vi, afterEach } from 'vitest';
24+
import React from 'react';
25+
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
26+
import '@testing-library/jest-dom';
27+
import { AUDIENCE_ANCHOR_POSITIONS } from '@objectstack/spec';
28+
import { PositionRefField, PermissionRefField } from '../RegistryRefField';
29+
30+
/** The assignable positions, by label beside name, as the picker reads them. */
31+
const ASSIGNABLE = ['auditor', 'Sales Manager (sales_manager)', 'Sales Representative (sales_rep)'];
32+
33+
const POSITIONS = [
34+
{ name: 'sales_rep', label: 'Sales Representative' },
35+
{ name: 'sales_manager', label: 'Sales Manager' },
36+
{ name: 'auditor' },
37+
// The anchors, as the registry serves them: listed beside the positions.
38+
{ name: 'everyone', label: 'Everyone' },
39+
{ name: 'guest', label: 'Guest' },
40+
];
41+
42+
function lister(items: Array<{ name: string; label?: string }>) {
43+
return { listMetadataItems: vi.fn(async () => items), find: vi.fn() } as any;
44+
}
45+
46+
function mount(
47+
Widget: typeof PositionRefField,
48+
{ dataSource, value = '', readonly = false }: { dataSource: unknown; value?: string; readonly?: boolean },
49+
) {
50+
const onChange = vi.fn();
51+
render(
52+
<Widget
53+
value={value}
54+
onChange={onChange}
55+
readonly={readonly}
56+
field={{ name: 'position', type: 'text', label: 'Position' } as any}
57+
dataSource={dataSource}
58+
id="f-position"
59+
/>,
60+
);
61+
return { onChange };
62+
}
63+
64+
/** The option texts of the open picker, in order. */
65+
async function openOptions(): Promise<string[]> {
66+
fireEvent.click(await screen.findByRole('combobox'));
67+
await screen.findAllByRole('option');
68+
return screen.getAllByRole('option').map((option) => option.textContent ?? '');
69+
}
70+
71+
afterEach(() => cleanup());
72+
73+
describe('position-ref leaves the audience anchors out of what it offers (objectui#12200)', () => {
74+
it('offers the assignable positions and no anchor', async () => {
75+
// The names the card asks for are the ones the spec publishes.
76+
expect([...AUDIENCE_ANCHOR_POSITIONS].sort()).toEqual(['everyone', 'guest']);
77+
78+
mount(PositionRefField, { dataSource: lister(POSITIONS) });
79+
80+
expect(await openOptions()).toEqual(ASSIGNABLE);
81+
for (const anchor of AUDIENCE_ANCHOR_POSITIONS) {
82+
expect(screen.queryByText(new RegExp(`\\(${anchor}\\)$`))).toBeNull();
83+
}
84+
});
85+
86+
it('a registry that lists only the anchors offers no picker: the typed-name box', async () => {
87+
const ds = lister(POSITIONS.slice(3));
88+
mount(PositionRefField, { dataSource: ds });
89+
90+
await waitFor(() => expect(ds.listMetadataItems).toHaveBeenCalledWith('position'));
91+
const box = await screen.findByRole('textbox');
92+
await waitFor(() => expect(box).toBeEnabled());
93+
expect(screen.queryByRole('combobox')).toBeNull();
94+
expect(screen.queryByTestId('registry-ref-position-load-failed')).toBeNull();
95+
});
96+
});
97+
98+
describe('a stored anchor still shows, and is not offered as a fresh choice (objectui#12200)', () => {
99+
it('shows a stored `everyone` by its label beside its name, unflagged, and writes nothing', async () => {
100+
const { onChange } = mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'everyone' });
101+
102+
const trigger = await screen.findByRole('combobox');
103+
expect(trigger).toHaveTextContent('Everyone (everyone)');
104+
// The registry lists it, so it is not "not found".
105+
expect(trigger).not.toHaveAttribute('data-not-found');
106+
expect(onChange).not.toHaveBeenCalled();
107+
108+
// Its own option, as the row's value; the other anchor is not offered.
109+
expect(await openOptions()).toEqual(['Everyone (everyone)', ...ASSIGNABLE]);
110+
});
111+
112+
it('read-only: a stored anchor reads as its label beside its name', async () => {
113+
mount(PositionRefField, { dataSource: lister(POSITIONS), value: 'guest', readonly: true });
114+
115+
const shown = await screen.findByText('Guest (guest)');
116+
expect(shown).not.toHaveAttribute('data-not-found');
117+
});
118+
});
119+
120+
describe('permission-ref filters nothing (objectui#12200)', () => {
121+
it('lists, and writes, a permission set named like an anchor', async () => {
122+
const sets = lister([
123+
{ name: 'everyone', label: 'Everyone Baseline' },
124+
{ name: 'guest' },
125+
{ name: 'sales_user', label: 'Sales User' },
126+
]);
127+
const { onChange } = mount(PermissionRefField, { dataSource: sets });
128+
129+
expect(await openOptions()).toEqual(['Everyone Baseline (everyone)', 'guest', 'Sales User (sales_user)']);
130+
fireEvent.click(screen.getByText('Everyone Baseline (everyone)'));
131+
expect(onChange).toHaveBeenCalledWith('everyone');
132+
});
133+
});

0 commit comments

Comments
 (0)