Skip to content

Commit d4c897e

Browse files
fix(plugin-approvals, plugin-security, service-messaging, service-realtime): nine titleFormat-only system objects declare a title pointer instead of taking the raw id (#20087)
Fixes #20044 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 `titleFormat` describe (`packages/spec/src/data/object.zod.ts`) states the migration: 「an explicit nameField now takes precedence … Migrate a single-field title to nameField, a composite to a formula field designated as nameField」. Nine services-lane objects declared a `titleFormat` and no pointer. At registration the registry's designate-only pass (`provisionPrimary(…, { synthesize: false })`) derives the first title-eligible field, which on all nine is `id`, and stamps it as `nameField`. A `/meta` read serves that stamp as if the author had written it. objectstack-ai/objectui#9436 (landed as objectstack-ai/objectui#10358) makes the record page honour the declared pointer, so each record page's H1 becomes the raw id once the console pin moves past it. This is the same remedy PR #20042 applied to five objects (#20015), repeated for these nine. ### Measured per object Each declaration was registered in a real `ObjectQL` registry on in-memory SQLite. One representative row was seeded and read back through `findOne`, and its H1 was read under ADR-0079's order next to the `titleFormat` rendering. The "main" columns were measured at `b76aad5f6f`, with the declarations as they stand on `main`. The "this branch" columns come from the committed pins. | Object | `titleFormat` | main: registered `nameField` / `displayNameField` | main: H1 | `titleFormat` rendering | this branch: pointer | this branch: H1 (= `resolveRecordTitle`) | |:---|:---|:---|:---|:---|:---|:---| | `sys_approval_delegation` | `{delegator_id} → {delegate_id}` | `id` / none | `RcqJgHufg-44Qdbj` (raw id) | `usr_alice → usr_bob` | `display_title` | `usr_alice → usr_bob` | | `sys_position_permission_set` | `{position_id} → {permission_set_id}` | `id` / none | `2lvPKcxu8uDzPCzk` (raw id) | `pos_sales → ps_crm_edit` | `display_title` | `pos_sales → ps_crm_edit` | | `sys_user_permission_set` | `{user_id} → {permission_set_id}` | `id` / none | `F06WPbxwwcDPtxYt` (raw id) | `usr_alice → ps_crm_edit` | `display_title` | `usr_alice → ps_crm_edit` | | `sys_user_position` | `{user_id} → {position}` | `id` / none | `WeYJnXQkg-O3NKIh` (raw id) | `usr_alice → sales_manager` | `display_title` | `usr_alice → sales_manager` | | `sys_notification_delivery` | `{channel} → {recipient_id}` | `id` / none | `MjrV1FGgJdUhhoj7` (raw id) | `email → usr_alice` | `display_title` | `email → usr_alice` | | `sys_notification_preference` | `{user_id} · {topic} · {channel}` | `id` / none | `YuzjUl0Lsxsk4L9v` (raw id) | `usr_alice · billing.invoice · email` | `display_title` | `usr_alice · billing.invoice · email` | | `sys_notification_receipt` | `{state}` | `id` / none | `3IWhzyEYVsd9JGeI` (raw id) | `read` | `state` | `read` | | `sys_notification_subscription` | `{principal} · {topic}` | `id` / none | `aNY2Diw2O2_qvHT8` (raw id) | `role:sales_manager · billing.invoice` | `display_title` | `role:sales_manager · billing.invoice` | | `sys_presence` | `{user_id} ({status})` | `id` / none | `zRKJBDnWzfMtp9Ps` (raw id) | `usr_alice (away)` | `display_title` | `usr_alice (away)` (scratch run, see Deviations) | ### The fix The eight composites each declare `display_title`, a formula field with `returnType: 'text'` over the columns their `titleFormat` names. `nameField` and the `displayNameField` mirror point at it, as in #20042. | Object | `display_title` expression | |:---|:---| | `sys_approval_delegation` | `record.delegator_id + ' → ' + record.delegate_id` | | `sys_position_permission_set` | `record.position_id + ' → ' + record.permission_set_id` | | `sys_user_permission_set` | `record.user_id + ' → ' + record.permission_set_id` | | `sys_user_position` | `record.user_id + ' → ' + record.position` | | `sys_notification_delivery` | `record.channel + ' → ' + record.recipient_id` | | `sys_notification_preference` | `record.user_id + ' · ' + record.topic + ' · ' + record.channel` | | `sys_notification_subscription` | `record.principal + ' · ' + record.topic` | | `sys_presence` | `record.user_id + ' (' + record.status + ')'` | `sys_notification_receipt`'s title is the single column `{state}`, so `nameField` and `displayNameField` name `state` directly. That is the describe's migration for a single-field title. An explicit pointer is honoured whatever the field's type (ADR-0079 D4, `resolveDisplayField`). `select` is kept out of derivation only, which is why the pass skipped `state` and stamped `id`. - **No NULL part reaches a formula.** Every column the titles read is `required: true`, so the formulas carry no null guard, like #20042's required-column formulas. #20042's NULL-part legs covered nullable columns, and none of these nine titles has one. The write path refuses an omitted title column with `VALIDATION_FAILED`, naming the field with code `required`. Where the column declares a default, the write fills it instead: preference `topic` / `channel` become `'*'`, receipt `state` becomes `'delivered'`, and presence `status` becomes `'online'`. The engine pins cover both behaviours. A row written around the engine with a NULL title column (raw SQL) makes the formula evaluate to `null`. This was measured in a scratch run, where the `titleFormat` rendering of that row would be `usr_alice → ` instead. - **No stored column.** A formula is computed on read, and the synced tables carry no `display_title` column. No search-companion column appears either: a formula is never a companion source, and `select` is not title text. The pin runs `provisionSearchCompanion` over the registered body, the step a pinyin-enabled registry runs, and `resolveSearchCompanionSources` answers `[]`. No migration runs. - **`titleFormat` is unchanged** on all nine objects, for renderers that still read it first. - **`$search` scans the same fields.** A formula is never a search target, and neither was `id`. On `sys_notification_receipt`, `state` (a `select`) was already in the auto-default set and now leads it. The lead changes the order only, never the members, and none of the nine declares `searchableFields`. ## Security: the three permission-assignment tables `sys_position_permission_set`, `sys_user_permission_set` and `sys_user_position` bind permissions, so the new field was checked against their existing read access: - Each formula reads only its own row's columns: the foreign keys (`position_id`, `permission_set_id`, `user_id`) and the `position` name. It never reads a field of the record a key points at, so it never traverses a lookup the reader may not see. - None of those columns is `hidden`, guarded by `requiredPermissions` or masked (`maskingRule`). The pin asserts all three per column, and each was ablated on its own. They were already served to every reader of the row, and they appear in `highlightFields` and `titleFormat`. - No row scope, permission set, `apiMethods`, `managedBy` or `userActions` entry changes. `display_title` is read-only. The shipped permission sets grant these objects object-level access only (`default-permission-sets.ts`) and carry no field entries. ## Tests One new file per package. The three engine files boot the real `ObjectQL` engine on in-memory SQLite with the real declarations: - `plugin-approvals/src/sys-approval-delegation-display-title.test.ts` (6 tests) - `plugin-security/src/objects/sys-security-assignment-display-title.test.ts` (18 tests, 6 per object) - `service-messaging/src/objects/notification-display-title.test.ts` (24 tests, 6 per object) - `service-realtime/src/objects/sys-presence-display-title.test.ts` (2 tests; no engine, see Deviations) Per object, the engine files assert: - the body the registry holds after registration names the new pointer, for both `nameField` and `displayNameField`; - a seeded row's H1 is the literal `titleFormat` text, is not the row's id, equals the `titleFormat` rendering of that row, and `resolveRecordTitle` agrees; - a row missing a title column is refused (`VALIDATION_FAILED`, field named, code `required`), or is filled from the declared default and titled by it; - the formula reads exactly the `titleFormat` columns, one level deep, each `required`, none `hidden` / permission-guarded / masked; - no `display_title` column exists in the synced table, and no search companion is provisioned. Runs at `17db356e01`, the head of this PR: | Suite | Result | |:---|:---| | `@objectstack/plugin-approvals`, full (`vitest run --maxWorkers=2`, at `d040485b5b`) | 51 files, 790 tests passed | | `@objectstack/plugin-security`, full (at `d040485b5b`) | 134 files, 2663 tests passed | | `@objectstack/service-messaging`, full (at `d040485b5b`) | 46 files, 503 tests passed | | `@objectstack/service-realtime`, full (at `d040485b5b`) | 5 files, 33 tests passed | | the three rewritten engine files, at `17db356e01` | 6 + 18 + 24 passed | | `typecheck`, all four packages, at `17db356e01` | exit 0; `check:test-typecheck` OK for plugin-approvals (ledger unchanged) and plugin-security (0 errors) | Only the three engine test files changed between `d040485b5b` and `17db356e01`. `tsc --listFiles` finds every new test file inside a typecheck program: plugin-approvals and plugin-security `tsconfig.test.json`, service-messaging and service-realtime `tsconfig.json`. ### Ablations (committed state `17db356e01`, `scripts/ablation-replace.mjs`) The object files are imported relatively from source, so no `dist/` sits on the resolution path and no rebuild is involved. Every leg printed `ok mutation landed` before its run and `ok restored: blob == HEAD` after it. The tree equalled `HEAD` after each of the 33 legs. | Leg | Mutation | Result | |:---|:---|:---| | ptr-id, 8 formula objects | both pointers → `'id'` (#20015's shape) | red on the pointer and H1 tests of that object, e.g. `expected '22o5f2s01PYV_LAL' to be 'usr_alice → usr_bob'`, `expected 'Hc3c8fIEvdtL9EZZ' to be 'pos_sales → ps_crm_edit'`, `expected '1-dAWCH9Y12B-1z4' to be 'email → usr_alice'`; presence: `expected 'id' to be 'display_title'` from the designation pass | | ptr-removed, receipt | both pointers deleted (`main`'s state) | red 3: `expected 'id' to be 'state'`, `expected 'fKk2vvHoRePGTkDD' to be 'read'`, `expected 'nwVM_6ig0mkb28sJ' to be 'delivered'` | | ptr-removed, delegation and presence | both pointers deleted | red 1 each, on the `displayNameField` mirror only (`expected undefined to be 'display_title'`); the H1 stays right, see Acceptance notes | | req, 8 objects | one title column `required: true` → `false` | red on the refusal test (`promise resolved … instead of rejecting`) and the inputs test (`recipient_id: expected false to be true`); presence: the inputs test | | withheld, 6 legs | `hidden: true` on an input (delegation, delivery, presence, `sys_position_permission_set`); `requiredPermissions` on `sys_user_permission_set.user_id`; `maskingRule: 'name'` on `sys_user_position.position` | red on the inputs test each time, e.g. `user_id: expected [ 'view_assignment_subjects' ] to deeply equal []`, `position: expected 'name' to be undefined` | | col, 7 objects | `Field.formula(` → `Field.text(` | red 4 or 5, including `to not include 'display_title'` and the companion pin | | companion-receipt | `state: Field.select([…], {` → `state: Field.text({` | red 1: `expected [ 'state' ] to deeply equal []` | The first version of the companion pin read the synced table's columns for `__search`. The engine the tests boot builds its registry with the companion off (it follows `OS_SEARCH_PINYIN_ENABLED`), so that pin could not fail, and the companion-receipt leg stayed green. It was replaced by the `provisionSearchCompanion` form above, which goes red on that leg. ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` from the real diff derived 62 families. All 62 were run at `17db356e01`, and `--ran` (with an exit code recorded per family) reconciles: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)". - `check:registry-log-declared` first went red on an intermediate commit whose tests constructed a `SchemaRegistry`. That requires an `OS_REGISTRY_LOG` declaration in three `vitest.config.ts` files outside this change. The pin was rewritten not to construct one, and the gate is green at `17db356e01`. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (six unrelated packages had no `dist/`). After building them it passed: "104 published require entry point(s) across 67 package(s) load". - `GITHUB_TOKEN="$GH_TOKEN" node scripts/check-issue-citations.mjs`: exit 0. - `check:i18n-coverage` (at `3fc935d944`, the bundle commit; nothing after it touches a bundle or a declaration): OK, "13 config(s), 621 baselined untranslated string(s), none new". - Narrowed eslint over the 41 changed `.ts` files (`--no-inline-config --format json`): 41 files, 0 errors, 0 warnings, 0 ignored. The config never enables type-aware linting (`eslint.config.mjs` around line 328: no `parserOptions.project`), so a file's verdict depends on that file alone. The repo-wide `pnpm lint` is CI's. The derivation notes the tree is 7 commits behind `origin/main`. Two gate inputs changed across that range: `scripts/check-spec-docblock-symbol-anchors.mjs` and `scripts/doc-authoring-prose-id.baseline.json`. None of those commits touches a file in this diff. ## i18n `node scripts/check-i18n-bundles.mjs --write --filter=…` regenerated the four packages' bundles, and its output is committed unedited. The English bundles gain `display_title`'s label and help. The zh-CN, ja-JP and es-ES bundles carry the generator's English fill, and the source-hash companions record those fills. A second `--write` is a byte-for-byte fixed point. `check:i18n` and `check:i18n-stale-fill` are green. #20042 translated its new leaves by hand, but this dispatch said the bundles are regenerated by tooling and never edited by hand. The translated values can be hand-written in a later change, which AGENTS.md allows. ## Deviations - **`sys_presence`'s rendered-title pin is not committed.** `@objectstack/service-realtime` declares neither `@objectstack/objectql` nor `@objectstack/driver-sql`. An engine test there needs both as devDependencies, which edits `package.json` and `pnpm-lock.yaml`, outside the claimed file surface. The committed file pins the designation pass (`provisionPrimary`, the step the registry runs) and the formula's inputs. The rendered title was measured through the real engine in a scratch run, not committed: `usr_alice (away)`, equal to the `titleFormat` rendering. - **The inherited commit (`3a1ea534a4`) was kept and amended by a follow-up commit, not rewritten.** Its formulas were re-derived against each `titleFormat` and all nine match. `sys_notification_receipt`'s `titleFormat` is exactly `{state}`, so `state` is its one field. The follow-up rewraps overlong comment lines and corrects "all required" for two-column titles. It narrows the plugin-security note to what the declared read path shows, and replaces a comment that promised the presence test holds the rendered text. - **`origin/main` was not merged.** It moved 7 commits, none touching a file in this diff, so there is nothing to conflict. ## Acceptance notes - Removing both pointers from a formula object does not bring the defect back: `display_title` then wins derivation tier 2 (the `_title` affix). Measured on `sys_approval_delegation` and `sys_presence`, where only the `displayNameField` mirror went missing. The explicit pointer is kept because the describe prescribes it and #20042 declared it. - A formula is a field of its own under field-level security. The masker (`field-masker.ts` `maskResults`) deletes fields by name and knows no formula inputs. A deployment that hides an input column through a permission-set field entry therefore does not hide `display_title` with it. That holds for every formula field, #20042's five included. No shipped declaration or permission set restricts these inputs. Noted, not measured. - Where an input is a lookup, the title carries the stored foreign key, as the `titleFormat` substitution did (#20042's `request_id` likewise). - The `title-format-retired` lint warning stays on all nine objects, because `titleFormat` stays (as in #20042). --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c48234 commit d4c897e

42 files changed

Lines changed: 1095 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/plugin-approvals": patch
3+
"@objectstack/plugin-security": patch
4+
"@objectstack/service-messaging": patch
5+
"@objectstack/service-realtime": patch
6+
---
7+
8+
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)
9+
10+
Clause-②: no
11+
12+
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.
13+
14+
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:
15+
16+
- `sys_approval_delegation`: `{delegator_id} → {delegate_id}`;
17+
- `sys_position_permission_set`: `{position_id} → {permission_set_id}`;
18+
- `sys_user_permission_set`: `{user_id} → {permission_set_id}`;
19+
- `sys_user_position`: `{user_id} → {position}`;
20+
- `sys_notification_delivery`: `{channel} → {recipient_id}`;
21+
- `sys_notification_preference`: `{user_id} · {topic} · {channel}`;
22+
- `sys_notification_subscription`: `{principal} · {topic}`;
23+
- `sys_presence`: `{user_id} ({status})`.
24+
25+
`sys_notification_receipt`'s title is the single column `{state}`, so its `nameField` and `displayNameField` now name `state` directly.
26+
27+
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.
28+
29+
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.
30+
31+
`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.
32+
33+
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.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The record title of `sys_approval_delegation`, which declared a
5+
* `titleFormat` and no title pointer (#20044).
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`. With no pointer declared, the registry's
10+
* designate-only pass (`provisionPrimary(…, { synthesize: false })`) stamped
11+
* `nameField: 'id'` — the first title-eligible field — onto the registered
12+
* body, and a `/meta` read serves that stamp as if the author had written it.
13+
* A renderer honouring the order therefore drew the raw id as the record
14+
* page's H1.
15+
*
16+
* The object now points at `display_title`, a text formula over the columns
17+
* `titleFormat` names. Through the real engine this file asserts:
18+
*
19+
* 1. the body the registry holds after registration names `display_title`;
20+
* 2. a seeded row's H1 is the `titleFormat` text, not the id, and the
21+
* server-side accessor (`resolveRecordTitle`) agrees;
22+
* 3. a row missing a title column is refused by the write path, so the
23+
* formula never sees a NULL part (the sibling of #20015's nullable legs:
24+
* here every title column is required);
25+
* 4. the formula reads exactly the columns `titleFormat` names, on this row
26+
* only, each required and none withheld from a reader of the row;
27+
* 5. the formula adds no stored column, and no search companion column
28+
* either — not even where pinyin search provisions one (a formula is
29+
* never a companion source).
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import {
34+
ObjectQL,
35+
SEARCH_COMPANION_FIELD,
36+
provisionSearchCompanion,
37+
resolveRecordTitle,
38+
resolveSearchCompanionSources,
39+
} from '@objectstack/objectql';
40+
import { SqlDriver } from '@objectstack/driver-sql';
41+
import { resolveDisplayField } from '@objectstack/spec/data';
42+
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
43+
44+
const SYS = { context: { isSystem: true } } as any;
45+
const OBJECT = 'sys_approval_delegation';
46+
47+
/** The `titleFormat` source: the parsed schema carries it as an envelope. */
48+
function titleFormatSource(schema: unknown): string {
49+
const tf = (schema as { titleFormat?: unknown }).titleFormat;
50+
const source = typeof tf === 'string' ? tf : (tf as { source?: unknown })?.source;
51+
if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(tf)}`);
52+
return source;
53+
}
54+
55+
/**
56+
* The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder
57+
* substituted with the row's value. It is the reference the formula has to
58+
* reproduce, not a second title resolver.
59+
*/
60+
function renderTitleFormat(schema: unknown, row: Record<string, unknown>): string {
61+
return titleFormatSource(schema).replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? ''));
62+
}
63+
64+
/** The columns `titleFormat` names. */
65+
function titleFormatColumns(schema: unknown): string[] {
66+
return [...titleFormatSource(schema).matchAll(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g)].map((m) => m[1]).sort();
67+
}
68+
69+
/** Every `record.<path>` the `display_title` expression reads, as written. */
70+
function formulaReads(schema: { fields: Record<string, any> }): string[] {
71+
const source = schema.fields.display_title?.expression?.source;
72+
if (typeof source !== 'string') throw new Error('display_title carries no expression source');
73+
return [...source.matchAll(/record\.([A-Za-z_][A-Za-z0-9_.]*)/g)].map((m) => m[1]).sort();
74+
}
75+
76+
/** The stored row as a hook body holds it: no formula value on it. */
77+
function storedOnly(row: Record<string, unknown>): Record<string, unknown> {
78+
const { display_title: _omit, ...rest } = row;
79+
return rest;
80+
}
81+
82+
describe('[#20044] sys_approval_delegation resolves a real record title under ADR-0079 order', () => {
83+
let engine: ObjectQL;
84+
let driver: SqlDriver;
85+
86+
beforeAll(async () => {
87+
engine = new ObjectQL();
88+
driver = new SqlDriver({
89+
client: 'better-sqlite3',
90+
connection: { filename: ':memory:' },
91+
useNullAsDefault: true,
92+
});
93+
engine.registerDriver(driver, true);
94+
await engine.init();
95+
engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044');
96+
await engine.syncSchemas();
97+
});
98+
99+
afterAll(async () => {
100+
try { await engine?.destroy(); } catch { /* noop */ }
101+
});
102+
103+
it('the registered body points at display_title, a text formula — not the id the designation pass stamped', () => {
104+
const registered = engine.registry.getObject(OBJECT) as any;
105+
expect(registered.nameField).toBe('display_title');
106+
expect(registered.displayNameField).toBe('display_title');
107+
expect(resolveDisplayField(registered)).toBe('display_title');
108+
expect(registered.fields.display_title?.type).toBe('formula');
109+
expect(registered.fields.display_title?.returnType).toBe('text');
110+
});
111+
112+
it('the H1 is "{delegator_id} → {delegate_id}", not the id', async () => {
113+
const registered = engine.registry.getObject(OBJECT) as any;
114+
const created = await engine.insert(OBJECT, { delegator_id: 'usr_alice', delegate_id: 'usr_bob' }, SYS);
115+
// The write response carries the title too (read-your-write).
116+
expect(created.display_title).toBe('usr_alice → usr_bob');
117+
const row = await engine.findOne(OBJECT, { where: { id: created.id } }, SYS);
118+
expect(row).not.toBeNull();
119+
120+
const h1 = row![resolveDisplayField(registered)!];
121+
expect(h1).toBe('usr_alice → usr_bob');
122+
expect(h1).not.toBe(row!.id);
123+
expect(h1).toBe(renderTitleFormat(SysApprovalDelegation, row!));
124+
expect(resolveRecordTitle(registered, storedOnly(row!))).toBe('usr_alice → usr_bob');
125+
});
126+
127+
it('a row missing a title column is refused, so the formula never sees a NULL part', async () => {
128+
for (const [omit, keep] of [['delegator_id', { delegate_id: 'usr_bob' }], ['delegate_id', { delegator_id: 'usr_alice' }]] as const) {
129+
await expect(engine.insert(OBJECT, keep, SYS)).rejects.toMatchObject({
130+
code: 'VALIDATION_FAILED',
131+
fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]),
132+
});
133+
}
134+
});
135+
136+
it('the formula reads exactly the titleFormat columns, on this row, each required and none withheld', () => {
137+
const fields = SysApprovalDelegation.fields as Record<string, any>;
138+
const reads = formulaReads(SysApprovalDelegation as any);
139+
// One level deep: a dotted path would read a looked-up record's field.
140+
expect(reads).toEqual(titleFormatColumns(SysApprovalDelegation));
141+
for (const column of reads) {
142+
expect(fields[column], column).toBeDefined();
143+
expect(fields[column].required, column).toBe(true);
144+
expect(fields[column].hidden ?? false, column).toBe(false);
145+
expect(fields[column].requiredPermissions ?? [], column).toEqual([]);
146+
expect(fields[column].maskingRule, column).toBeUndefined();
147+
}
148+
});
149+
150+
it('adds no stored column: the formula is computed on read', async () => {
151+
const columns = Object.keys(await driver.getKnex()(OBJECT).columnInfo());
152+
expect(columns).toContain('delegator_id');
153+
expect(columns).not.toContain('display_title');
154+
});
155+
156+
it('provisions no search companion column, even where pinyin search is on', () => {
157+
// The companion (`__search`) is a real column fed by the title field. A
158+
// registry provisions it only where pinyin search is on, by running
159+
// `provisionSearchCompanion` over the body it has just designated, so run
160+
// that step over the registered body. A formula title is never a source.
161+
const registered = engine.registry.getObject(OBJECT) as any;
162+
expect(resolveSearchCompanionSources(registered)).toEqual([]);
163+
expect(provisionSearchCompanion(registered).fields[SEARCH_COMPANION_FIELD]).toBeUndefined();
164+
});
165+
});

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 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_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
@@ -40,6 +41,15 @@ export const SysApprovalDelegation = ObjectSchema.create({
4041
managedBy: 'system-data',
4142
description:
4243
'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
44+
// [ADR-0079] The record title is `display_title`, a text formula over the
45+
// same two columns `titleFormat` names. With no pointer declared, the
46+
// registry's designate-only pass stamped `nameField: 'id'` (the first
47+
// title-eligible field), so a renderer honouring ADR-0079's order (an
48+
// explicit `nameField` wins over `titleFormat`) drew the raw id as the record
49+
// page's H1. `titleFormat` stays for renderers that still read it first;
50+
// `sys-approval-delegation-display-title.test.ts` holds the two to the same text.
51+
displayNameField: 'display_title',
52+
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
4353
titleFormat: '{delegator_id} → {delegate_id}',
4454
highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],
4555

@@ -62,6 +72,17 @@ export const SysApprovalDelegation = ObjectSchema.create({
6272
fields: {
6373
id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }),
6474

75+
// [ADR-0079] The record title (`nameField` above). A formula is computed on
76+
// read and has no stored column. Both source columns are required, so the
77+
// expression needs no null guard.
78+
display_title: Field.formula({
79+
label: 'Title',
80+
returnType: 'text',
81+
expression: F`record.delegator_id + ' → ' + record.delegate_id`,
82+
description: 'Record title: the delegator and the delegate (computed on read)',
83+
group: 'Delegation',
84+
}),
85+
6586
delegator_id: Field.lookup('sys_user', {
6687
label: 'Delegator',
6788
required: true,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
302302
id: {
303303
label: "Delegation ID"
304304
},
305+
display_title: {
306+
label: "Title",
307+
help: "Record title: the delegator and the delegate (computed on read)"
308+
},
305309
delegator_id: {
306310
label: "Delegator",
307311
help: "The user going out of office; their individually-routed approver slots are rerouted while active."

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
302302
id: {
303303
label: "Delegation ID"
304304
},
305+
display_title: {
306+
label: "Title",
307+
help: "Record title: the delegator and the delegate (computed on read)"
308+
},
305309
delegator_id: {
306310
label: "Delegator",
307311
help: "The user going out of office; their individually-routed approver slots are rerouted while active."

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
2828
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
2929
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
3030
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
31+
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
32+
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
3133
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
3234
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
3335
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
302302
id: {
303303
label: "Delegation ID"
304304
},
305+
display_title: {
306+
label: "Title",
307+
help: "Record title: the delegator and the delegate (computed on read)"
308+
},
305309
delegator_id: {
306310
label: "Delegator",
307311
help: "The user going out of office; their individually-routed approver slots are rerouted while active."

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
2727
"objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
2828
"objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
2929
"objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
30+
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
31+
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
3032
"objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
3133
"objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
3234
"objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
302302
id: {
303303
label: "委派 ID"
304304
},
305+
display_title: {
306+
label: "Title",
307+
help: "Record title: the delegator and the delegate (computed on read)"
308+
},
305309
delegator_id: {
306310
label: "委派人",
307311
help: "即将不在岗的用户;规则生效期间,路由到其个人的审批人槽位将被改派。"

‎packages/plugins/plugin-approvals/src/translations/zh-CN.source-hashes.generated.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
*/
1919

2020
export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
21+
"objects.sys_approval_delegation.fields.display_title.help": "2831a1ffde72b425",
22+
"objects.sys_approval_delegation.fields.display_title.label": "70f7aadecce647a5",
2123
"objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
2224
"objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
2325
"objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",

0 commit comments

Comments
 (0)