Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/26022-retire-position-grant-via-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': patch
'@object-ui/app-shell': patch
---

**BREAKING** — `PositionGrantVia` (`@objectstack/spec/contracts`) loses its `'name'` member, and the Setup position list drops its "(by name)" badge (#26022).

Clause-②: no (narrowing)
Metadata-protocol: none
Ruling-ref: #13419 ruling B (`5472608110`); 「甲」 as amended (`6303865025`); triage unlock on #26022 (`6424661273`)

**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.

What changes:

- `@objectstack/spec`: `PositionGrantVia` is `'definition'`. Every entry of `PositionGrantSummary.permissionSets` carries `via: 'definition'`, as the security plugin already serves it.
- `@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).
- `@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.

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.

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.

<!-- adr-0087: registered position-grant-via-name-retired -->
3 changes: 2 additions & 1 deletion packages/rest/src/meta-item-read-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3373,7 +3373,8 @@ function withItems(raw: unknown, items: any[]): unknown {
* organization, or `null` when no count is a fact for this caller (see
* `PositionGrantSummary.holderCount`);
* - `_grantedPermissionSets` — the sets it grants its holders, in force, each
* with `via: 'definition' | 'name'` (`name`: the position-name fold).
* with `via: 'definition'` (a set that only shares the position's name is
* not granted, so it is not listed).
*
* Both are the security service's answer (`describePositionGrants`), which reads
* them where the resolver enforces them — this step only places them, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ const securityFor = (caller: Caller) => ({
holderCount: caller.holdings.length > 0 ? (position === 'sales_rep' ? 2 : 1) : null,
permissionSets: position === 'sales_rep'
? [{ name: 'crm_sales_user', via: 'definition' }]
: [{ name: 'sales_manager', via: 'name' }],
: [{ name: 'forecasting', via: 'definition' }],
})),
});

Expand Down Expand Up @@ -676,7 +676,7 @@ describe('[#20320] row A: the reference moves — each probe changes RestServer\
const described = (await read('/meta/position', { include: 'grants' })).items;
expect(described?.map((p: any) => [p.name, p._holderCount, p._grantedPermissionSets])).toEqual([
['sales_rep', 2, [{ name: 'crm_sales_user', via: 'definition' }]],
['sales_manager', 1, [{ name: 'sales_manager', via: 'name' }]],
['sales_manager', 1, [{ name: 'forecasting', via: 'definition' }]],
]);
expect(text(await read('/meta/positions'))).not.toContain('_holderCount');
expect((await bootRest('non-holder').read('/meta/position', { include: 'grants' })).items?.[0]?._holderCount).toBeNull();
Expand Down
17 changes: 9 additions & 8 deletions packages/spec/src/contracts/security-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,14 @@ export interface DelegableScope {
* {@link PositionGrantSummary}:
*
* - `definition` — the position's definition names it in `permissionSets`
* (ADR-0131 D3/D4), the binding the spec declares;
* - `name` — it shares the position's NAME and the definition does not list
* it: the resolver's position-name fold grants it anyway. That grant is
* deprecated (the runtime warns `position_name_fold_grant`); it is reported so
* that what a position is shown to grant is what it is enforced to grant.
* (ADR-0131 D3/D4), the binding the spec declares and the only one the
* resolver grants by.
*
* A set that merely shares the position's name is not granted, so it is never
* listed: the position-name fold is retired, and with it the `name` member
* (ADR-0087 D3 entry `position-grant-via-name-retired`).
*/
export type PositionGrantVia = 'definition' | 'name';
export type PositionGrantVia = 'definition';

/** One position, as {@link ISecurityService.describePositionGrants} describes it. */
export interface PositionGrantSummary {
Expand Down Expand Up @@ -727,8 +728,8 @@ export interface ISecurityService {
* get?" from the enforcement path rather than from a second copy of it.
*
* Answers one {@link PositionGrantSummary} per name, in input order. A name
* that names no position is described like any other: no holders, and only
* what the position-name fold would grant a holder of that name.
* that names no position is described like any other: no holders and no
* permission sets.
*
* **THROWS on a read failure** — a holder count or a grant that could not be
* read is never reported as `0` or as none.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// A TS/API surface, never stored in stack metadata: `PositionGrantVia` types a
// served description (`describePositionGrants`, `GET /meta/position?include=grants`),
// so there is no source for the chain to rewrite and this entry is the whole
// ADR-0087 registration. The runtime half (the position-name fold itself) carries
// no entry of its own: it moved no authorable key, spelling or stored shape.
export const entry: SemanticMigration = {
id: 'position-grant-via-name-retired',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a
// code span AND a table cell.
surface:
'PositionGrantVia on @objectstack/spec/contracts (the via of each entry of '
+ 'PositionGrantSummary.permissionSets, served as _grantedPermissionSets on the position list '
+ 'read with include=grants) loses its name member',
replacement:
'(no replacement member — `via` is `\'definition\'` on every entry. Code that branched on '
+ '`via === \'name\'` deletes that branch: the compiler names it (TS2367, the two types have no '
+ 'overlap). To have a position grant the permission set that shares its name, name the set in the '
+ 'position\'s `permissionSets`.)',
reason:
'ADR-0131 D3/D4 and the retirement of the position-name fold: a position grants only the sets its '
+ 'definition names, so a set that merely shares its name is neither granted nor described. Since '
+ 'the security plugin stopped requesting a held position\'s name as a set name, nothing serves '
+ '`via: \'name\'`; a published member no producer can emit invites a consumer to keep a branch, '
+ 'such as a by-name badge, that can only be dead or wrong (ADR-0049 enforce-or-remove).',
acceptanceCriteria:
'No code compares a `PositionGrantVia` value with `\'name\'` (TS2367 after upgrade). '
+ '`GET /api/v1/meta/position?include=grants` serves `via: \'definition\'` on every entry of '
+ '`_grantedPermissionSets`, exactly as before this change. No authored metadata document ever carried '
+ 'the type, so `os migrate meta` has nothing to visit.',
};
2 changes: 1 addition & 1 deletion ui/content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. |
| **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. |
| **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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,11 @@ const POSITIONS = [
{ name: 'everyone', permissionSets: ['member_default'] },
{ name: 'guest', permissionSets: [] },
{ name: 'sales_rep', permissionSets: ['sales'] },
// Lists only the baseline; its holders get the `closer` set by name.
// Lists only the baseline, and shares its name with a permission set
// `closer`: its holders are not granted that set (the position-name fold is
// retired, objectstack-ai/objectstack#24757).
{ name: 'closer', permissionSets: ['member_default'] },
];
const PERMISSION_SETS = [{ name: 'member_default' }, { name: 'sales' }, { name: 'closer' }];
vi.mock('@object-ui/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('@object-ui/react')>();
const none: never[] = [];
Expand All @@ -83,10 +84,9 @@ vi.mock('@object-ui/react', async (importOriginal) => {
error: null,
refresh: async () => {},
invalidate: () => {},
ensureType: async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : []),
ensureType: async (type: string) => (type === 'position' ? POSITIONS : []),
getItem: async () => null,
getItemsByType: () => [],
getTypeStatus: () => 'ready' as const,
});
return { ...actual, useMetadata: () => store };
});
Expand Down Expand Up @@ -138,7 +138,7 @@ const GRANTS = [
];
const ASSIGNMENTS = [
{ id: 'a1', user_id: 'u_cy', position: 'sales_rep' },
// Granted the `closer` set by the position's name alone.
// Holds a position that only shares its name with a set: nothing beyond the baseline.
{ id: 'a3', user_id: 'u_eve', position: 'closer' },
// The anchor: every user holds it, and it lists only the baseline.
{ id: 'a2', user_id: 'u_ada', position: 'everyone' },
Expand Down Expand Up @@ -206,15 +206,16 @@ describe('objectui#13330 — Setup → Users on a managed environment', () => {
expect(line).toHaveTextContent(/Invite people to your organization there/);
});

it('marks the users who hold nothing beyond the baseline, and only them', async () => {
it('marks the users who hold nothing beyond the baseline, and only them — a position grants by its definition, never by its name', async () => {
mountUsers({ admin: true, cloudManaged: true });
for (const name of ['Ada Baseline', 'Dee Expired']) {
// Eve holds `closer`, which lists only the baseline; the set named `closer` is not granted to her.
for (const name of ['Ada Baseline', 'Dee Expired', 'Eve Folded']) {
const row = await rowOf(name);
await waitFor(() => expect(within(row).getByTestId('managed-users-unauthorized')).toHaveTextContent('Not authorized'), {
timeout: 5000,
});
}
for (const name of ['Bob Direct', 'Cy Position', 'Eve Folded']) {
for (const name of ['Bob Direct', 'Cy Position']) {
expect(within(await rowOf(name)).queryByTestId('managed-users-unauthorized')).toBeNull();
}
// One read per object answers the whole page, asked by user.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,9 @@ const POSITIONS = [
{ name: 'guest', permissionSets: ['guest_read'] },
{ name: 'sales_rep', permissionSets: ['sales'] },
{ name: 'greeter', permissionSets: ['member_default'] },
// Lists only a baseline set, and shares its name with a set that exists.
// Lists only a baseline set, and shares its name with a permission set `closer`.
{ name: 'closer', permissionSets: ['member_default'] },
];
const PERMISSION_SETS = ['member_default', 'accepted_default', 'guest_read', 'sales', 'closer'].map((name) => ({ name }));

function sourceOf(grants: unknown, assignments: unknown) {
return {
Expand All @@ -37,8 +36,7 @@ function sourceOf(grants: unknown, assignments: unknown) {
};
}
const registry = {
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : type === 'permission' ? PERMISSION_SETS : [])),
getTypeStatus: () => 'ready',
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])),
};

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

it('a position counts the set of its own name, though its definition does not list it (the name fold)', async () => {
it('a position grants nothing by sharing its name with a permission set; only its definition counts', async () => {
registry.ensureType.mockClear();
const states = await readUserGrantStates(
sourceOf(
{ data: [] },
{ data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'greeter' }] },
{ data: [{ user_id: 'u1', position: 'closer' }, { user_id: 'u2', position: 'sales_rep' }] },
),
registry,
['u1', 'u2'],
NOW,
);
// The server grants holders of `closer` the `closer` set (objectstack-ai/objectstack#24757).
expect(states.get('u1')).toBe('beyond-baseline');
// No set is named `greeter`: nothing folds.
expect(states.get('u2')).toBe('baseline-only');
});

it('a position held while the permission-set catalogue cannot be read leaves its user unknown', async () => {
const refused = {
ensureType: vi.fn(async (type: string) => (type === 'position' ? POSITIONS : [])),
getTypeStatus: (type: string) => (type === 'permission' ? 'error' : 'ready'),
};
const states = await readUserGrantStates(
sourceOf(
{ data: [{ user_id: 'u3', permission_set: 'sales' }] },
{ data: [{ user_id: 'u1', position: 'greeter' }] },
),
refused,
['u1', 'u2', 'u3'],
NOW,
);
expect(states.get('u1')).toBe('unknown');
// A user who holds no position and no grant row holds the baseline, catalogue or not.
expect(states.get('u2')).toBe('baseline-only');
expect(states.get('u3')).toBe('beyond-baseline');
// `closer` lists only a baseline set; the set named `closer` is not granted
// to its holders (the position-name fold is retired, objectstack-ai/objectstack#24757).
expect(states.get('u1')).toBe('baseline-only');
// Control: a position whose definition lists a set beyond the baseline.
expect(states.get('u2')).toBe('beyond-baseline');
// The permission-set catalogue is not read: no set is judged by its name.
expect(registry.ensureType.mock.calls.map(([type]) => type)).toEqual(['position']);
});

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