Skip to content

Commit 7e6ca17

Browse files
fix(plugin-approvals, service-automation, service-messaging): title five system objects with a text formula instead of the raw id (#20042)
Fixes #20015 Clause-②: no ## What this changes ADR-0079 resolves a record's title as `nameField`, then `displayNameField`, then a derivation. An explicit `nameField` takes precedence over the render-only `titleFormat`. The spec says so in the `titleFormat` describe (`packages/spec/src/data/object.zod.ts:2149` on `main`): > [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. objectstack-ai/objectui#9436 makes the record page follow that order. Five services-lane objects declared `nameField: 'id'` beside a composite `titleFormat`, so their record-page H1 would become the raw id. Each of the five now declares `display_title`, a formula field with `returnType: 'text'` that reproduces its `titleFormat`. `nameField` and the `displayNameField` mirror point at it. This is the migration the describe prescribes. | Object | Formula (`display_title`) | |:---|:---| | `sys_approval_request` | `record.process_name + ' · ' + record.record_id` | | `sys_approval_action` | `record.step_name != null ? record.action + ' · ' + record.step_name : record.action` | | `sys_approval_approver` | `record.approver + ' · ' + record.request_id` | | `sys_automation_run` | `record.node_id != null ? record.flow_name + ' · ' + record.node_id : record.flow_name` | | `sys_http_delivery` | `record.label != null ? record.label + ' → ' + record.url : record.url` | Where a source column is nullable, a row without it is titled by the other column alone, so the formula does not fail to evaluate. - **No stored column.** A formula is virtual. `SqlDriver.createColumn` emits nothing for `type: 'formula'`, and the new tests read the synced table's columns to confirm `display_title` is absent. No migration runs. - **`titleFormat` is unchanged** on all five objects, for renderers that still read it first. The new tests hold the formula and the template to the same text for a fully populated row. - **`$search` is unchanged.** Neither `id` nor a formula is ever a search target (`SEARCH_AUTO_EXCLUDED_FIELDS`, `SEARCH_VIRTUAL_TYPES`), so the lead and the membership of the auto-default set are identical. - **No server-side sort reads the title pointer.** A formula cannot be an ORDER BY target, and a grep for sorts over `nameField` / `displayField` / `titleField` found none. - The formula form follows the ADR-0079 worked example (`content/docs/data-modeling/formulas.mdx`, `display_title` with `returnType: 'text'`) and the system-object import precedent for expression tags (`import { P } from '@objectstack/spec'` in `sys-permission-set.object.ts`). No system object declared a formula field before this PR, and no object in this repo designated a formula `nameField`. ## Census (re-run on `main` `9d81af714f`) Every object definition declaring `nameField: 'id'` (all eight also declare `displayNameField: 'id'`): | Object | File | Lane | `titleFormat` | H1 under ADR-0079 order, before | Verdict | |:---|:---|:---|:---|:---|:---| | `sys_approval_request` | `plugin-approvals/src/sys-approval-request.object.ts` | services | `{process_name} · {record_id}` | raw id | **changed** → `display_title` | | `sys_approval_action` | `plugin-approvals/src/sys-approval-action.object.ts` | services | `{action} · {step_name}` | raw id | **changed** → `display_title` | | `sys_approval_approver` | `plugin-approvals/src/sys-approval-approver.object.ts` | services | `{approver} · {request_id}` | raw id | **changed** → `display_title` | | `sys_automation_run` | `service-automation/src/sys-automation-run.object.ts` | services | `{flow_name} · {node_id}` | raw run id | **changed** → `display_title` | | `sys_http_delivery` | `service-messaging/src/objects/http-delivery.object.ts` | services | `{label} → {url}` | raw delivery UUID | **changed** → `display_title` | | `sys_approval_token` | `plugin-approvals/src/sys-approval-token.object.ts` | services | none | raw id, same as today | left: no `titleFormat`, so the id is today's title too; no regression | | `sys_flow_dispatch` | `service-automation/src/sys-flow-dispatch.object.ts` | services | none | raw dispatch key, same as today | left: no `titleFormat`; the dispatch key is the row's identity by design | | `sys_migration` | `platform-objects/src/system/sys-migration.object.ts` | engine | `{id}` | raw id, same as `titleFormat` | reported, not edited (`domain:engine`): the template is the id itself, so no regression | ## Pointer pins, re-judged - `service-automation/src/sys-automation-run-pointer-pair-verdict.test.ts` pinned `SysAutomationRun.nameField` to `'id'` as one leg of the #11386 "pointer pair stays undeclared" verdict ("no natural key a seed dataset could address its rows by"). **Re-judged, not dropped.** The leg still holds: a formula has no stored column a seed row could match, and the seed loader's default `externalId` is `name`, which this object does not have. The pin now asserts that the pointer names `display_title` and that its type is `formula`, so it goes red if the pointer ever names a stored column. The object's own comment carrying the same leg is updated to match. - No other test pins these objects' pointers. `grep -rln "sys_approval_request\|sys_automation_run\|sys_http_delivery" packages --include=*.test.ts` finds 37 files in 15 other packages. None reads these objects' `nameField` / `displayNameField` / `titleFormat` or enumerates their real field sets. They use hand-built fixtures or mention the names in prose. ## Tests New, one per package, each through the real `ObjectQL` engine on in-memory SQLite and the real object declarations: - `plugin-approvals/src/sys-approval-display-title.test.ts` - `service-automation/src/sys-automation-run-display-title.test.ts` - `service-messaging/src/objects/http-delivery-display-title.test.ts` Per object, each test asserts: - `resolveDisplayField` resolves `display_title`; - a seeded row read back with `findOne` carries the H1 the `titleFormat` intended (for example `flow:deal_desk · opp_42`), and that H1 is not the row's id; - the H1 equals the `titleFormat` rendering of the same row; - the server-side accessor `resolveRecordTitle` agrees; - the nullable-column leg titles the row by the other column; - `display_title` is absent from the synced table's columns. Runs at `a02c8673d7`: | Suite | Result | |:---|:---| | `@objectstack/plugin-approvals`, full (`vitest run --maxWorkers=2`) | 50 files, 785 tests passed | | `@objectstack/service-automation`, full | 145 files, 1729 tests passed | | `@objectstack/service-messaging`, full | 45 files, 483 tests passed | | `typecheck`, all three packages (incl. the `check:test-typecheck` ledgers) | exit 0 | `tsc --listFiles` confirms every new test file is inside a typecheck program: service-messaging `tsconfig.json`, plugin-approvals `tsconfig.test.json`, and service-automation `tsconfig.test.json` and `tsconfig.json`. ### Ablations (committed state, `scripts/ablation-replace.mjs`, each restore proven blob == HEAD) The object files are imported relatively from source, so no `dist/` sits on the resolution path and no rebuild is involved. | Leg | Mutation | Result | |:---|:---|:---| | ptr-request | `nameField: 'display_title'` → `'id'` | red 2/5: `expected 'id' to be 'display_title'`; `expected '1noNVpCJW8_iN7Aq' to be 'flow:deal_desk · opp_42'` | | ptr-action | same | red 2/5: H1 `'9kMVUkyx9hCJHlrr'`, expected `'approve · manager_review'` | | ptr-approver | same | red 2/5: H1 `'CQJXqZmwf6e_0vl4'`, expected `'role:finance · N3bOxv5ejSt1FZj9'` | | ptr-run | same | red 4/8, including the re-judged pointer-pair pin (`expected 'id' to be 'display_title'`) | | ptr-http | same | red 3/4: H1 `'JNhfRQXBccs860NR'`, expected `'order.created → https://hooks.example.com/in'` | | col-request | `Field.formula(` → `Field.text(` | red 3/5, including `expected [ 'id', 'created_at', …(20) ] to not include 'display_title'` | | col-run | same | red 5/8, including the stored-column pin | | col-http | same | red 4/4, including the stored-column pin | Every leg printed `ok mutation landed` before its run and `ok restored: blob == HEAD` after it. The post-batch tree was clean. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the real diff derived 62 families. All 62 were run, and `--ran` reconciles: "62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN". `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (six unrelated packages had no `dist/`). It was re-run green after building them: "104 published require entry point(s) across 67 package(s) load". Also run: - `check:i18n-coverage`: OK, 621 baselined, none new; - `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs`: exit 0; - narrowed eslint over the 17 changed `.ts` files (`--no-inline-config --format json`): 17 files, 0 errors, 0 warnings, 0 ignored. The config never enables type-aware linting (`eslint.config.mjs`, around line 328), so a file's verdict depends on that file alone. The repo-wide `pnpm lint` is CI's. The derivation noted the tree is a few commits behind `origin/main`. The one stale input is `scripts/engine-double-contract.pinned.json`, and this diff adds no fake engine. ## i18n `display_title` label and help are extracted into the plugin-approvals (request, action) and service-messaging bundles, and translated by hand in zh-CN, ja-JP and es-ES. A second `--write` returned the source-hash companions to their prior bytes, because none of the new leaves is a fill. `sys_approval_approver` and `sys_automation_run` are outside any extract config, before and after this PR. ## Acceptance notes - **Finding for the seat (not filed from here): 19 objects declare `titleFormat` with no pointer, and the registry stamps `nameField: 'id'` on them.** Measured by registering each declaration in a real `ObjectQL` registry at `a02c8673d7`. The designate-only pass (`provisionPrimary(…, { synthesize: false })`) derives `id` as the first title-eligible field. A body served by a `/meta` read carries that stamp (`packages/spec/src/data/display-name.ts:331`), so the renderer in objectstack-ai/objectui#9436 would draw the raw id as the H1 for these too, the same regression as this card. The 19 are, by lane: - `platform-objects`: `sys_account`, `sys_business_unit_member`, `sys_invitation`, `sys_member`, `sys_scim_group_member`, `sys_scim_projection_grant`, `sys_scim_subject`, `sys_team_member`, `sys_two_factor`, `sys_verification`; - `plugin-approvals`: `sys_approval_delegation`; - `plugin-security`: `sys_position_permission_set`, `sys_user_permission_set`, `sys_user_position`; - `service-messaging`: `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_receipt`, `sys_notification_subscription`; - `service-realtime`: `sys_presence`. Not fixed here. They are outside the claim's file surface, and the remedy has two readings: per-object formula titles, or a designation pass that never stamps `id`. - Six more no-pointer objects get a non-id field designated, so their H1 moves to that field's value: `sys_attachment` → `file_name`, `sys_report_schedule` → `name`, `sys_audience_binding_suggestion` → `permission_set_name`, `sys_record_share` → `object_name`, `sys_share_link` → `label`, `sys_notification_template` → `subject`. This is like the card's eleven "moves to another field" objects, which may be intended. Noted only. - `Clause-②: no` is carried from the claim. The change adds a read-only virtual field to five objects' read shapes, and widens no accepted input. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d58b8b6 commit 7e6ca17

18 files changed

Lines changed: 580 additions & 17 deletions

‎.changeset/20015-name-field-id.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
"@objectstack/service-automation": patch
4+
"@objectstack/service-messaging": patch
5+
---
6+
7+
fix(plugin-approvals, service-automation, service-messaging): five system objects title their records with a text formula instead of the raw id (#20015)
8+
9+
Clause-②: no
10+
11+
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`. Five system objects declared `nameField: 'id'` beside a composite `titleFormat`. A renderer that follows ADR-0079's order therefore showed the raw record id as the record page's title for:
12+
13+
- `sys_approval_request`, whose `titleFormat` is `{process_name} · {record_id}`;
14+
- `sys_approval_action`, whose `titleFormat` is `{action} · {step_name}`;
15+
- `sys_approval_approver`, whose `titleFormat` is `{approver} · {request_id}`;
16+
- `sys_automation_run`, whose `titleFormat` is `{flow_name} · {node_id}`;
17+
- `sys_http_delivery`, whose `titleFormat` is `{label} → {url}`.
18+
19+
Each object now declares `display_title`, a formula field with `returnType: 'text'` over the same columns, and points `nameField` and `displayNameField` at it. This is the migration the `titleFormat` schema text prescribes: "a composite to a formula field designated as nameField". The record title is now the text the `titleFormat` described. Where a source column is nullable (`step_name`, `node_id`, `label`), a row without it is titled by the other column alone.
20+
21+
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 now carry `display_title`. For these objects the server-side title accessor (`resolveRecordTitle`) now returns the formula's text instead of the raw id.
22+
23+
`titleFormat` stays on all five objects, unchanged, for renderers that still read it first. `$search` resolution is unchanged: a formula field is never a search target, and neither was `id`.

‎packages/plugins/plugin-approvals/src/sys-approval-action.object.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { F } from '@objectstack/spec';
45
import { APPROVAL_ACTION_KINDS, APPROVAL_ACTION_KIND_LABELS } from '@objectstack/spec/contracts';
56

67
/**
@@ -22,8 +23,14 @@ export const SysApprovalAction = ObjectSchema.create({
2223
isSystem: true,
2324
managedBy: 'append-only',
2425
description: 'Append-only audit trail for approval actions',
25-
displayNameField: 'id',
26-
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
26+
// [ADR-0079] The record title is `display_title`, a text formula over the
27+
// same two columns `titleFormat` names. The pointer used to be `id`: once a
28+
// renderer honours ADR-0079's order (an explicit `nameField` wins over
29+
// `titleFormat`), that made the record page's H1 the raw id. `titleFormat`
30+
// stays for renderers that still read it first;
31+
// `sys-approval-display-title.test.ts` holds the two to the same text.
32+
displayNameField: 'display_title',
33+
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
2734
titleFormat: '{action} · {step_name}',
2835
highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'via_override', 'created_at'],
2936

@@ -71,6 +78,17 @@ export const SysApprovalAction = ObjectSchema.create({
7178
fields: {
7279
id: Field.text({ label: 'Action ID', required: true, readonly: true, group: 'System' }),
7380

81+
// [ADR-0079] The record title (`nameField` above). A formula is computed on
82+
// read and has no stored column. `step_name` is nullable, so a row without
83+
// one is titled by its action alone rather than failing to evaluate.
84+
display_title: Field.formula({
85+
label: 'Title',
86+
returnType: 'text',
87+
expression: F`record.step_name != null ? record.action + ' · ' + record.step_name : record.action`,
88+
description: 'Record title: the action and, when recorded, its step (computed on read)',
89+
group: 'Action',
90+
}),
91+
7492
organization_id: Field.lookup('sys_organization', {
7593
label: 'Organization',
7694
required: false,

‎packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { F } from '@objectstack/spec';
45

56
/**
67
* sys_approval_approver — Pending-approver index (issue #1745).
@@ -32,14 +33,31 @@ export const SysApprovalApprover = ObjectSchema.create({
3233
isSystem: true,
3334
managedBy: 'engine-owned',
3435
description: 'Normalized pending-approver rows for indexed inbox queries',
35-
displayNameField: 'id',
36-
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
36+
// [ADR-0079] The record title is `display_title`, a text formula over the
37+
// same two columns `titleFormat` names. The pointer used to be `id`: once a
38+
// renderer honours ADR-0079's order (an explicit `nameField` wins over
39+
// `titleFormat`), that made the record page's H1 the raw id. `titleFormat`
40+
// stays for renderers that still read it first;
41+
// `sys-approval-display-title.test.ts` holds the two to the same text.
42+
displayNameField: 'display_title',
43+
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3744
titleFormat: '{approver} · {request_id}',
3845
highlightFields: ['request_id', 'approver', 'created_at'],
3946

4047
fields: {
4148
id: Field.text({ label: 'Row ID', required: true, readonly: true, group: 'System' }),
4249

50+
// [ADR-0079] The record title (`nameField` above). A formula is computed on
51+
// read and has no stored column. Both source columns are required, so the
52+
// expression needs no null guard.
53+
display_title: Field.formula({
54+
label: 'Title',
55+
returnType: 'text',
56+
expression: F`record.approver + ' · ' + record.request_id`,
57+
description: 'Record title: the approver identity and its request (computed on read)',
58+
group: 'Target',
59+
}),
60+
4361
organization_id: Field.lookup('sys_organization', {
4462
label: 'Organization',
4563
required: false,
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The record title of the three approval objects that used to point
5+
* `nameField` at `id` (#20015).
6+
*
7+
* ADR-0079 resolves a record's title as `nameField ?? displayNameField ??
8+
* derivation`, and an explicit `nameField` takes precedence over the
9+
* render-only `titleFormat`. A renderer that honours that order therefore
10+
* showed the raw id as the record page's H1 for `sys_approval_action`,
11+
* `sys_approval_approver` and `sys_approval_request`, because each of them
12+
* declared `nameField: 'id'` beside a composite `titleFormat`.
13+
*
14+
* Each object now points at `display_title`, a text formula over the same
15+
* columns. This file asserts, per object, through the real engine:
16+
*
17+
* 1. the title field ADR-0079's order resolves is `display_title`, not `id`;
18+
* 2. a seeded row read back through `findOne` carries the H1 the
19+
* `titleFormat` intended, and that H1 is not the row's id;
20+
* 3. the server-side title accessor (`resolveRecordTitle`) agrees;
21+
* 4. the formula adds no stored column.
22+
*
23+
* Where a source column is nullable, a row without it is titled by the other
24+
* column rather than failing to evaluate.
25+
*/
26+
27+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
28+
import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql';
29+
import { SqlDriver } from '@objectstack/driver-sql';
30+
import { resolveDisplayField } from '@objectstack/spec/data';
31+
import { SysApprovalAction } from './sys-approval-action.object.js';
32+
import { SysApprovalApprover } from './sys-approval-approver.object.js';
33+
import { SysApprovalRequest } from './sys-approval-request.object.js';
34+
35+
const SYS = { context: { isSystem: true } } as any;
36+
37+
/**
38+
* The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder
39+
* substituted with the row's value. It is the reference the formula has to
40+
* reproduce, not a second title resolver. The parsed schema carries
41+
* `titleFormat` as a `{ dialect: 'template', source }` envelope.
42+
*/
43+
function renderTitleFormat(titleFormat: unknown, row: Record<string, unknown>): string {
44+
const source = typeof titleFormat === 'string' ? titleFormat : (titleFormat as { source?: unknown })?.source;
45+
if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(titleFormat)}`);
46+
return source.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? ''));
47+
}
48+
49+
/** The H1 under ADR-0079's order: the value at the resolved title field. */
50+
function h1Of(schema: unknown, row: Record<string, unknown>): unknown {
51+
const field = resolveDisplayField(schema as any);
52+
return field === undefined ? undefined : row[field];
53+
}
54+
55+
/** The stored row as a hook body holds it: no formula value on it. */
56+
function storedOnly(row: Record<string, unknown>): Record<string, unknown> {
57+
const { display_title: _omit, ...rest } = row;
58+
return rest;
59+
}
60+
61+
describe('[#20015] approval objects resolve a real record title under ADR-0079 order', () => {
62+
let engine: ObjectQL;
63+
let driver: SqlDriver;
64+
65+
beforeAll(async () => {
66+
engine = new ObjectQL();
67+
driver = new SqlDriver({
68+
client: 'better-sqlite3',
69+
connection: { filename: ':memory:' },
70+
useNullAsDefault: true,
71+
});
72+
engine.registerDriver(driver, true);
73+
await engine.init();
74+
for (const o of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
75+
engine.registry.registerObject(o as any, 'com.objectstack.test.20015');
76+
}
77+
await engine.syncSchemas();
78+
});
79+
80+
afterAll(async () => {
81+
try { await engine?.destroy(); } catch { /* noop */ }
82+
});
83+
84+
it('points all three objects at display_title, a text formula', () => {
85+
for (const schema of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
86+
expect(resolveDisplayField(schema as any)).toBe('display_title');
87+
const field = (schema.fields as Record<string, any>).display_title;
88+
expect(field?.type).toBe('formula');
89+
expect(field?.returnType).toBe('text');
90+
}
91+
});
92+
93+
it('sys_approval_request: the H1 is "{process_name} · {record_id}", not the id', async () => {
94+
const created = await engine.insert('sys_approval_request', {
95+
process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_42',
96+
status: 'pending',
97+
}, SYS);
98+
// The write response carries the title too (read-your-write).
99+
expect(created.display_title).toBe('flow:deal_desk · opp_42');
100+
const row = await engine.findOne('sys_approval_request', { where: { id: created.id } }, SYS);
101+
expect(row).not.toBeNull();
102+
103+
expect(h1Of(SysApprovalRequest, row!)).toBe('flow:deal_desk · opp_42');
104+
expect(h1Of(SysApprovalRequest, row!)).not.toBe(row!.id);
105+
expect(h1Of(SysApprovalRequest, row!)).toBe(renderTitleFormat(SysApprovalRequest.titleFormat, row!));
106+
expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('flow:deal_desk · opp_42');
107+
});
108+
109+
it('sys_approval_action: the H1 is "{action} · {step_name}", and the action alone without a step', async () => {
110+
const request = await engine.insert('sys_approval_request', {
111+
process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_43',
112+
status: 'pending',
113+
}, SYS);
114+
115+
const stepped = await engine.insert('sys_approval_action', {
116+
request_id: request.id, action: 'approve', step_name: 'manager_review',
117+
}, SYS);
118+
const row = await engine.findOne('sys_approval_action', { where: { id: stepped.id } }, SYS);
119+
expect(h1Of(SysApprovalAction, row!)).toBe('approve · manager_review');
120+
expect(h1Of(SysApprovalAction, row!)).not.toBe(row!.id);
121+
expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(SysApprovalAction.titleFormat, row!));
122+
expect(resolveRecordTitle(SysApprovalAction, storedOnly(row!))).toBe('approve · manager_review');
123+
124+
const stepless = await engine.insert('sys_approval_action', {
125+
request_id: request.id, action: 'submit',
126+
}, SYS);
127+
const bare = await engine.findOne('sys_approval_action', { where: { id: stepless.id } }, SYS);
128+
expect(bare!.step_name ?? null).toBeNull();
129+
expect(h1Of(SysApprovalAction, bare!)).toBe('submit');
130+
expect(resolveRecordTitle(SysApprovalAction, storedOnly(bare!))).toBe('submit');
131+
});
132+
133+
it('sys_approval_approver: the H1 is "{approver} · {request_id}", not the id', async () => {
134+
const request = await engine.insert('sys_approval_request', {
135+
process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_44',
136+
status: 'pending',
137+
}, SYS);
138+
const created = await engine.insert('sys_approval_approver', {
139+
request_id: request.id, approver: 'role:finance',
140+
}, SYS);
141+
const row = await engine.findOne('sys_approval_approver', { where: { id: created.id } }, SYS);
142+
143+
expect(h1Of(SysApprovalApprover, row!)).toBe(`role:finance · ${request.id}`);
144+
expect(h1Of(SysApprovalApprover, row!)).not.toBe(row!.id);
145+
expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(SysApprovalApprover.titleFormat, row!));
146+
expect(resolveRecordTitle(SysApprovalApprover, storedOnly(row!))).toBe(`role:finance · ${request.id}`);
147+
});
148+
149+
it('adds no stored column: the formula is computed on read', async () => {
150+
for (const [object, sourceColumn] of [
151+
['sys_approval_request', 'process_name'],
152+
['sys_approval_action', 'step_name'],
153+
['sys_approval_approver', 'approver'],
154+
] as const) {
155+
const columns = Object.keys(await driver.getKnex()(object).columnInfo());
156+
expect(columns).toContain(sourceColumn);
157+
expect(columns).not.toContain('display_title');
158+
}
159+
});
160+
});

‎packages/plugins/plugin-approvals/src/sys-approval-request.object.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
import { F } from '@objectstack/spec';
45
import {
56
APPROVAL_STATUSES,
67
APPROVAL_STATUS_LABELS,
@@ -44,8 +45,14 @@ export const SysApprovalRequest = ObjectSchema.create({
4445
isSystem: true,
4546
managedBy: 'engine-owned',
4647
description: 'Live approval instance tracked per submission',
47-
displayNameField: 'id',
48-
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
48+
// [ADR-0079] The record title is `display_title`, a text formula over the
49+
// same two columns `titleFormat` names. The pointer used to be `id`: once a
50+
// renderer honours ADR-0079's order (an explicit `nameField` wins over
51+
// `titleFormat`), that made the record page's H1 the raw id. `titleFormat`
52+
// stays for renderers that still read it first;
53+
// `sys-approval-display-title.test.ts` holds the two to the same text.
54+
displayNameField: 'display_title',
55+
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
4956
titleFormat: '{process_name} · {record_id}',
5057
highlightFields: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'],
5158

@@ -103,6 +110,17 @@ export const SysApprovalRequest = ObjectSchema.create({
103110
fields: {
104111
id: Field.text({ label: 'Request ID', required: true, readonly: true, group: 'System' }),
105112

113+
// [ADR-0079] The record title (`nameField` above). A formula is computed on
114+
// read and has no stored column. Both source columns are required, so the
115+
// expression needs no null guard.
116+
display_title: Field.formula({
117+
label: 'Title',
118+
returnType: 'text',
119+
expression: F`record.process_name + ' · ' + record.record_id`,
120+
description: 'Record title: the request source and the record it is about (computed on read)',
121+
group: 'Target',
122+
}),
123+
106124
// [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
107125
// organization, with the acting context as fallback — resolved by the
108126
// SHARED platform-row resolver (`resolveRecordOrganizationField`,

‎packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
2323
id: {
2424
label: "Request ID"
2525
},
26+
display_title: {
27+
label: "Title",
28+
help: "Record title: the request source and the record it is about (computed on read)"
29+
},
2630
organization_id: {
2731
label: "Organization",
2832
help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
@@ -212,6 +216,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
212216
id: {
213217
label: "Action ID"
214218
},
219+
display_title: {
220+
label: "Title",
221+
help: "Record title: the action and, when recorded, its step (computed on read)"
222+
},
215223
organization_id: {
216224
label: "Organization",
217225
help: "Tenant that owns this action (mirrors the parent request)"

‎packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
2323
id: {
2424
label: "ID de solicitud"
2525
},
26+
display_title: {
27+
label: "Título",
28+
help: "Título del registro: el origen de la solicitud y el registro al que se refiere (calculado al leer)"
29+
},
2630
organization_id: {
2731
label: "Organización",
2832
help: "Organización del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
@@ -212,6 +216,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
212216
id: {
213217
label: "ID de acción"
214218
},
219+
display_title: {
220+
label: "Título",
221+
help: "Título del registro: la acción y, si consta, su paso (calculado al leer)"
222+
},
215223
organization_id: {
216224
label: "Organización",
217225
help: "Tenant que posee esta acción (refleja la solicitud principal)."

‎packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
2323
id: {
2424
label: "リクエスト ID"
2525
},
26+
display_title: {
27+
label: "タイトル",
28+
help: "レコードタイトル:申請のソースと対象レコード(読み取り時に計算)"
29+
},
2630
organization_id: {
2731
label: "組織",
2832
help: "このリクエストの対象レコードが属する組織(レコードに組織がない場合は操作コンテキストにフォールバック)"
@@ -212,6 +216,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
212216
id: {
213217
label: "アクション ID"
214218
},
219+
display_title: {
220+
label: "タイトル",
221+
help: "レコードタイトル:アクションと、記録されている場合はそのステップ(読み取り時に計算)"
222+
},
215223
organization_id: {
216224
label: "組織",
217225
help: "このアクションを所有するテナント(親リクエストと同じ)"

0 commit comments

Comments
 (0)