Skip to content

Commit a0fac02

Browse files
feat(platform-objects,spec): a single-organization deployment can rename its organization (#25661)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuA2jsB5ocLSV8XnUmaYAnbA)_ Fixes #25347 Clause-②: yes Metadata-protocol: none Ruling-ref: 6384629949 (maintainer ruling A, 「25347 同意」). Claim of record: `6384658457` on the card (card thread; the first PR #25464 landed the help and Branding half as `832a84eb`). Branch `claude/project-thread-xvfy21` is the harness-pinned branch for this session. It names no issue, so the Duplicate Fix Guard's branch-name hint will not match, and the claim is the identity record. ## Why Under the `single` posture the one organization could not be renamed anywhere. `update_organization` and `rename_organization` were both gated on `multiOrgEnabled`, and the generic edit form keeps `name` readonly (ADR-0092 D4). So the record kept "Default Organization" for good, which is the third name in the card's report. Renaming the one organization changes nothing about tenancy, so it is not a multi-organization action. ## What changes - **`sys_organization.rename_organization`** (name and logo, never the slug): `requiresFeature` moves from `multiOrgEnabled` to `organization`. Its membership grades are unchanged: owner and admin, the same `requiresMembershipReach: 'update_organization'`. better-auth's `organization/update` still decides at the door. - **Stays multi-organization only:** `update_organization` (it also sends the slug), `create_organization`, `delete_organization`, `set_active_organization` and `leave_organization`. Under `multi` nothing changes. - **`PUBLIC_AUTH_FEATURES`**: the `'sys_organization.actions.rename_organization'` row moves from `multiOrgEnabled.gatedInputs` to `organization.gatedInputs`. `feature-gate-guard.test.ts` checks the registry against the declarations in both directions, so the row and the gate land together, as in #24216, #24317 and #25309. - **`sys_organization.name`'s help** now names its door: "Change it with Rename Organization at the top of this record." en was regenerated by `check-i18n-bundles --write`. The zh-CN, ja-JP and es-ES leaves were translated by hand, using each bundle's own label for the action. ## Outside the claim's file surface This card is `domain:services`. One file belongs to the spec lane: `packages/spec/src/kernel/public-auth-features.ts`. It changes by +5 / −3: one row moved between two `gatedInputs` lists, with its comment. Maintainer ruling `6384629949` on #25347 puts it in this PR, for this card only; standing rule G is not decided. A cross-lane declaration is posted on spec seat post #6017. The spec face owes a `CONTRACT_REVIEW_TIER` contract review before the queue. The build is `mode:cloud`, because the surface spans `packages/spec/src/**` and another package. ## Pins - `sys-organization-rename-action.test.ts`: the gate case now pins `rename_organization` on `(reach) && features.organization != false`, with `update_organization` on `multiOrgEnabled` as the control. - `platform-objects.test.ts`: - The gate-matrix row for `rename_organization` is now `${ADMINS} && ${ORG}`. - The multiOrg roster test ("the SysOrganization rows are every action it gates on multiOrgEnabled") now counts only the rows whose expected gate is `MULTI_ORG`. - `sys-organization-name-timezone-help.test.ts`: a new case checks that the name help names Rename Organization and that the action carries that label. - New `packages/qa/dogfood/test/organization-rename-single.dogfood.test.ts`. On a booted plain showcase (`single`), on the wire: - The served flags are `organization: true`, `multiOrgEnabled: false`. The seeded admin holds an owner or admin grade. - The admin is offered Rename Organization, evaluated with the console's `celEngine` against the served metadata and session `positions`. The five multi-organization actions stay hidden. - A plain member is not offered it. The door refuses the member with 403 `YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_ORGANIZATION`, and the name is unchanged. - The admin's rename, built the way the console's api runner builds it, returns 200. The name reads back changed and the slug is kept. **Ablation:** `sys-organization.object.ts`, `public-auth-features.ts` and the four bundles were restored from `origin/main` in the worktree only, and spec and platform-objects rebuilt. - In platform-objects, 4 tests failed and 156 passed: the gate case, the matrix row, the roster test and the new help case. - In the dogfood pin, 1 test failed and 3 passed: "the admin is offered Rename Organization". The door itself admits the rename on both trees, because the gate is a console affordance. - After restoring HEAD and rebuilding, the dogfood pin passed 4 of 4. ## Tests and gates (head `d613a6d8`, base `49653f91`) - `pnpm --filter @objectstack/spec test`: `Test Files 716 passed (716)`, `Tests 21725 passed | 1 todo`. - `pnpm --filter @objectstack/platform-objects test`: `Test Files 94 passed (94)`, `Tests 1344 passed (1344)`. This run includes `feature-gate-guard.test.ts`. - `organization-rename-single`, `organization-rename-door`, `org-record-admin-reach` and `org-change-slug-bound-action` (dogfood): `Test Files 4 passed (4)`, `Tests 18 passed (18)`. - Typecheck for spec, platform-objects and dogfood: exit 0. eslint on the changed files: clean. - `dispatch-gates --commands`: 91 families. All 91 exited 0, and `--ran` reports 91 run, 0 NOT-MEASURED. - `check-i18n-bundles`, `check:i18n-stale-fill`, and `check-adr-0087-registration --base origin/main` (no breaking changeset) all passed. `git merge-tree` onto the latest `origin/main` is clean. - Full dogfood battery: not owed. The title has no `!` and `Clause-②` has no `(narrowing)` arm. ## Overlap Two open PRs touch neighbouring lines, and neither asserts anything about this action: - #25626 adds `sys_user.actions.create_user.params.organizationId` near the moved row in `multiOrgEnabled.gatedInputs`. - #25637 rewrites `invite_user`'s `successMessage` in `sys-organization.object.ts`. Whichever lands second needs only a text merge. ## Acceptance notes None new. The first PR's notes still stand: there are two built-in product names (`OS_PRODUCT_NAME` and `OS_APP_NAME`), and `sys_business_unit.timezone`'s help promises an inheritance that no code implements. Changesets: `.changeset/25347-rename-organization-single.md` (`@objectstack/platform-objects` patch) and `.changeset/25347-rename-organization-auth-feature.md` (`@objectstack/spec` patch). --- _Generated by [Claude Code](https://claude.ai/code/session_01JJZRJcjTF4oBQK7333oAnR)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JJZRJcjTF4oBQK7333oAnR Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc6c7ee commit a0fac02

12 files changed

Lines changed: 232 additions & 24 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): `PUBLIC_AUTH_FEATURES` books Rename Organization under the organization capability
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
The `sys_organization.actions.rename_organization` row moves from `multiOrgEnabled.gatedInputs` to `organization.gatedInputs`, matching the action's new `requiresFeature: 'organization'` in `@objectstack/platform-objects`. A `single` deployment, where `multiOrgEnabled` is false and the organization capability is on, now offers it. No other row moves.
11+
12+
Ruling 6384629949 on #25347.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
---
4+
5+
feat(platform-objects): a single-organization deployment can rename its organization
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
- **`sys_organization`'s Rename Organization** (name and logo, never the slug) is now gated on the organization capability instead of `multiOrgEnabled`. Under the `single` posture it was the one organization's only possible name door, and it was hidden: the generic edit keeps `name` readonly and every other organization action is multi-organization only. It is still offered only to the organization's owners and admins, and better-auth's `organization/update` still decides at the door.
11+
- **Edit Organization** (which also sends the slug), Create, Delete, Switch and Leave stay multi-organization only. Under `multi` nothing changes.
12+
- **`sys_organization.name`'s help** now says the name is changed with Rename Organization, in all four bundles.
13+
14+
Ruling 6384629949 on #25347.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
528528
fields: {
529529
name: {
530530
label: "Name",
531-
help: "The organization's own name. The product name the console shows is a different setting, set in Settings → Branding."
531+
help: "The organization's own name. Change it with Rename Organization at the top of this record. The product name the console shows is a different setting, set in Settings → Branding."
532532
},
533533
slug: {
534534
label: "Slug",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
528528
fields: {
529529
name: {
530530
label: "Nombre",
531-
help: "El nombre propio de la organización. El nombre del producto que muestra la consola es otro ajuste, que se define en Configuración → Marca."
531+
help: "El nombre propio de la organización. Se cambia con Renombrar organización, en la parte superior de este registro. El nombre del producto que muestra la consola es otro ajuste, que se define en Configuración → Marca."
532532
},
533533
slug: {
534534
label: "Slug",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
528528
fields: {
529529
name: {
530530
label: "名前",
531-
help: "組織自体の名前。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。"
531+
help: "組織自体の名前。このレコード上部の「組織名を変更」で変更できます。コンソールに表示される製品名は別の設定で、設定 → ブランディング で設定します。"
532532
},
533533
slug: {
534534
label: "スラッグ",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
528528
fields: {
529529
name: {
530530
label: "名称",
531-
help: "组织自身的名称。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。"
531+
help: "组织自身的名称。可在此记录顶部用“重命名组织”修改。控制台显示的产品名称是另一项设置,在 设置 → 品牌 中设置。"
532532
},
533533
slug: {
534534
label: "标识符",

‎packages/platform-objects/src/identity/sys-organization-name-timezone-help.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
//
77
// - `name` is the organization's own name. The product name the console shows
88
// is Settings → Branding's `workspace_name`, a separate setting, and the help
9-
// says so. It names no door: the rename doors are multi-organization only.
9+
// says so. It names the door that changes it, Rename Organization, which is
10+
// offered under `single` too (ruling 6384629949; the gate is pinned in
11+
// `sys-organization-rename-action.test.ts`).
1012
// - `timezone` is read by nothing in the platform (see its declaration). The
1113
// zone in effect is the settings door's `localization.timezone`, and the help
1214
// says so instead of claiming date boundaries are computed in this column.
@@ -24,6 +26,12 @@ describe('sys_organization: the name and timezone fields say which value is whic
2426
expect(field('name').description).toMatch(/different setting, set in Settings → Branding/);
2527
});
2628

29+
it('the name names the door that changes it, the action labelled Rename Organization', () => {
30+
expect(field('name').description).toMatch(/Change it with Rename Organization/);
31+
const rename = (SysOrganization.actions ?? []).find((a) => a.name === 'rename_organization');
32+
expect(rename?.label).toBe('Rename Organization');
33+
});
34+
2735
it("the timezone says it changes no date calculation and that Localization's Default timezone wins", () => {
2836
const help = field('timezone').description ?? '';
2937
expect(help).toMatch(/does not change any date calculation/);

‎packages/platform-objects/src/identity/sys-organization-rename-action.test.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
// - the new action's declaration: its params are exactly `name` and `logo`,
1414
// it sits at `record_header` and `list_item`, it targets better-auth's
1515
// `organization/update` exactly as `update_organization` does, and it is
16-
// gated like it: on `multiOrgEnabled` and on the grades that reach
17-
// `organization/update` (owner, admin);
16+
// gated on the grades that reach `organization/update` (owner, admin) like
17+
// it, but on the organization capability rather than `multiOrgEnabled`, so
18+
// a `single` deployment can rename its one organization (#25347);
1819
// - one door per field on the record header: no `record_header` action that
1920
// targets `organization/update` carries a `slug` param;
2021
// - the header order: the rename takes an inline slot and leave folds into
@@ -102,10 +103,11 @@ describe('sys_organization.rename_organization: the declaration', () => {
102103
.toEqual({ type: update.type, target: update.target, method: update.method, recordIdParam: update.recordIdParam, bodyShape: update.bodyShape });
103104
});
104105

105-
it('is gated on the update reach and multiOrgEnabled, the same lowered predicate as update_organization', () => {
106-
const gate = `(${membershipReachPredicate('update_organization')}) && ${featureGatePredicate('multiOrgEnabled')}`;
107-
expect(action('rename_organization').visible?.source).toBe(gate);
108-
expect(action('update_organization').visible?.source).toBe(gate);
106+
it('is gated on the update reach like update_organization, but on the organization capability, not multiOrgEnabled', () => {
107+
const reach = `(${membershipReachPredicate('update_organization')})`;
108+
expect(action('rename_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('organization')}`);
109+
// CONTROL: the slug door stays multi-organization only.
110+
expect(action('update_organization').visible?.source).toBe(`${reach} && ${featureGatePredicate('multiOrgEnabled')}`);
109111
});
110112
});
111113

‎packages/platform-objects/src/identity/sys-organization.object.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,13 @@ export const SysOrganization = ObjectSchema.create({
245245
target: '/api/v1/auth/organization/update',
246246
recordIdParam: 'organizationId',
247247
bodyShape: { wrap: 'data' },
248-
requiresFeature: 'multiOrgEnabled',
248+
// [#25347, ruling 6384629949] Gated on the organization CAPABILITY, not
249+
// on `multiOrgEnabled`: a `single` deployment has one organization and
250+
// this is its only name door (the generic edit keeps `name` readonly,
251+
// ADR-0092 D4). Renaming it changes nothing about tenancy, so it is not
252+
// a multi-organization action; `update_organization`, which also sends
253+
// the slug, stays multi-organization only.
254+
requiresFeature: 'organization',
249255
// The same door as `update_organization`, so the same grades.
250256
requiresMembershipReach: 'update_organization',
251257
successMessage: 'Organization renamed',
@@ -299,16 +305,18 @@ export const SysOrganization = ObjectSchema.create({
299305
// better-auth's adapter (which stamps `isSystem`) still writes them.
300306
// [#25347] The organization's one name, and its help says what it is not:
301307
// the product name the console shows is Settings → Branding's
302-
// `workspace_name`, a separate home. It names no door: the rename doors are
303-
// multi-organization only, so under `single` none is offered.
308+
// `workspace_name`, a separate home. It names the door that changes it,
309+
// `rename_organization`, offered under every posture with organizations
310+
// (`single` included, ruling 6384629949).
304311
name: Field.text({
305312
label: 'Name',
306313
required: true,
307314
readonly: true,
308315
searchable: true,
309316
maxLength: 255,
310317
description:
311-
'The organization\'s own name. The product name the console shows is a different setting, set in Settings → Branding.',
318+
'The organization\'s own name. Change it with Rename Organization at the top of this record. ' +
319+
'The product name the console shows is a different setting, set in Settings → Branding.',
312320
group: 'Identity',
313321
}),
314322

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,9 @@ describe('feature-gate lowering matrix (#2874)', () => {
699699
// Their doors are better-auth's `organization/update` (owner, admin) and
700700
// `organization/delete` (owner), so the grade term leads the feature gate.
701701
['SysOrganization', SysOrganization, 'update_organization', `${ADMINS} && ${MULTI_ORG}`],
702-
['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${MULTI_ORG}`],
702+
// [#25347] The name/logo-only rename is on the capability: under `single`
703+
// it is the one organization's only name door.
704+
['SysOrganization', SysOrganization, 'rename_organization', `${ADMINS} && ${ORG}`],
703705
['SysOrganization', SysOrganization, 'delete_organization', `(${OWNER}) && ${MULTI_ORG}`],
704706
['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG],
705707
['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG],
@@ -762,15 +764,19 @@ describe('feature-gate lowering matrix (#2874)', () => {
762764
expect((action?.visible as { source?: string })?.source).toBe(expected);
763765
});
764766

765-
// The rows above are the whole multi-org surface of sys_organization: an
766-
// action gated on `multiOrgEnabled` without a row here is one this matrix
767-
// never pinned. `change_slug` is not among them — its door is the cloud
768-
// orchestrator's, and the composition that mounts it declares the action.
767+
// The multi-org rows above are the whole multi-org surface of
768+
// sys_organization: an action gated on `multiOrgEnabled` without a row here
769+
// is one this matrix never pinned. `change_slug` is not among them — its door
770+
// is the cloud orchestrator's, and the composition that mounts it declares
771+
// the action. `rename_organization` has a row on the capability gate instead
772+
// (#25347), so the pinned side reads the rows' gate too.
769773
it('the SysOrganization rows are every action it gates on multiOrgEnabled, and change_slug is none of them', () => {
770774
const gated = (SysOrganization.actions ?? [])
771775
.filter((a) => ((a.visible as { source?: string } | undefined)?.source ?? '').includes('features.multiOrgEnabled'))
772776
.map((a) => a.name);
773-
const pinned = rows.filter(([exported]) => exported === 'SysOrganization').map(([, , name]) => name);
777+
const pinned = rows
778+
.filter(([exported, , , expected]) => exported === 'SysOrganization' && expected.includes(MULTI_ORG))
779+
.map(([, , name]) => name);
774780
expect([...gated].sort()).toEqual([...pinned].sort());
775781
expect(gated).not.toContain('change_slug');
776782
});

0 commit comments

Comments
 (0)