Skip to content

Commit 9494dd8

Browse files
feat(spec)!: retire PositionGrantVia 'name' and the Setup by-name grant readers (#26398)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB6HyysmaMsguGWxWciWzUMt)_ Fixes #26022 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - **改了什么**:`PositionGrantVia` 收窄为只剩 `'definition'`;Setup 岗位列表去掉「(同名)」徽标及其两个 i18n 键;托管环境 Users 页的「未授权」标记不再按岗位同名推算权限集。 - **为什么改**:#24757 已让运行时不再按岗位名授予同名权限集,服务端再也不会返回 `via: 'name'`;spec 和界面却还在描述这种已不存在的授权(#13419 裁决 B,18.x 直接退役)。 - **风险与回滚**:风险低。线上返回值不变(本来就全是 `'definition'`);TS 消费方若比较 `via === 'name'` 会编译报错,删掉该分支即可。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/managedEnvironmentUsers.tsx`(判定逻辑变化:同名岗位的持有人现在会被标「未授权」);`packages/spec/src/contracts/security-service.ts`(公开类型收窄);`packages/spec/src/migrations/entries/semantic/18.position-grant-via-name-retired.ts`(ADR-0087 登记)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx`,预期 16 个用例全绿,其中「a position grants nothing by sharing its name with a permission set」为新判定。 - **需要你做的**:无,席位落地(落地前欠一次 `CONTRACT_REVIEW_TIER` 契约复核)。 ## What changes - **`@objectstack/spec`**: `PositionGrantVia` (`packages/spec/src/contracts/security-service.ts`) is now `'definition'`. The docblock no longer describes the position-name fold. The security plugin already serves only `via: 'definition'` (`security-plugin.ts` `describePositionGrants`), so the wire answer does not change. - **ADR-0087**: a new D3 semantic entry `position-grant-via-name-retired` registers the retirement. The type describes a served answer, not a stack collection or a metadata type, so there is no D2 conversion source. The changeset carries the `registered` disposition. - **Setup → Positions** (`PositionGrantCells.tsx`): the granted-sets cell shows each set's name alone. The "(by name)" / 「(同名)」 badge, its hint, and the i18n keys `engine.position.grants.byName` and `engine.position.grants.byNameHint` (en and zh) are removed. The served-entry filter accepts only `via: 'definition'`. - **Setup → Users on a managed environment** (`managedEnvironmentUsers.tsx`): the client-side name fold is retired. A held position now counts only by the sets its definition lists. The permission-set catalogue read, `catalogueRead`, and `PositionRegistrySource.getTypeStatus` existed only for the fold. Measured: no other reader in the file or in its one caller, `objectViewIdentity.tsx`. All three are removed, including the caller's `getTypeStatus` line. The observable change: a holder of a position whose name matches a permission set the definition does not list is now marked Not authorized, unless something else grants them a set beyond the baseline. That matches what the server enforces. - **Docblock**: `packages/rest/src/meta-item-read-gate.ts` (`describeMetaPositionList`) now says `via: 'definition'`. - **Fixture triage** (consumer radius of the narrowed value, respelled to a declared set): `packages/runtime/src/domains/meta-list-projection-parity.test.ts` and `ui/packages/data-objectstack/src/metadata-client.listInclude-25333.test.ts` still served `via: 'name'` from their doubles. Both now serve `{ name: 'forecasting', via: 'definition' }`. ## Tests - `managedEnvironmentUsers-13330.test.tsx`: the name-fold pin is inverted. A holder of `closer` (a position that shares its name with a set but lists only a baseline set) reads `baseline-only`. The control holder of `sales_rep` reads `beyond-baseline`. The registry is asked only for `position`. The "catalogue cannot be read" case is deleted because the read it covered is gone. - `ResourceListPage.positionGrants-25333.test.tsx`: the fixture serves only declared sets. Each badge's text is exactly the set name and carries no `title`. - Ablation, one-time: re-adding a name-based count, `beyondBaseline(position) ||` in the position branch, turns three cases red, the new one among them. The file was restored, and its blob equals HEAD (`044d3b83d42f`). <details><summary>Local readings (head 1efc50f, then merged head 1a70ed5)</summary> - `pnpm --filter @objectstack/spec build`: exit 0. `check:generated`: exit 0, all 14 artifacts up to date. The api-surface is unchanged because the export name stays. - `pnpm --filter @objectstack/spec test`: 726/726 files, 21984 tests passed. - `cd ui && vitest run` on the three touched test files: 3/3 files, 25/25 tests. Ablation run: 3 failed / 13 passed, as intended, then restored. - `cd ui && vitest run` on `app-shell/src/views/__tests__/`, `metadata-admin/ResourceListPage*`, `zh-base-term`, all of `data-objectstack`, and `i18n` `objectView-config-keys-retired-4730`: 182/182 files, 1871/1871 tests, on both heads. - `packages/runtime` `meta-list-projection-parity.test.ts`: 1/1 file, 878/878 tests. - Type checks: `@object-ui/app-shell type-check` (both heads), `@object-ui/data-objectstack type-check`, `@objectstack/spec typecheck`, `@objectstack/rest typecheck` and `@objectstack/runtime typecheck`: exit 0. Runtime first reported TS2307 for `@objectstack/driver-turso`, an unbuilt dist. It was green after building that package. - `dispatch-gates --commands` on the final head 1a70ed5: 94 commands, all exit 0. `--ran` reconciliation: 94 derived, 94 run, 0 NOT-MEASURED, a derived zero. </details> <details><summary>Full dogfood battery (final head 1a70ed5)</summary> `pnpm --filter @objectstack/dogfood test` under the verify lock: 302 files (2 failed, 297 passed, 3 skipped), 2549 tests (10 failed, 2521 passed, 18 skipped). VERDICT command-exit 1. All 10 failures are in `test/authz-probe-blind-spot.test.ts` (4) and `test/authz-conformance.test.ts` #17111 (6). They count text in `packages/rest/src/rest-server.ts` and `packages/rest/src/rest-route-ledger.ts`, for example the control `this.routeManager.register(` is expected at 62 and found at 63. This PR does not touch either file or `packages/qa/dogfood`. `git diff origin/main` on all three is empty, so the same counts fail on `origin/main` (e931621). They are main's, not this change's. 10 reds in authz-conformance.test.ts and authz-probe-blind-spot.test.ts are main's at origin/main abb9046 (same 10, measured on a detached worktree), anchored on #26400; not this PR's. </details> ## Acceptance notes - `packages/plugins/plugin-security/src/security-plugin.ts` (docblock of `describePositionGrants`, around line 3535) still says "the contract's `'name'` member is no longer served". The member is now gone, so the sentence goes stale. It is left alone because PR #26337 holds that file. Carrier: the seat. - `.changeset/24757-retire-position-name-fold.md` (pending, from #24757) says the `'name'` member is "retired separately in #26022; until then the value is published but never served". It stays accurate as a record of that change and is not edited here. - The full app-shell suite (about 1,800 files) was narrowed locally to the modules this diff reaches, listed above. CI's UI jobs run the rest. - The full-battery dogfood reds above are pre-existing on `origin/main` (see the dogfood block). They are reported to the seat as a main-red finding, not fixed here. ## Verification - Requires one `CONTRACT_REVIEW_TIER` contract review before the queue, as the claim records. - No governed surface is touched. Changed lines are about 203, under the 3,000 threshold. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzgKsGKQ8LMfpA72sns)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1a2e71 commit 9494dd8

14 files changed

Lines changed: 119 additions & 107 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
**BREAKING** — `PositionGrantVia` (`@objectstack/spec/contracts`) loses its `'name'` member, and the Setup position list drops its "(by name)" badge (#26022).
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: none
10+
Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); triage unlock on #26022 (`6424661273`)
11+
12+
**Why.** The security plugin no longer grants a position's holders the permission set that only shares the position's name: a position grants the sets its definition names in `permissionSets`, and nothing else (#24757). Since then nothing serves `via: 'name'` on `describePositionGrants` or on `GET /api/v1/meta/position?include=grants`. The spec still published the member and the console still drew a badge for it, so both described a grant the runtime no longer makes. #13419 ruling B retires the channel in 18.x with no retirement stage.
13+
14+
What changes:
15+
16+
- `@objectstack/spec`: `PositionGrantVia` is `'definition'`. Every entry of `PositionGrantSummary.permissionSets` carries `via: 'definition'`, as the security plugin already serves it.
17+
- `@object-ui/app-shell`, Setup → Positions: the granted-sets column shows each set's name alone. The "(by name)" / 「(同名)」 badge and its hint are gone, with the i18n keys `engine.position.grants.byName` and `engine.position.grants.byNameHint` (en and zh).
18+
- `@object-ui/app-shell`, Setup → Users on a managed environment: the 「未授权」 marker judges a held position only by the sets its definition lists. It no longer counts a permission set that shares the position's name, and it no longer reads the permission-set catalogue to do so. A holder of such a position is now marked when nothing else grants them a set beyond the baseline, which is what the server enforces. Before this change, such a holder was left unmarked.
19+
20+
For TypeScript consumers: code that compares a `PositionGrantVia` value with `'name'` no longer compiles (TS2367). Delete that branch, because no server serves the value. To have a position grant the set that shares its name, name the set in the position's `permissionSets`. No stored row or authored metadata carries the type, so nothing is converted.
21+
22+
The ADR-0087 D3 semantic entry `position-grant-via-name-retired` records the retirement. `PositionGrantVia` types a served description, not a stack collection or a metadata type, so there is no source for a D2 conversion to rewrite.
23+
24+
<!-- adr-0087: registered position-grant-via-name-retired -->

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3373,7 +3373,8 @@ function withItems(raw: unknown, items: any[]): unknown {
33733373
* organization, or `null` when no count is a fact for this caller (see
33743374
* `PositionGrantSummary.holderCount`);
33753375
* - `_grantedPermissionSets` — the sets it grants its holders, in force, each
3376-
* with `via: 'definition' | 'name'` (`name`: the position-name fold).
3376+
* with `via: 'definition'` (a set that only shares the position's name is
3377+
* not granted, so it is not listed).
33773378
*
33783379
* Both are the security service's answer (`describePositionGrants`), which reads
33793380
* them where the resolver enforces them — this step only places them, so the

‎packages/runtime/src/domains/meta-list-projection-parity.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ const securityFor = (caller: Caller) => ({
344344
holderCount: caller.holdings.length > 0 ? (position === 'sales_rep' ? 2 : 1) : null,
345345
permissionSets: position === 'sales_rep'
346346
? [{ name: 'crm_sales_user', via: 'definition' }]
347-
: [{ name: 'sales_manager', via: 'name' }],
347+
: [{ name: 'forecasting', via: 'definition' }],
348348
})),
349349
});
350350

@@ -676,7 +676,7 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\
676676
const described = (await read('/meta/position', { include: 'grants' })).items;
677677
expect(described?.map((p: any) => [p.name, p._holderCount, p._grantedPermissionSets])).toEqual([
678678
['sales_rep', 2, [{ name: 'crm_sales_user', via: 'definition' }]],
679-
['sales_manager', 1, [{ name: 'sales_manager', via: 'name' }]],
679+
['sales_manager', 1, [{ name: 'forecasting', via: 'definition' }]],
680680
]);
681681
expect(text(await read('/meta/positions'))).not.toContain('_holderCount');
682682
expect((await bootRest('non-holder').read('/meta/position', { include: 'grants' })).items?.[0]?._holderCount).toBeNull();

‎packages/spec/src/contracts/security-service.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,13 +121,14 @@ export interface DelegableScope {
121121
* {@link PositionGrantSummary}:
122122
*
123123
* - `definition` — the position's definition names it in `permissionSets`
124-
* (ADR-0131 D3/D4), the binding the spec declares;
125-
* - `name` — it shares the position's NAME and the definition does not list
126-
* it: the resolver's position-name fold grants it anyway. That grant is
127-
* deprecated (the runtime warns `position_name_fold_grant`); it is reported so
128-
* that what a position is shown to grant is what it is enforced to grant.
124+
* (ADR-0131 D3/D4), the binding the spec declares and the only one the
125+
* resolver grants by.
126+
*
127+
* A set that merely shares the position's name is not granted, so it is never
128+
* listed: the position-name fold is retired, and with it the `name` member
129+
* (ADR-0087 D3 entry `position-grant-via-name-retired`).
129130
*/
130-
export type PositionGrantVia = 'definition' | 'name';
131+
export type PositionGrantVia = 'definition';
131132

132133
/** One position, as {@link ISecurityService.describePositionGrants} describes it. */
133134
export interface PositionGrantSummary {
@@ -727,8 +728,8 @@ export interface ISecurityService {
727728
* get?" from the enforcement path rather than from a second copy of it.
728729
*
729730
* Answers one {@link PositionGrantSummary} per name, in input order. A name
730-
* that names no position is described like any other: no holders, and only
731-
* what the position-name fold would grant a holder of that name.
731+
* that names no position is described like any other: no holders and no
732+
* permission sets.
732733
*
733734
* **THROWS on a read failure** — a holder count or a grant that could not be
734735
* read is never reported as `0` or as none.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// A TS/API surface, never stored in stack metadata: `PositionGrantVia` types a
6+
// served description (`describePositionGrants`, `GET /meta/position?include=grants`),
7+
// so there is no source for the chain to rewrite and this entry is the whole
8+
// ADR-0087 registration. The runtime half (the position-name fold itself) carries
9+
// no entry of its own: it moved no authorable key, spelling or stored shape.
10+
export const entry: SemanticMigration = {
11+
id: 'position-grant-via-name-retired',
12+
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
13+
// code span AND a table cell.
14+
surface:
15+
'PositionGrantVia on @objectstack/spec/contracts (the via of each entry of '
16+
+ 'PositionGrantSummary.permissionSets, served as _grantedPermissionSets on the position list '
17+
+ 'read with include=grants) loses its name member',
18+
replacement:
19+
'(no replacement member — `via` is `\'definition\'` on every entry. Code that branched on '
20+
+ '`via === \'name\'` deletes that branch: the compiler names it (TS2367, the two types have no '
21+
+ 'overlap). To have a position grant the permission set that shares its name, name the set in the '
22+
+ 'position\'s `permissionSets`.)',
23+
reason:
24+
'ADR-0131 D3/D4 and the retirement of the position-name fold: a position grants only the sets its '
25+
+ 'definition names, so a set that merely shares its name is neither granted nor described. Since '
26+
+ 'the security plugin stopped requesting a held position\'s name as a set name, nothing serves '
27+
+ '`via: \'name\'`; a published member no producer can emit invites a consumer to keep a branch, '
28+
+ 'such as a by-name badge, that can only be dead or wrong (ADR-0049 enforce-or-remove).',
29+
acceptanceCriteria:
30+
'No code compares a `PositionGrantVia` value with `\'name\'` (TS2367 after upgrade). '
31+
+ '`GET /api/v1/meta/position?include=grants` serves `via: \'definition\'` on every entry of '
32+
+ '`_grantedPermissionSets`, exactly as before this change. No authored metadata document ever carried '
33+
+ 'the type, so `os migrate meta` has nothing to visit.',
34+
};

‎ui/content/docs/guide/console.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
3939
| **Studio Copilot Drafts** | When the AI copilot saves a change to a live app as a draft, its card offers **Review changes and publish** and **Undo**. Review opens Studio's **Changes** drawer for the package: in the Studio copilot it opens in place (same pillar, same selection), and from the full-page chat it opens the package's Studio with the drawer open. The drawer's link is `/studio/:packageId/:tab?changes=review`; Studio opens the drawer to review the pending drafts and drops the parameter. Undo discards the package's pending drafts (`POST /api/v1/packages/:id/discard-drafts`) and asks first when other drafts are pending. The top bar's **Changes** count follows the copilot as its tools answer, including a change the environment publishes immediately. The Studio copilot draws no floating pending-changes bar over its composer: there the top bar's **Changes** and **Publish** are the way to review and publish. The full-page chat and the console dock show that bar while the package has pending drafts. |
4040
| **Dashboard Refresh** | A dashboard page shows a **Refresh All** button at the right of its page header, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted, and a dataset-bound single-value (KPI) tile re-reads too. `0` or no value means no automatic refresh. |
4141
| **Refused Dashboards and List Views** | The server lists only the dashboards and list views a user may open, so one gated by `requiredPermissions` is not in their lists. When such a dashboard or list view is opened by its URL, the console asks the server about it by name. If the server refuses it with `403 PERMISSION_DENIED`, the page says "You don't have access" and shows the server's sentence, which names the item. The page does not say "not found", and the list page does not switch to the default view. For a name that does not exist, the dashboard page still says it was not found and the list page still opens the default view. An object's own list view that the server sends whole to a user who may edit the object is listed and opens. |
42-
| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. A permission set that shares the position's name is granted even though the position does not list it, so it is shown and marked "(by name)". A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. |
42+
| **Setup Positions** | Setup → Positions lists, for each position, how many people hold it in the active organization and which permission sets it grants. Both columns come from the server (`GET /meta/position?include=grants`), which computes them where it enforces them. The column lists only the permission sets the position's definition names; a set that merely shares the position's name is not granted and not listed. A holder count the server did not serve (no active organization, or no read on position assignments) shows "—", not 0. On every Setup metadata list, a click anywhere on a row opens that item. New Position asks for the permission sets with the label and name, so a position is created and bound in one save. On a position's page, Add holder offers the members of the active organization only (its `sys_member` rows), and Remove asks first, naming the person. |
4343
| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. |
4444
| **Record Approvals Tab** | A record with approval requests grows an Approvals tab on its detail page (peer of Details/Related, with a request-count badge) — current step, decision progress, resolved "waiting on" approvers, the merged decision timeline, and a submitter remind button — visible to every viewer who can read the record, not just approvers. |
4545
| **Approval Request Pages** | A request opens on its record page, read through the approvals routes rather than the data API: the request with its decision tally and the `viewer` block its decision actions gate on, and its Timeline from the request's actions route. Those routes serve a request to each party to it, so an approver who is not an administrator sees the full page — fields, decision actions and Timeline — although the data API stays closed to them on both approval objects. |

‎ui/packages/app-shell/src/views/ObjectView.managedEnvironmentUsers-13330.test.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ const POSITIONS = [
6666
{ name: 'everyone', permissionSets: ['member_default'] },
6767
{ name: 'guest', permissionSets: [] },
6868
{ name: 'sales_rep', permissionSets: ['sales'] },
69-
// Lists only the baseline; its holders get the `closer` set by name.
69+
// Lists only the baseline, and shares its name with a permission set
70+
// `closer`: its holders are not granted that set (the position-name fold is
71+
// retired, objectstack-ai/objectstack#24757).
7072
{ name: 'closer', permissionSets: ['member_default'] },
7173
];
72-
const PERMISSION_SETS = [{ name: 'member_default' }, { name: 'sales' }, { name: 'closer' }];
7374
vi.mock('@object-ui/react', async (importOriginal) => {
7475
const actual = await importOriginal<typeof import('@object-ui/react')>();
7576
const none: never[] = [];
@@ -83,10 +84,9 @@ vi.mock('@object-ui/react', async (importOriginal) => {
8384
error: null,
8485
refresh: async () => {},
8586
invalidate: () => {},
86-
ensureType: async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : []),
87+
ensureType: async (type: string) => (type === 'position' ? POSITIONS : []),
8788
getItem: async () => null,
8889
getItemsByType: () => [],
89-
getTypeStatus: () => 'ready' as const,
9090
});
9191
return { ...actual, useMetadata: () => store };
9292
});
@@ -138,7 +138,7 @@ const GRANTS = [
138138
];
139139
const ASSIGNMENTS = [
140140
{ id: 'a1', user_id: 'u_cy', position: 'sales_rep' },
141-
// Granted the `closer` set by the position's name alone.
141+
// Holds a position that only shares its name with a set: nothing beyond the baseline.
142142
{ id: 'a3', user_id: 'u_eve', position: 'closer' },
143143
// The anchor: every user holds it, and it lists only the baseline.
144144
{ id: 'a2', user_id: 'u_ada', position: 'everyone' },
@@ -206,15 +206,16 @@ describe('objectui#13330 — Setup → Users on a managed environment', () => {
206206
expect(line).toHaveTextContent(/Invite people to your organization there/);
207207
});
208208

209-
it('marks the users who hold nothing beyond the baseline, and only them', async () => {
209+
it('marks the users who hold nothing beyond the baseline, and only them — a position grants by its definition, never by its name', async () => {
210210
mountUsers({ admin: true, cloudManaged: true });
211-
for (const name of ['Ada Baseline', 'Dee Expired']) {
211+
// Eve holds `closer`, which lists only the baseline; the set named `closer` is not granted to her.
212+
for (const name of ['Ada Baseline', 'Dee Expired', 'Eve Folded']) {
212213
const row = await rowOf(name);
213214
await waitFor(() => expect(within(row).getByTestId('managed-users-unauthorized')).toHaveTextContent('Not authorized'), {
214215
timeout: 5000,
215216
});
216217
}
217-
for (const name of ['Bob Direct', 'Cy Position', 'Eve Folded']) {
218+
for (const name of ['Bob Direct', 'Cy Position']) {
218219
expect(within(await rowOf(name)).queryByTestId('managed-users-unauthorized')).toBeNull();
219220
}
220221
// One read per object answers the whole page, asked by user.

‎ui/packages/app-shell/src/views/__tests__/managedEnvironmentUsers-13330.test.tsx‎

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ const POSITIONS = [
2424
{ name: 'guest', permissionSets: ['guest_read'] },
2525
{ name: 'sales_rep', permissionSets: ['sales'] },
2626
{ name: 'greeter', permissionSets: ['member_default'] },
27-
// Lists only a baseline set, and shares its name with a set that exists.
27+
// Lists only a baseline set, and shares its name with a permission set `closer`.
2828
{ name: 'closer', permissionSets: ['member_default'] },
2929
];
30-
const PERMISSION_SETS = ['member_default', 'accepted_default', 'guest_read', 'sales', 'closer'].map((name) => ({ name }));
3130

3231
function sourceOf(grants: unknown, assignments: unknown) {
3332
return {
@@ -37,8 +36,7 @@ function sourceOf(grants: unknown, assignments: unknown) {
3736
};
3837
}
3938
const registry = {
40-
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : [])),
41-
getTypeStatus: () => 'ready',
39+
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])),
4240
};
4341

4442
afterEach(() => cleanup());
@@ -124,40 +122,24 @@ describe('readUserGrantStates', () => {
124122
expect(states.get('u4')).toBe('baseline-only');
125123
});
126124

127-
it('a position counts the set of its own name, though its definition does not list it (the name fold)', async () => {
125+
it('a position grants nothing by sharing its name with a permission set; only its definition counts', async () => {
126+
registry.ensureType.mockClear();
128127
const states = await readUserGrantStates(
129128
sourceOf(
130129
{ data: [] },
131-
{ data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'greeter' }] },
130+
{ data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'sales_rep' }] },
132131
),
133132
registry,
134133
['u1', 'u2'],
135134
NOW,
136135
);
137-
// The server grants holders of `closer` the `closer` set (objectstack-ai/objectstack#24757).
138-
expect(states.get('u1')).toBe('beyond-baseline');
139-
// No set is named `greeter`: nothing folds.
140-
expect(states.get('u2')).toBe('baseline-only');
141-
});
142-
143-
it('a position held while the permission-set catalogue cannot be read leaves its user unknown', async () => {
144-
const refused = {
145-
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])),
146-
getTypeStatus: (type: string) => (type === 'permission' ? 'error' : 'ready'),
147-
};
148-
const states = await readUserGrantStates(
149-
sourceOf(
150-
{ data: [{ user_id: 'u3', permission_set: 'sales' }] },
151-
{ data: [{ user_id: 'u1', position: 'greeter' }] },
152-
),
153-
refused,
154-
['u1', 'u2', 'u3'],
155-
NOW,
156-
);
157-
expect(states.get('u1')).toBe('unknown');
158-
// A user who holds no position and no grant row holds the baseline, catalogue or not.
159-
expect(states.get('u2')).toBe('baseline-only');
160-
expect(states.get('u3')).toBe('beyond-baseline');
136+
// `closer` lists only a baseline set; the set named `closer` is not granted
137+
// to its holders (the position-name fold is retired, objectstack-ai/objectstack#24757).
138+
expect(states.get('u1')).toBe('baseline-only');
139+
// Control: a position whose definition lists a set beyond the baseline.
140+
expect(states.get('u2')).toBe('beyond-baseline');
141+
// The permission-set catalogue is not read: no set is judged by its name.
142+
expect(registry.ensureType.mock.calls.map(([type]) => type)).toEqual(['position']);
161143
});
162144

163145
it('a position the registry does not define leaves its user unknown', async () => {

0 commit comments

Comments
 (0)