Skip to content
21 changes: 21 additions & 0 deletions .changeset/24318-platform-objects-change-slug-to-cloud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/platform-objects': minor
---

fix(platform-objects)!: `sys_organization` no longer declares `change_slug`, whose door only the cloud orchestrator mounts (#24318)

Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing)

`sys_organization` declared a `change_slug` action ("Change Slug") at `record_header`, gated on `multiOrgEnabled`, whose target, `/api/v1/cloud/organizations/{id}/change-slug`, no package in this repository serves. So on every open-source composition with multi-organization on, the organization record page offered Change Slug in its `⋯` overflow, and the click answered `404 ENDPOINT_NOT_FOUND`. Measured on a booted multi-organization showcase.

The action now belongs to the composition that serves its route. That composition declares `change_slug` as a top-level action bound by `objectName: 'sys_organization'`, and the registry attaches such an action to the served object, whichever package registers first.

**BREAKING — what moves for consumers.** A declared action leaves a published platform object, shipped as `minor` under the launch-window convention for breaking changes (pre mode was exited at `c46b5f14cf`, so the launch-window guard is armed and a breaking change ships as `minor`).

- FROM: `GET /api/v1/meta/object/sys_organization` served `change_slug` on every composition, and the four generated locale bundles carried its `label`, `description` and `successMessage`.
- TO: the object serves it only where a composition binds it. The open-source record header offers Invite User, Set Active and Rename Organization inline, and Leave Organization in the overflow; each of them is a better-auth door this composition mounts.
- Fix: cloud declares its own `change_slug`, the same declaration bound by `objectName: 'sys_organization'`, with its own translations. An open-source composition changes an organization's slug through `update_organization` (better-auth's `organization/update`, list row), as before.

**Unchanged.** `rename_organization` (name and logo, no slug) and `update_organization` (name, slug and logo, list row only) keep their declarations, gates and order. No stored row moves.

<!-- adr-0087: not-required (no-migration-prescription) a platform object stops declaring one action whose door exists only in another composition: no authorable spec key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. The repair is the serving composition's own declaration of the action, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this declaration (not already-registered); and the change is an object's declared action, not a runtime interface, a type surface or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
19 changes: 19 additions & 0 deletions .changeset/24318-spec-change-slug-gated-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/spec': minor
---

fix(spec)!: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug` (#24318)

Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing)

`gatedInputs` is the census of platform-object inputs gated on a public auth feature, which the platform objects' completeness guard holds equal to their declarations in both directions. `sys_organization` no longer declares `change_slug` (its door is the cloud orchestrator's, and the composition that serves it declares the action), so its row leaves the census.

**BREAKING — what moves for consumers.** One string leaves a published constant, shipped as `minor` under the launch-window convention for breaking changes.

- FROM: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` listed seven `sys_organization` actions, `'sys_organization.actions.change_slug'` among them.
- TO: it lists six: `create_organization`, `update_organization`, `rename_organization`, `delete_organization`, `set_active_organization` and `leave_organization`.
- Fix: none for an action declared outside the platform objects. The census is not a gate on other packages: a top-level action bound to `sys_organization` that declares `requiresFeature: 'multiOrgEnabled'` is lowered to the same `features.multiOrgEnabled != false` predicate and served with it, measured on a booted composition.

**Unchanged.** The `multiOrgEnabled` key, its surface (`crud`) and its semantics (`default-on`); every other feature's row. The constant is declared `as const`, so its literal type loses the one tuple member too.

<!-- adr-0087: not-required (no-migration-prescription) one path string leaves a published census constant that only the platform objects' own completeness guard reads: no authorable spec key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this row (not already-registered); and the export stays, its declared entry type unchanged; the `as const` literal tuple loses one member, which nothing in this repository names (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #7309 — translation-carryover guard for the 14 confirm questions moved from
* `confirmText` to `description`.
* #7309 — translation-carryover guard for the confirm questions moved from
* `confirmText` to `description` (14 then, 13 the platform still declares).
*
* The wording did not change; the KEY did. `os i18n extract` treats a renamed
* key as a brand-new gap, and this repo's `i18n:extract` script runs with
Expand Down Expand Up @@ -32,7 +32,12 @@ const LOCALES = [
['es-ES', esESObjects],
] as const;

/** The 14 (object, action) pairs #7309 converted. */
/**
* The (object, action) pairs #7309 converted that this package still
* declares: 13 of its 14. `sys_organization.change_slug` left the platform
* object (its door is the cloud orchestrator's, and the composition that
* mounts it declares it), so its strings left these bundles with it.
*/
const PAIRS: readonly (readonly [string, string])[] = [
['sys_user', 'ban_user'],
['sys_user', 'delete_my_account'],
Expand All @@ -41,7 +46,6 @@ const PAIRS: readonly (readonly [string, string])[] = [
['sys_two_factor', 'disable_two_factor'],
['sys_two_factor', 'regenerate_backup_codes'],
['sys_account', 'unlink_account'],
['sys_organization', 'change_slug'],
['sys_team_member', 'remove_team_member'],
['sys_oauth_application', 'enable_oauth_application'],
['sys_oauth_application', 'disable_oauth_application'],
Expand Down Expand Up @@ -78,6 +82,15 @@ describe('#7309 — the moved confirm questions stay translated', () => {
}
});

it('the pinned set is 13 pairs, and no locale still carries sys_organization.change_slug', () => {
expect(PAIRS).toHaveLength(13);
for (const [locale, bundle] of [['en', enObjects] as const, ...LOCALES]) {
expect(node(bundle, 'sys_organization', 'change_slug'), `${locale} sys_organization.change_slug`).toBeUndefined();
// The control: the sibling actions of the same object are still there.
expect(node(bundle, 'sys_organization', 'rename_organization'), `${locale} sys_organization.rename_organization`).toBeDefined();
}
});

it('no locale left the retired `confirmText` behind on these actions', () => {
for (const [locale, bundle] of LOCALES) {
for (const [obj, act] of PAIRS) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,11 +605,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
label: "Rename Organization",
successMessage: "Organization renamed"
},
change_slug: {
label: "Change Slug",
description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?",
successMessage: "Organization slug changed"
},
leave_organization: {
label: "Leave Organization",
confirmText: "Leave this organization? You will lose access to its environments and members.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,11 +605,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
label: "Renombrar organización",
successMessage: "Organización renombrada"
},
change_slug: {
label: "Cambiar slug",
description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?",
successMessage: "Slug de la organización cambiado"
},
leave_organization: {
label: "Abandonar organización",
confirmText: "¿Abandonar esta organización? Perderá el acceso a sus entornos y miembros.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,11 +605,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "組織名を変更",
successMessage: "組織名を変更しました"
},
change_slug: {
label: "スラッグを変更",
description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?",
successMessage: "組織のスラッグを変更しました"
},
leave_organization: {
label: "組織から退出",
confirmText: "この組織から退出しますか?この組織の環境とメンバーへのアクセスを失います。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,11 +605,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
label: "重命名组织",
successMessage: "组织已重命名"
},
change_slug: {
label: "修改标识符",
description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?",
successMessage: "组织标识符已修改"
},
leave_organization: {
label: "退出组织",
confirmText: "要退出该组织吗?退出后你将失去对其环境和成员的访问权限。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ const byName = (obj: any, name: string) => {
return a;
};

/** The 14 sites #7309 converted: object → action names. */
/**
* The sites #7309 converted that this package still declares: object → action
* names. #7309 converted 14; the fifteenth row it carried,
* `sys_organization.change_slug`, left the platform object, because its door
* is the cloud orchestrator's and the composition that mounts it declares it.
*/
const CONVERTED: Record<string, string[]> = {
sys_user: ['ban_user', 'delete_my_account', 'disable_two_factor', 'generate_backup_codes'],
sys_oauth_application: [
Expand All @@ -63,7 +68,6 @@ const CONVERTED: Record<string, string[]> = {
],
sys_two_factor: ['disable_two_factor', 'regenerate_backup_codes'],
sys_account: ['unlink_account'],
sys_organization: ['change_slug'],
sys_sso_provider: ['delete_sso_provider'],
sys_team_member: ['remove_team_member'],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
//
// `update_organization` renames too, but it is declared at `list_item` only (a
// record page's header never offers it) and it bundles `slug`, which the cloud
// orchestrator's `change_slug` governs where it exists. So the record page
// needs its own door for `name` and `logo`, one that sends no slug.
// orchestrator's slug action governs where it exists — an action the
// composition serving that route declares itself, never this object. So the
// record page needs its own door for `name` and `logo`, one that sends no slug.
//
// What this file pins, from source:
// - the new action's declaration: its params are exactly `name` and `logo`,
Expand All @@ -16,10 +17,12 @@
// `organization/update` (owner, admin);
// - one door per field on the record header: no `record_header` action that
// targets `organization/update` carries a `slug` param;
// - the header order: the rename takes an inline slot and `change_slug` folds
// into the overflow (the ordering model is described in
// - the header order: the rename takes an inline slot and leave folds into
// the overflow (the ordering model is described in
// `sys-organization-invite-action.test.ts`);
// - controls: `update_organization` and `change_slug` are unchanged.
// - every door this object declares is better-auth's: no action targets the
// cloud orchestrator, whose route no package in this repository serves;
// - control: `update_organization`, the open-source slug door, is unchanged.
//
// The runtime half, a rename through better-auth that reads the slug back
// unchanged, is `organization-rename-door.dogfood.test.ts` in
Expand Down Expand Up @@ -117,29 +120,35 @@ describe('sys_organization: one door per field on the record header', () => {
}
});

it('the rename takes an inline header slot and change_slug folds into the overflow', () => {
it('the rename takes an inline header slot and leave_organization folds into the overflow', () => {
const order = headerOrder();
const names = actions().map((a) => a.name);
expect(names.indexOf('rename_organization')).toBeLessThan(names.indexOf('change_slug'));
expect(order.indexOf('rename_organization')).toBeGreaterThanOrEqual(0);
expect(order).toEqual(['invite_user', 'set_active_organization', 'rename_organization', 'leave_organization']);
expect(order.indexOf('rename_organization')).toBeLessThan(MAX_VISIBLE);
expect(order.indexOf('change_slug')).toBeGreaterThanOrEqual(MAX_VISIBLE);
expect(order[order.length - 1]).toBe('leave_organization');
expect(order.indexOf('leave_organization')).toBeGreaterThanOrEqual(MAX_VISIBLE);
});
});

describe('sys_organization: the slug doors are unchanged (controls)', () => {
describe('sys_organization: every door it declares is one an open-source composition mounts', () => {
it("every api action targets better-auth's organization routes, and none the cloud orchestrator", () => {
const api = actions().filter((a) => a.type === 'api');
expect(api.map((a) => a.name).sort()).toEqual([
'create_organization', 'delete_organization', 'invite_user', 'leave_organization',
'rename_organization', 'set_active_organization', 'update_organization',
]);
for (const a of api) {
expect(a.target, a.name).toMatch(/^\/api\/v1\/auth\/organization\/[a-z-]+$/);
}
// The slug action the cloud orchestrator serves is declared by the
// composition that mounts its route, bound here by `objectName`.
expect(actions().map((a) => a.name)).not.toContain('change_slug');
});
});

describe('sys_organization: the open-source slug door is unchanged (control)', () => {
it('update_organization keeps name / slug / logo, its target and list_item alone', () => {
const update = action('update_organization');
expect(update.locations).toEqual(['list_item']);
expect(update.target).toBe(UPDATE_TARGET);
expect(paramKeys(update)).toEqual(['name', 'slug', 'logo']);
});

it("change_slug keeps its cloud orchestrator target, record_header alone and its one slug param", () => {
const change = action('change_slug');
expect(change.locations).toEqual(['record_header']);
expect(change.target).toBe('/api/v1/cloud/organizations/{id}/change-slug');
expect(paramKeys(change)).toEqual(['slug']);
});
});
52 changes: 10 additions & 42 deletions packages/platform-objects/src/identity/sys-organization.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,9 @@ export const SysOrganization = ObjectSchema.create({
// `invite_user` leading, the header's three inline slots go to the
// invite, `set_active_organization` and `rename_organization`, and this
// one folds into the `⋯` overflow's destructive band (objectui's
// page:header split, default `maxVisible` 3), with `change_slug` in the
// overflow above it. Without it, registration order kept it inline as
// the third button. Ungated by grade: every grade may leave.
// page:header split, default `maxVisible` 3). Without it, registration
// order kept it inline as the third button. Ungated by grade: every
// grade may leave.
order: 1,
mode: 'custom',
locations: ['list_item', 'record_header'],
Expand All @@ -226,15 +226,13 @@ export const SysOrganization = ObjectSchema.create({
// `beforeUpdateOrganization` slug guard both run only on a sent slug.
//
// One door per field. A record page reaches no `list_item` action, so
// this one is declared at `record_header` too; where the cloud
// orchestrator governs the slug, that page offers this action and
// `change_slug`, and nothing on it sends a slug to better-auth.
// `update_organization` stays the slug door of a composition without the
// orchestrator, because `change_slug`'s target exists only in cloud.
//
// Declared BEFORE `change_slug`: both sort at `order` 0, so declaration
// order breaks the tie, the rename takes the header's third inline slot,
// and `change_slug` folds into the `⋯` overflow.
// this one is declared at `record_header` too, and nothing on that page
// sends a slug to better-auth. `update_organization` is the slug door of
// a composition without the cloud orchestrator. Where the orchestrator
// governs the slug, the composition that serves its route declares the
// slug action itself, bound to this object by `objectName` and attached
// to it as served; this object declares no action whose door only one
// composition mounts.
name: 'rename_organization',
label: 'Rename Organization',
icon: 'pencil',
Expand All @@ -255,36 +253,6 @@ export const SysOrganization = ObjectSchema.create({
{ field: 'logo', defaultFromRow: true },
],
},
{
// Rename the organization slug (URL prefix). Backed by the cloud
// orchestrator at /api/v1/cloud/organizations/{id}/change-slug,
// which atomically updates sys_organization.slug, rewrites
// platform_subdomain sys_domain rows under the new slug, soft-
// retires the old rows with a redirect window, parks the old
// slug in sys_slug_reservation, and refreshes the registry
// mirror. See cloud `docs/design/sys-domain.md` §6.
name: 'change_slug',
label: 'Change Slug',
icon: 'edit-3',
variant: 'secondary',
mode: 'custom',
locations: ['record_header'],
type: 'api',
target: '/api/v1/cloud/organizations/{id}/change-slug',
method: 'POST',
requiresFeature: 'multiOrgEnabled',
// Confirm question on `description`, not `confirmText`: this action collects
// params, and pairing the two keys opens two dialogs for one decision
// (#7278 ruling 2026-08-10, swept by #7309). The two param-LESS actions
// above (`delete_organization`, `leave_organization`) keep `confirmText`
// — with no param dialog, the confirm IS the only dialog.
description: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?',
successMessage: 'Organization slug changed',
refreshAfter: true,
params: [
{ field: 'slug', required: true, defaultFromRow: true },
],
},
],

listViews: {
Expand Down
Loading
Loading