diff --git a/.changeset/20059-identity-objects-title-formula.md b/.changeset/20059-identity-objects-title-formula.md new file mode 100644 index 00000000000..cf718454c3d --- /dev/null +++ b/.changeset/20059-identity-objects-title-formula.md @@ -0,0 +1,27 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): ten identity objects declare their record title instead of having `nameField: 'id'` stamped on them (#20059) + +Clause-②: no + +ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation, and an explicit `nameField` takes precedence over the render-only `titleFormat`. Ten identity objects declared a `titleFormat` and no title pointer. The registry's designate-only pass derived `id`, the first title-eligible field on each of them, and stamped `nameField: 'id'`, and a `/meta` read serves that stamp as if it were declared. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title. + +Nine of them now declare `display_title`, a formula field with `returnType: 'text'` over the columns their `titleFormat` names, and point `nameField` and `displayNameField` at it: + +- `sys_account`: `{provider_id} - {account_id}`; +- `sys_business_unit_member`: `{user_id} in {business_unit_id}`; +- `sys_invitation`: `Invitation for {email}`; +- `sys_member`: `{user_id} ({role})`. A row without a role is titled by its user alone; +- `sys_scim_group_member`: `{scim_user_id} in {group_id}`; +- `sys_scim_projection_grant`: `{role} → {user_id}`; +- `sys_team_member`: `{user_id} in {team_id}`; +- `sys_two_factor`: `Two-factor for {user_id}`; +- `sys_verification`: `Verification for {identifier}`. + +This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". `sys_scim_subject` has a single-field title (`{user_id}`), so its `nameField` and `displayNameField` name `user_id` directly, as the same text prescribes for a single field. + +A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads now carry `display_title` on the nine objects. A formula is evaluated on the stored row, so where a `titleFormat` names a lookup (`user_id`, `team_id`, `business_unit_id`, `group_id`, `scim_user_id`), the formula's text carries the stored id of the related record, not its name. + +`titleFormat` stays on all ten objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: neither a formula field, a lookup nor `id` is ever a search target. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index e5ef3ada4fb..3ecc03566b9 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -407,6 +407,10 @@ export const enObjects: NonNullable = { id: { label: "Account ID" }, + display_title: { + label: "Title", + help: "Record title: the provider and the account id it issued (computed on read)" + }, created_at: { label: "Created At" }, @@ -499,6 +503,10 @@ export const enObjects: NonNullable = { id: { label: "Verification ID" }, + display_title: { + label: "Title", + help: "Record title: the identifier being verified (computed on read)" + }, created_at: { label: "Created At" }, @@ -606,6 +614,10 @@ export const enObjects: NonNullable = { id: { label: "Member ID" }, + display_title: { + label: "Title", + help: "Record title: the member and, when recorded, their role (computed on read)" + }, created_at: { label: "Created At" }, @@ -668,6 +680,10 @@ export const enObjects: NonNullable = { id: { label: "Invitation ID" }, + display_title: { + label: "Title", + help: "Record title: the invited email address (computed on read)" + }, created_at: { label: "Created At" }, @@ -815,6 +831,10 @@ export const enObjects: NonNullable = { id: { label: "Team Member ID" }, + display_title: { + label: "Title", + help: "Record title: the user and the team (computed on read)" + }, created_at: { label: "Created At" }, @@ -932,6 +952,10 @@ export const enObjects: NonNullable = { id: { label: "Member ID" }, + display_title: { + label: "Title", + help: "Record title: the user and the business unit they are assigned to (computed on read)" + }, business_unit_id: { label: "Business Unit" }, @@ -1049,6 +1073,10 @@ export const enObjects: NonNullable = { id: { label: "Two Factor ID" }, + display_title: { + label: "Title", + help: "Record title: the user the credential belongs to (computed on read)" + }, created_at: { label: "Created At" }, @@ -2091,6 +2119,10 @@ export const enObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "Title", + help: "Record title: the provisioned user and the group (computed on read)" + }, connection_id: { label: "Connection ID" }, @@ -2162,6 +2194,10 @@ export const enObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "Title", + help: "Record title: the projected role and the user it is granted to (computed on read)" + }, connection_id: { label: "Connection ID" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 4a5048bb437..23b9117eb8b 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -407,6 +407,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de cuenta de autenticación" }, + display_title: { + label: "Título", + help: "Título del registro: el proveedor y el ID de cuenta que emitió (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -499,6 +503,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de verificación" }, + display_title: { + label: "Título", + help: "Título del registro: el identificador que se verifica (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -606,6 +614,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de miembro" }, + display_title: { + label: "Título", + help: "Título del registro: el miembro y, si consta, su rol (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -668,6 +680,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de invitación" }, + display_title: { + label: "Título", + help: "Título del registro: la dirección de correo electrónico invitada (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -815,6 +831,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de miembro del equipo" }, + display_title: { + label: "Título", + help: "Título del registro: el usuario y el equipo (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -932,6 +952,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de miembro" }, + display_title: { + label: "Título", + help: "Título del registro: el usuario y la unidad de negocio a la que está asignado (calculado al leer)" + }, business_unit_id: { label: "Unidad de negocio" }, @@ -1049,6 +1073,10 @@ export const esESObjects: NonNullable = { id: { label: "ID de doble factor" }, + display_title: { + label: "Título", + help: "Título del registro: el usuario al que pertenece la credencial (calculado al leer)" + }, created_at: { label: "Creado el" }, @@ -2091,6 +2119,10 @@ export const esESObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "Título", + help: "Título del registro: el usuario aprovisionado y el grupo (calculado al leer)" + }, connection_id: { label: "Connection ID" }, @@ -2162,6 +2194,10 @@ export const esESObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "Título", + help: "Título del registro: el rol proyectado y el usuario al que se concede (calculado al leer)" + }, connection_id: { label: "Connection ID" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 07a31d766d9..375e8e0ec97 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -407,6 +407,10 @@ export const jaJPObjects: NonNullable = { id: { label: "アカウント ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:プロバイダーと、そのプロバイダーが発行したアカウント ID(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -499,6 +503,10 @@ export const jaJPObjects: NonNullable = { id: { label: "検証 ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:検証対象の識別子(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -606,6 +614,10 @@ export const jaJPObjects: NonNullable = { id: { label: "メンバー ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:メンバーと、記録されている場合はそのロール(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -668,6 +680,10 @@ export const jaJPObjects: NonNullable = { id: { label: "招待 ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:招待先のメールアドレス(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -815,6 +831,10 @@ export const jaJPObjects: NonNullable = { id: { label: "チームメンバー ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:ユーザーとチーム(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -932,6 +952,10 @@ export const jaJPObjects: NonNullable = { id: { label: "メンバー ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:ユーザーと、その割り当て先のビジネスユニット(読み取り時に計算)" + }, business_unit_id: { label: "ビジネスユニット" }, @@ -1049,6 +1073,10 @@ export const jaJPObjects: NonNullable = { id: { label: "二要素認証 ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:この認証情報を所有するユーザー(読み取り時に計算)" + }, created_at: { label: "作成日時" }, @@ -2091,6 +2119,10 @@ export const jaJPObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:プロビジョニングされたユーザーとグループ(読み取り時に計算)" + }, connection_id: { label: "Connection ID" }, @@ -2162,6 +2194,10 @@ export const jaJPObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "タイトル", + help: "レコードタイトル:投影されたロールと、その付与先のユーザー(読み取り時に計算)" + }, connection_id: { label: "Connection ID" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index affacc6082d..b43770758d8 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -407,6 +407,10 @@ export const zhCNObjects: NonNullable = { id: { label: "身份链接 ID" }, + display_title: { + label: "标题", + help: "记录标题:提供方及其签发的账号 ID(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -499,6 +503,10 @@ export const zhCNObjects: NonNullable = { id: { label: "验证记录 ID" }, + display_title: { + label: "标题", + help: "记录标题:正在验证的标识符(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -606,6 +614,10 @@ export const zhCNObjects: NonNullable = { id: { label: "成员 ID" }, + display_title: { + label: "标题", + help: "记录标题:成员,以及已记录时的角色(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -668,6 +680,10 @@ export const zhCNObjects: NonNullable = { id: { label: "邀请 ID" }, + display_title: { + label: "标题", + help: "记录标题:受邀的电子邮件地址(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -815,6 +831,10 @@ export const zhCNObjects: NonNullable = { id: { label: "团队成员 ID" }, + display_title: { + label: "标题", + help: "记录标题:用户及其所在团队(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -932,6 +952,10 @@ export const zhCNObjects: NonNullable = { id: { label: "成员 ID" }, + display_title: { + label: "标题", + help: "记录标题:用户及其被分配到的业务单元(读取时计算)" + }, business_unit_id: { label: "业务单元" }, @@ -1049,6 +1073,10 @@ export const zhCNObjects: NonNullable = { id: { label: "双因素认证 ID" }, + display_title: { + label: "标题", + help: "记录标题:该凭据所属的用户(读取时计算)" + }, created_at: { label: "创建时间" }, @@ -2091,6 +2119,10 @@ export const zhCNObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "标题", + help: "记录标题:预配的用户及其所在组(读取时计算)" + }, connection_id: { label: "Connection ID" }, @@ -2162,6 +2194,10 @@ export const zhCNObjects: NonNullable = { id: { label: "ID" }, + display_title: { + label: "标题", + help: "记录标题:投射的角色及被授予该角色的用户(读取时计算)" + }, connection_id: { label: "Connection ID" }, diff --git a/packages/platform-objects/src/identity/identity-display-title.test.ts b/packages/platform-objects/src/identity/identity-display-title.test.ts new file mode 100644 index 00000000000..e20769c7453 --- /dev/null +++ b/packages/platform-objects/src/identity/identity-display-title.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record title of the ten identity objects that declared a `titleFormat` + * and no title pointer (#20059). + * + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? + * derivation`, and an explicit `nameField` takes precedence over the + * render-only `titleFormat`. The registry's materialization seam runs + * `provisionPrimary(…, { synthesize: false })` over every base layer, and on + * these objects that designate-only pass derived `id` (the first + * title-eligible field) and stamped `nameField: 'id'`. A `/meta` read serves + * that stamp as if the author had declared it, so a renderer honouring + * ADR-0079's order drew the raw record id as the record page's H1. + * + * The `titleFormat` describe prescribes the migration: a single-field title + * moves to `nameField`, a composite becomes a text formula designated as + * `nameField`. This file asserts, per object: + * + * 1. the pointer the designate-only pass leaves on the SERVED body is the + * declared one (`display_title`, or `user_id` for the single-field + * `sys_scim_subject`), never `id`, with the `displayNameField` mirror; + * 2. `display_title` is a text formula, so it is title-eligible and has no + * stored column; + * 3. the formula, evaluated the way the engine's read path evaluates it, + * renders the text the `titleFormat` renders for a stored row, and that + * text is not the row's id; + * 4. where a source column is nullable (`sys_member.role`), a row without it + * is titled by the remaining column instead of failing to evaluate. + * + * The engine's read path (`applyFormulaPlan`, `@objectstack/objectql`) calls + * `ExpressionEngine.evaluate(expression, { now, …, record })` on the row the + * driver returned, and writes `null` when the result is not `ok`. That is the + * call below. A formula runs on the STORED row, before `$expand`, so a + * `titleFormat` placeholder naming a lookup renders the stored id in both. + */ + +import { describe, it, expect } from 'vitest'; +import { ExpressionEngine } from '@objectstack/formula'; +import { isTitleEligible, provisionPrimary, resolveDisplayField } from '@objectstack/spec/data'; +import { SysAccount } from './sys-account.object.js'; +import { SysBusinessUnitMember } from './sys-business-unit-member.object.js'; +import { SysInvitation } from './sys-invitation.object.js'; +import { SysMember } from './sys-member.object.js'; +import { SysScimGroupMember } from './sys-scim-group-member.object.js'; +import { SysScimProjectionGrant } from './sys-scim-projection-grant.object.js'; +import { SysScimSubject } from './sys-scim-subject.object.js'; +import { SysTeamMember } from './sys-team-member.object.js'; +import { SysTwoFactor } from './sys-two-factor.object.js'; +import { SysVerification } from './sys-verification.object.js'; + +type Row = Record; +type Schema = { name: string; nameField?: string; displayNameField?: string; titleFormat?: unknown; fields: Record }; + +/** + * The text a `titleFormat`-first renderer draws for a fully populated row: + * each `{field}` placeholder substituted with the row's value. It is the + * reference the formula has to reproduce, not a second title resolver. The + * parsed schema carries `titleFormat` as a `{ dialect: 'template', source }` + * envelope. + */ +function renderTitleFormat(titleFormat: unknown, row: Row): string { + const source = typeof titleFormat === 'string' ? titleFormat : (titleFormat as { source?: unknown })?.source; + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(titleFormat)}`); + return source.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? '')); +} + +/** `display_title` evaluated as the read path evaluates a formula field. */ +function evaluateDisplayTitle(schema: Schema, row: Row): unknown { + const field = schema.fields.display_title; + const expression = typeof field.expression === 'string' ? { dialect: 'cel' as const, source: field.expression } : field.expression; + const r = ExpressionEngine.evaluate(expression, { now: new Date(), record: row }); + return r.ok ? r.value : null; +} + +/** The pointer the registry's designate-only pass leaves on the served body. */ +function servedPointers(schema: Schema): { nameField?: string; displayNameField?: string } { + const served = provisionPrimary(schema as any, { synthesize: false }) as Schema; + return { nameField: served.nameField, displayNameField: served.displayNameField }; +} + +interface Case { + schema: Schema; + /** A stored row as the driver returns it: every column present, ids as stored. */ + row: Row; + /** The title the `titleFormat` renders for `row`. */ + title: string; +} + +const FORMULA_CASES: Case[] = [ + { + schema: SysAccount as unknown as Schema, + row: { id: 'acc_7Hq2', provider_id: 'github', account_id: '5812039', user_id: 'usr_Ab12' }, + title: 'github - 5812039', + }, + { + schema: SysBusinessUnitMember as unknown as Schema, + row: { id: 'bum_9Kz1', user_id: 'usr_Ab12', business_unit_id: 'bu_emea' }, + title: 'usr_Ab12 in bu_emea', + }, + { + schema: SysInvitation as unknown as Schema, + row: { id: 'inv_3Pw8', email: 'ada@example.com', role: 'member', organization_id: null }, + title: 'Invitation for ada@example.com', + }, + { + schema: SysMember as unknown as Schema, + row: { id: 'mem_5Tr4', user_id: 'usr_Ab12', role: 'admin', organization_id: null }, + title: 'usr_Ab12 (admin)', + }, + { + schema: SysScimGroupMember as unknown as Schema, + row: { id: 'sgm_2Lc6', scim_user_id: 'scu_Qx90', group_id: 'scg_ops' }, + title: 'scu_Qx90 in scg_ops', + }, + { + schema: SysScimProjectionGrant as unknown as Schema, + row: { id: 'spg_8Vn3', role: 'billing_admin', user_id: 'usr_Ab12' }, + title: 'billing_admin → usr_Ab12', + }, + { + schema: SysTeamMember as unknown as Schema, + row: { id: 'tmm_4Ds7', user_id: 'usr_Ab12', team_id: 'team_core' }, + title: 'usr_Ab12 in team_core', + }, + { + schema: SysTwoFactor as unknown as Schema, + row: { id: 'tfa_6Gm5', user_id: 'usr_Ab12' }, + title: 'Two-factor for usr_Ab12', + }, + { + schema: SysVerification as unknown as Schema, + row: { id: 'ver_1Jb9', identifier: 'ada@example.com', value: 'tok_redacted' }, + title: 'Verification for ada@example.com', + }, +]; + +describe('[#20059] identity objects resolve a real record title under ADR-0079 order', () => { + describe.each(FORMULA_CASES.map((c) => [c.schema.name, c] as const))('%s', (_name, c) => { + it('serves `display_title` as the title pointer, not the stamped `id`', () => { + expect(servedPointers(c.schema)).toEqual({ nameField: 'display_title', displayNameField: 'display_title' }); + expect(resolveDisplayField(c.schema as any)).toBe('display_title'); + }); + + it('declares `display_title` as a text formula (title-eligible, no stored column)', () => { + const field = c.schema.fields.display_title; + expect(field?.type).toBe('formula'); + expect(field?.returnType).toBe('text'); + expect(isTitleEligible(field)).toBe(true); + }); + + it('renders the `titleFormat` text for a stored row, never the id', () => { + const title = evaluateDisplayTitle(c.schema, c.row); + expect(title).toBe(c.title); + expect(title).toBe(renderTitleFormat(c.schema.titleFormat, c.row)); + expect(title).not.toBe(c.row.id); + expect(String(title)).not.toContain(String(c.row.id)); + }); + }); + + it('sys_member: a row without a role is titled by its user alone', () => { + const row = { id: 'mem_5Tr5', user_id: 'usr_Cd34', role: null, organization_id: null }; + expect(evaluateDisplayTitle(SysMember as unknown as Schema, row)).toBe('usr_Cd34'); + }); + + it('sys_scim_subject: the single-field title points at `user_id` directly', () => { + const schema = SysScimSubject as unknown as Schema; + expect(servedPointers(schema)).toEqual({ nameField: 'user_id', displayNameField: 'user_id' }); + expect(schema.fields.display_title).toBeUndefined(); + const row = { id: 'scs_0Wy2', user_id: 'usr_Ab12', revision: 3 }; + expect(row[resolveDisplayField(schema as any) as 'user_id']).toBe(renderTitleFormat(schema.titleFormat, row)); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 786dd6a956c..967a1142af9 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_account — System Account Object @@ -26,6 +27,15 @@ export const SysAccount = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'OAuth and authentication provider accounts', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{provider_id} - {account_id}', highlightFields: ['provider_id', 'user_id', 'account_id'], @@ -131,6 +141,16 @@ export const SysAccount = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Every source column is required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.provider_id + ' - ' + record.account_id`, + description: 'Record title: the provider and the account id it issued (computed on read)', + }), created_at: Field.datetime({ label: 'Created At', diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index abae0c3cc8d..ae751b9aa3e 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_business_unit_member — User ↔ Business Unit Assignment @@ -22,6 +23,18 @@ export const SysBusinessUnitMember = ObjectSchema.create({ isSystem: true, managedBy: 'platform', description: 'User assignment to a business unit (matrix-org friendly, effective-dated).', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `user_id` and `business_unit_id` are lookups, so the formula reads their stored + // ids: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach a related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} in {business_unit_id}', highlightFields: ['user_id', 'business_unit_id', 'function_in_business_unit', 'is_primary'], @@ -33,6 +46,17 @@ export const SysBusinessUnitMember = ObjectSchema.create({ group: 'System', }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Every source column is required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' in ' + record.business_unit_id`, + description: 'Record title: the user and the business unit they are assigned to (computed on read)', + group: 'Assignment', + }), + business_unit_id: Field.lookup('sys_business_unit', { label: 'Business Unit', required: true, diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 1e960029887..fe566eca39b 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, InvitationStatus, @@ -31,6 +32,15 @@ export const SysInvitation = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Organization invitations for user onboarding', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) // Title by invitee email rather than organization_id: the latter is null in // single-org mode (renders "Invitation to null"), and the recipient email is // the more useful identifier in both modes anyway. @@ -190,6 +200,16 @@ export const SysInvitation = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. `email` is required, so the expression + // needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`'Invitation for ' + record.email`, + description: 'Record title: the invited email address (computed on read)', + }), created_at: Field.datetime({ label: 'Created At', diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index eb4acfaa0cb..4d97d3afa11 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS, MEMBERSHIP_ROLE_MEMBER } from '@objectstack/spec/identity'; /** @@ -27,6 +28,18 @@ export const SysMember = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Organization membership records', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `user_id` is a lookup, so the formula reads its stored id: a formula + // is evaluated on the stored row, before `$expand`, and cannot reach the + // related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) // Org-independent title: organization_id is null in single-org mode, so a // '{user_id} in {organization_id}' format renders "… in null". User + role // identifies the membership in both single- and multi-org deployments. @@ -234,6 +247,16 @@ export const SysMember = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. `role` is nullable, so a row without one + // is titled by its user alone rather than failing to evaluate. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.role != null ? record.user_id + ' (' + record.role + ')' : record.user_id`, + description: 'Record title: the member and, when recorded, their role (computed on read)', + }), created_at: Field.datetime({ label: 'Created At', diff --git a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts index d3416505ac7..cd6c05582be 100644 --- a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_scim_group_member — SCIM group membership junction @@ -29,6 +30,18 @@ export const SysScimGroupMember = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'SCIM group membership rows pushed by the IdP (group ↔ provisioned user)', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `scim_user_id` and `group_id` are lookups, so the formula reads their stored + // ids: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach a related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{scim_user_id} in {group_id}', highlightFields: ['group_id', 'scim_user_id', 'created_at'], @@ -47,6 +60,17 @@ export const SysScimGroupMember = ObjectSchema.create({ fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Every source column is required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.scim_user_id + ' in ' + record.group_id`, + description: 'Record title: the provisioned user and the group (computed on read)', + group: 'Membership', + }), + connection_id: Field.text({ label: 'Connection ID', required: true, diff --git a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts index 8aec1bffa5d..6bbbce830da 100644 --- a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_scim_projection_grant — Role/entitlement grants projected from SCIM @@ -30,6 +31,18 @@ export const SysScimProjectionGrant = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Role/entitlement grants projected onto platform users by SCIM provisioning', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `user_id` is a lookup, so the formula reads its stored id: a formula + // is evaluated on the stored row, before `$expand`, and cannot reach the + // related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{role} → {user_id}', highlightFields: ['role', 'source_kind', 'user_id', 'connection_id'], @@ -48,6 +61,17 @@ export const SysScimProjectionGrant = ObjectSchema.create({ fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Every source column is required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.role + ' → ' + record.user_id`, + description: 'Record title: the projected role and the user it is granted to (computed on read)', + group: 'Grant', + }), + connection_id: Field.text({ label: 'Connection ID', required: true, diff --git a/packages/platform-objects/src/identity/sys-scim-subject.object.ts b/packages/platform-objects/src/identity/sys-scim-subject.object.ts index 9954d25bd9a..d0cfbc29ddc 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -30,6 +30,14 @@ export const SysScimSubject = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Per-user SCIM provisioning link — one row per user any SCIM connection provisions', + // [ADR-0079] Single-field title: `titleFormat` names one field, so the + // pointer names that field directly, as the `titleFormat` describe + // prescribes (the same shape as `sys_session`). With no pointer declared, + // the registry's designate-only pass stamped `nameField: 'id'`, so a + // renderer honouring ADR-0079's order drew the raw id as the H1. + // `identity-display-title.test.ts` pins the pointer. + displayNameField: 'user_id', + nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{user_id}', highlightFields: ['user_id', 'profile_source_id', 'updated_at'], diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index e13557c46e4..b3fc25d5c76 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_team_member — System Team Member Object @@ -26,6 +27,18 @@ export const SysTeamMember = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Team membership records linking users to teams', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `user_id` and `team_id` are lookups, so the formula reads their stored + // ids: a formula is evaluated on the stored row, before `$expand`, and + // cannot reach a related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{user_id} in {team_id}', highlightFields: ['user_id', 'team_id', 'created_at'], @@ -86,6 +99,16 @@ export const SysTeamMember = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. Every source column is required, so the + // expression needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`record.user_id + ' in ' + record.team_id`, + description: 'Record title: the user and the team (computed on read)', + }), created_at: Field.datetime({ label: 'Created At', diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index c135c830930..5842a8cbd6c 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_two_factor — System Two-Factor Object @@ -26,6 +27,18 @@ export const SysTwoFactor = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Two-factor authentication credentials', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + // `user_id` is a lookup, so the formula reads its stored id: a formula + // is evaluated on the stored row, before `$expand`, and cannot reach the + // related record's own title. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: 'Two-factor for {user_id}', highlightFields: ['user_id', 'created_at'], @@ -133,6 +146,16 @@ export const SysTwoFactor = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. `user_id` is required, so the expression + // needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`'Two-factor for ' + record.user_id`, + description: 'Record title: the user the credential belongs to (computed on read)', + }), created_at: Field.datetime({ label: 'Created At', diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 38882d823eb..4f3fb6957c6 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { F } from '@objectstack/spec/shared'; /** * sys_verification — System Verification Object @@ -32,6 +33,15 @@ export const SysVerification = ObjectSchema.create({ docsUrl: 'https://objectstack.ai/docs/references/shared/protection', }, description: 'Email and phone verification tokens', + // [ADR-0079] The record title is `display_title`, a text formula over the + // columns `titleFormat` names. With no pointer declared, the registry's + // designate-only pass stamped `nameField: 'id'` (the first title-eligible + // field), so a renderer honouring ADR-0079's order (an explicit `nameField` + // wins over `titleFormat`) drew the raw id as the record page's H1. + // `titleFormat` stays for renderers that still read it first; + // `identity-display-title.test.ts` holds the two to the same text. + displayNameField: 'display_title', + nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: 'Verification for {identifier}', highlightFields: ['identifier', 'expires_at', 'created_at'], @@ -41,6 +51,16 @@ export const SysVerification = ObjectSchema.create({ required: true, readonly: true, }), + + // [ADR-0079] The record title (`nameField` above). A formula is computed on + // read and has no stored column. `identifier` is required, so the expression + // needs no null guard. + display_title: Field.formula({ + label: 'Title', + returnType: 'text', + expression: F`'Verification for ' + record.identifier`, + description: 'Record title: the identifier being verified (computed on read)', + }), created_at: Field.datetime({ label: 'Created At',