Skip to content
33 changes: 33 additions & 0 deletions .changeset/20044-services-title-pointers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/plugin-approvals": patch
"@objectstack/plugin-security": patch
"@objectstack/service-messaging": patch
"@objectstack/service-realtime": patch
---

fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine system objects that relied on `titleFormat` declare a title pointer, so their record title is no longer the raw id (#20044)

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`. Nine system objects declared a `titleFormat` and no pointer. When such an object is registered, the registry's designate-only pass picks the first title-eligible field as `nameField`, and for these nine that field is `id`. A `/meta` read serves that pointer as if it had been declared, so a renderer that follows ADR-0079's order showed the raw record id as the record page's title.

Eight of the titles are composites. Each of those objects now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it:

- `sys_approval_delegation`: `{delegator_id} → {delegate_id}`;
- `sys_position_permission_set`: `{position_id} → {permission_set_id}`;
- `sys_user_permission_set`: `{user_id} → {permission_set_id}`;
- `sys_user_position`: `{user_id} → {position}`;
- `sys_notification_delivery`: `{channel} → {recipient_id}`;
- `sys_notification_preference`: `{user_id} · {topic} · {channel}`;
- `sys_notification_subscription`: `{principal} · {topic}`;
- `sys_presence`: `{user_id} ({status})`.

`sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly.

This is the migration the `titleFormat` schema text prescribes: "Migrate a single-field title to nameField, a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Every column these titles read is required, so the formulas carry no null guard. Each formula reads only its own row's columns, never a field of a looked-up record.

A formula field is computed when a record is read. It adds no database column, so no schema migration runs. Record reads and write responses of the eight objects now carry `display_title`, and the server-side title accessor (`resolveRecordTitle`) returns the title text instead of the raw id. No row scope, permission set or API method changes.

`titleFormat` stays on all nine objects, unchanged, for renderers that still read it first. The set of fields `$search` scans is unchanged: a formula field is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` was already in the set and now leads it. No search-companion column is provisioned for any of the nine.

The new `display_title` label and help text are in each package's English bundle. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill for them, recorded in the source-hash companions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The record title of `sys_approval_delegation`, which declared a
* `titleFormat` and no title pointer (#20044).
*
* ADR-0079 resolves a record's title as `nameField ?? displayNameField ??
* derivation`, and an explicit `nameField` takes precedence over the
* render-only `titleFormat`. With no pointer declared, the registry's
* designate-only pass (`provisionPrimary(…, { synthesize: false })`) stamped
* `nameField: 'id'` — the first title-eligible field — onto the registered
* body, and a `/meta` read serves that stamp as if the author had written it.
* A renderer honouring the order therefore drew the raw id as the record
* page's H1.
*
* The object now points at `display_title`, a text formula over the columns
* `titleFormat` names. Through the real engine this file asserts:
*
* 1. the body the registry holds after registration names `display_title`;
* 2. a seeded row's H1 is the `titleFormat` text, not the id, and the
* server-side accessor (`resolveRecordTitle`) agrees;
* 3. a row missing a title column is refused by the write path, so the
* formula never sees a NULL part (the sibling of #20015's nullable legs:
* here every title column is required);
* 4. the formula reads exactly the columns `titleFormat` names, on this row
* only, each required and none withheld from a reader of the row;
* 5. the formula adds no stored column, and no search companion column
* either — not even where pinyin search provisions one (a formula is
* never a companion source).
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import {
ObjectQL,
SEARCH_COMPANION_FIELD,
provisionSearchCompanion,
resolveRecordTitle,
resolveSearchCompanionSources,
} from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { resolveDisplayField } from '@objectstack/spec/data';
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';

const SYS = { context: { isSystem: true } } as any;
const OBJECT = 'sys_approval_delegation';

/** The `titleFormat` source: the parsed schema carries it as an envelope. */
function titleFormatSource(schema: unknown): string {
const tf = (schema as { titleFormat?: unknown }).titleFormat;
const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source;
if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`);
return source;
}

/**
* The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder
* substituted with the row's value. It is the reference the formula has to
* reproduce, not a second title resolver.
*/
function renderTitleFormat(schema: unknown, row: Record<string, unknown>): string {
return titleFormatSource(schema).replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? ''));
}

/** The columns `titleFormat` names. */
function titleFormatColumns(schema: unknown): string[] {
return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort();
}

/** Every `record.<path>` the `display_title` expression reads, as written. */
function formulaReads(schema: { fields: Record<string, any> }): string[] {
const source = schema.fields.display_title?.expression?.source;
if (typeof source !== 'string') throw new Error('display_title carries no expression source');
return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort();
}

/** The stored row as a hook body holds it: no formula value on it. */
function storedOnly(row: Record<string, unknown>): Record<string, unknown> {
const { display_title: _omit, ...rest } = row;
return rest;
}

describe('[#20044] sys_approval_delegation resolves a real record title under ADR-0079 order', () => {
let engine: ObjectQL;
let driver: SqlDriver;

beforeAll(async () => {
engine = new ObjectQL();
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
engine.registerDriver(driver, true);
await engine.init();
engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044');
await engine.syncSchemas();
});

afterAll(async () => {
try { await engine?.destroy(); } catch { /* noop */ }
});

it('the registered body points at display_title, a text formula — not the id the designation pass stamped', () => {
const registered = engine.registry.getObject(OBJECT) as any;
expect(registered.nameField).toBe('display_title');
expect(registered.displayNameField).toBe('display_title');
expect(resolveDisplayField(registered)).toBe('display_title');
expect(registered.fields.display_title?.type).toBe('formula');
expect(registered.fields.display_title?.returnType).toBe('text');
});

it('the H1 is "{delegator_id} → {delegate_id}", not the id', async () => {
const registered = engine.registry.getObject(OBJECT) as any;
const created = await engine.insert(OBJECT, { delegator_id: 'usr_alice', delegate_id: 'usr_bob' }, SYS);
// The write response carries the title too (read-your-write).
expect(created.display_title).toBe('usr_alice → usr_bob');
const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS);
expect(row).not.toBeNull();

const h1 = row![resolveDisplayField(registered)!];
expect(h1).toBe('usr_alice → usr_bob');
expect(h1).not.toBe(row!.id);
expect(h1).toBe(renderTitleFormat(SysApprovalDelegation, row!));
expect(resolveRecordTitle(registered, storedOnly(row!))).toBe('usr_alice → usr_bob');
});

it('a row missing a title column is refused, so the formula never sees a NULL part', async () => {
for (const [omit, keep] of [['delegator_id', { delegate_id: 'usr_bob' }], ['delegate_id', { delegator_id: 'usr_alice' }]] as const) {
await expect(engine.insert(OBJECT, keep, SYS)).rejects.toMatchObject({
code: 'VALIDATION_FAILED',
fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]),
});
}
});

it('the formula reads exactly the titleFormat columns, on this row, each required and none withheld', () => {
const fields = SysApprovalDelegation.fields as Record<string, any>;
const reads = formulaReads(SysApprovalDelegation as any);
// One level deep: a dotted path would read a looked-up record's field.
expect(reads).toEqual(titleFormatColumns(SysApprovalDelegation));
for (const column of reads) {
expect(fields[column], column).toBeDefined();
expect(fields[column].required, column).toBe(true);
expect(fields[column].hidden ?? false, column).toBe(false);
expect(fields[column].requiredPermissions ?? [], column).toEqual([]);
expect(fields[column].maskingRule, column).toBeUndefined();
}
});

it('adds no stored column: the formula is computed on read', async () => {
const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo());
expect(columns).toContain('delegator_id');
expect(columns).not.toContain('display_title');
});

it('provisions no search companion column, even where pinyin search is on', () => {
// The companion (`__search`) is a real column fed by the title field. A
// registry provisions it only where pinyin search is on, by running
// `provisionSearchCompanion` over the body it has just designated, so run
// that step over the registered body. A formula title is never a source.
const registered = engine.registry.getObject(OBJECT) as any;
expect(resolveSearchCompanionSources(registered)).toEqual([]);
expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -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';

/**
* sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
Expand Down Expand Up @@ -40,6 +41,15 @@ export const SysApprovalDelegation = ObjectSchema.create({
managedBy: 'system-data',
description:
'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
// [ADR-0079] The record title is `display_title`, a text formula over the
// same two 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;
// `sys-approval-delegation-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: '{delegator_id} → {delegate_id}',
highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],

Expand All @@ -62,6 +72,17 @@ export const SysApprovalDelegation = ObjectSchema.create({
fields: {
id: Field.text({ label: 'Delegation 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. Both source columns are required, so the
// expression needs no null guard.
display_title: Field.formula({
label: 'Title',
returnType: 'text',
expression: F`record.delegator_id + ' → ' + record.delegate_id`,
description: 'Record title: the delegator and the delegate (computed on read)',
group: 'Delegation',
}),

delegator_id: Field.lookup('sys_user', {
label: 'Delegator',
required: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
id: {
label: "Delegation ID"
},
display_title: {
label: "Title",
help: "Record title: the delegator and the delegate (computed on read)"
},
delegator_id: {
label: "Delegator",
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
id: {
label: "Delegation ID"
},
display_title: {
label: "Title",
help: "Record title: the delegator and the delegate (computed on read)"
},
delegator_id: {
label: "Delegator",
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
id: {
label: "Delegation ID"
},
display_title: {
label: "Title",
help: "Record title: the delegator and the delegate (computed on read)"
},
delegator_id: {
label: "Delegator",
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
id: {
label: "委派 ID"
},
display_title: {
label: "Title",
help: "Record title: the delegator and the delegate (computed on read)"
},
delegator_id: {
label: "委派人",
help: "即将不在岗的用户;规则生效期间,路由到其个人的审批人槽位将被改派。"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/

export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

/**
* sys_position_permission_set — Position ↔ PermissionSet binding.
Expand Down Expand Up @@ -30,6 +31,15 @@ export const SysPositionPermissionSet = ObjectSchema.create({
// `userActions` block is needed — the DelegatedAdminGate is the authz.
managedBy: 'system-data',
description: 'Binds a permission set to a position.',
// [ADR-0079] The record title is `display_title`, a text formula over the
// same two 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;
// `sys-security-assignment-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: '{position_id} → {permission_set_id}',
highlightFields: ['position_id', 'permission_set_id'],

Expand All @@ -41,6 +51,19 @@ export const SysPositionPermissionSet = ObjectSchema.create({
description: 'UUID of the position-permission-set binding.',
}),

// [ADR-0079] The record title (`nameField` above). A formula is computed on
// read and has no stored column. It reads only this row's own columns —
// the two foreign keys, never a field of the looked-up records — and
// neither is hidden, permission-guarded or masked on this object, so the
// title carries nothing the declared read path withholds. Both are
// required, so the expression needs no null guard.
display_title: Field.formula({
label: 'Title',
returnType: 'text',
expression: F`record.position_id + ' → ' + record.permission_set_id`,
description: 'Record title: the position and the permission set bound to it (computed on read)',
}),

position_id: Field.lookup('sys_position', {
label: 'Position',
required: true,
Expand Down
Loading
Loading