diff --git a/.changeset/29007-object-title-format-refused.md b/.changeset/29007-object-title-format-refused.md new file mode 100644 index 00000000000..d879aeb169f --- /dev/null +++ b/.changeset/29007-object-title-format-refused.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': minor +'@objectstack/plugin-approvals': minor +'@objectstack/plugin-audit': minor +'@objectstack/plugin-security': minor +'@objectstack/plugin-sharing': minor +'@objectstack/plugin-webhooks': minor +'@objectstack/service-automation': minor +'@objectstack/service-mailbox': minor +'@objectstack/service-messaging': minor +'@objectstack/service-realtime': minor +'@objectstack/service-storage': minor +--- + +feat!: an object that declares `titleFormat` is refused at save and by `os validate`, with the `nameField` prescription (#29007) + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** accept-set narrowing on `object.titleFormat`. The key stays in the schema; what changes is which doors accept it. + +- **Before.** `titleFormat` was deprecated (ADR-0079) and still accepted: a save, a publish and `os validate` all passed, with an advisory `title-format-retired` warning on the CLI only. It titled nothing. An explicit `nameField` outranks it, and the registry stamps a derived `nameField` on every object that authors none, so the record page and the list showed one field however the template was written. +- **Now.** `title-format-retired` is an error. An object definition that declares `titleFormat` is refused by `os validate`, `os build` and `os lint`, and at the metadata save gate (`422 INVALID_METADATA`): an active save, a draft promotion and a restore. A draft save still lands, and its `_diagnostics` reads `valid: false` with the refusal its publish gives. The message and the fix line are the prescription the `titleFormat` describe already carried. +- **Unchanged.** A stored object that carries `titleFormat` still parses, loads and is served; no conversion drops the key. It is refused only when it is next saved, published or restored. The title order is unchanged: `nameField`, then its alias `displayNameField`, then the derivation. +- **The platform's own objects.** The 68 system objects that declared `titleFormat` no longer do. 65 already declared the `nameField` that titles them. `sys_attachment`, `sys_audience_binding_suggestion` and `sys_notification_template` now declare the field the registry already stamped (`file_name`, `permission_set_name`, `subject`), so every record title reads as before. +- **`@objectstack/lint`.** `validateTitleFormatRetired` is a new export and a new gating registry entry. `validateRecordTitle` now reports `title-unresolvable` only. + +**The remedy.** Point `nameField` at the title field and drop `titleFormat`. For a composite title that an import, a sort or a search must match, add a `text` field that declares an `expression` (computed and stored on every write) and point `nameField` at it; a `formula` with `returnType: 'text'` stays legal for a title shown for display only. The upgrade guide entry is `object-title-format-refused`. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 240b1d26ff1..f5e43d57fc2 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -67,7 +67,7 @@ export const Account = ObjectSchema.create({ | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | `nameField` | `string` | optional | The stored field used as the record display name, e.g. `'name'` or `'title'` (ADR-0079). The deprecated alias `displayNameField` is still accepted. | -| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence | +| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence. An object definition that declares it is refused at save and by `os validate`; a stored object that carries it still loads | | `imageField` | `string` | optional | The record's picture: names a field of this object whose type is `image` or `avatar` — any other name is refused when the object is validated or published. It is the one object-level declaration the record page header draws beside the title; a record whose field is empty shows no picture. | | `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout` — the old spelling was retired and is now rejected) | | `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) | diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 068d55a878e..b05a54010b3 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -56,7 +56,7 @@ export const MyObject = ObjectSchema.create({ | `icon` | string | Icon identifier | `'building'` | | `description` | string | Help text | `'Companies...'` | | `nameField` | string | Canonical primary title field — the stored field used as the record display name (ADR-0079); the deprecated alias `displayNameField` is still accepted | `'name'` | -| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence | `'{{record.name}} - {{record.id}}'` | +| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence. Refused at save and by `os validate`; a stored object that carries it still loads | `'{{record.name}} - {{record.id}}'` | | `highlightFields` | string[] | Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly `compactLayout`; the old spelling was retired and is now rejected) | `['name', 'status']` | ### Enable Features diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 36a75a0198d..dbecc9071ef 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 8027e3cc01a..61ebd4c19bf 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -760,7 +760,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/content/docs/frontend/building-crud-app.mdx b/content/docs/frontend/building-crud-app.mdx index 1922dc76381..8502a93d748 100644 --- a/content/docs/frontend/building-crud-app.mdx +++ b/content/docs/frontend/building-crud-app.mdx @@ -123,7 +123,7 @@ export const TaskSchema = { name: 'task', label: 'Task', icon: 'check-circle-2', - titleFormat: '{title}', + nameField: 'title', fields, listViews: { all: { diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 2c5a5cea0e3..d94b2c3aff6 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -310,7 +310,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (62)... + → Running author-time rules (63)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index ac8a8ce6ad0..8ca8f3f4b66 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -958,7 +958,7 @@ Metadata query with filtering, sorting, and pagination | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 355375fdff2..849cc844d6d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -162,7 +162,7 @@ const result = ApiMethod.parse(data); | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 66bd4c1bd12..04cdfc47ad7 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -348,7 +348,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | @@ -647,7 +647,7 @@ Create a new object | **activityMilestones** | `{ field: string; value: string; summary: string; type?: string }[]` | optional | Declarative semantic activity milestones — emit a templated timeline row when a field transitions into a value, no hook code (ADR-0052 §5b.2). | | **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). | | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | -| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | +| **titleFormat** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written `{{field}}` or `{field}` — the title renderers treat the two as equivalent, normalizing `{{field}}` to `{field}` before substituting; neither spelling is judged at parse time. | | **imageField** | `string` | optional | The record's picture: names the field the record page header (record chrome) draws beside the title — one object-level declaration every record detail page reads, not a per-page header prop. Must name a field of this object whose type is `image` or `avatar`; any other name is refused. A record whose field is empty shows no picture (no initials or placeholder). | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **relatedLists** | `string[]` | optional | Business order of this object's related lists on its record page, most important first. Each entry is a child object name (e.g. "task"), or object.field when that child has more than one relationship to this object (e.g. "event.related_to"). Listed relationships come first in this order; unlisted ones follow in the derived order (owned master_detail children, then lookup children). An entry whose child is in the stack but has no inbound lookup / master_detail field to this object is refused everywhere. An entry whose child exists nowhere is refused by os validate / os build (which leave a sys_ / cloud_ / ai_ name to the runtime) and by a metadata write that names no package; a write into a package stores it, since a child outside the package may be a downstream package's object that points here, and the record page places nothing for it. It orders only: relatedList: false on the relationship still hides a list, and relatedList: 'primary' still places a list in the record's body, so the order applies among the body tables and among the side cards. | diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 7e923e55f21..ccc38f7b4b1 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -404,7 +404,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (62)... + → Running author-time rules (63)... → Checking that every required capability has a provider installable in this edition... → Checking package docs (ADR-0046)... diff --git a/docs/notes/crm-development-standards.mdx b/docs/notes/crm-development-standards.mdx index 54810f9e7ab..481a595fb49 100644 --- a/docs/notes/crm-development-standards.mdx +++ b/docs/notes/crm-development-standards.mdx @@ -94,7 +94,7 @@ export const MyObject = ObjectSchema.create({ description: '...', // Help text // ✅ Display Configuration - titleFormat: '{field1} - {field2}', + nameField: 'field1', // Record title field (ADR-0079); never titleFormat highlightFields: ['field1', 'field2', 'field3'], // ✅ Fields Definition diff --git a/packages/cli/test/validate-title-format-retired.test.ts b/packages/cli/test/validate-title-format-retired.test.ts new file mode 100644 index 00000000000..fd22e29a0fa --- /dev/null +++ b/packages/cli/test/validate-title-format-retired.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#29007] `os validate` refuses an object definition that declares the retired + * `titleFormat` — the ruling of record (6574393684, letter B). The verdict is + * the registry's `validateTitleFormatRetired` (`@objectstack/lint`), the same + * entry the metadata save gate runs; what is pinned here is the command: the + * card's reproduction exits 1 with `title-format-retired` and its prescription, + * and the same object pointing `nameField` at the field validates clean. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +/** The card's object, with either the retired template or the pointer. */ +const stack = (title: string) => ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.acme.titles', name: 'titles', version: '1.0.0', type: 'app' }, + objects: [ + { + name: 'qar8_tf2', + label: 'Title Format', + sharingModel: 'public_read_write', + ${title}, + fields: { + code: { type: 'text', label: 'Code' }, + city: { type: 'text', label: 'City' }, + }, + }, + ], +}, { strict: false }); +`; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runValidate(cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, 'validate', '--json'], + // `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env). + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +const dirs: Record<'templated' | 'pointed', string> = { templated: '', pointed: '' }; + +beforeAll(() => { + const make = (key: keyof typeof dirs, source: string) => { + dirs[key] = mkdtempSync(join(tmpdir(), `os-validate-title-format-${key}-`)); + writeFileSync(join(dirs[key], 'objectstack.config.ts'), source); + linkSpec(dirs[key]); + }; + make('templated', stack(`titleFormat: '{code} @ {city}'`)); + make('pointed', stack(`nameField: 'code'`)); +}); + +afterAll(() => { + for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +describe('#29007 — `os validate` refuses the retired titleFormat', () => { + it('an object declaring titleFormat exits 1 with title-format-retired and its prescription', async () => { + const run = await runValidate(dirs.templated); + expect(run.code, run.stdout + run.stderr).toBe(1); + const payload = JSON.parse(run.stdout) as { valid: boolean; errors: Array> }; + expect(payload.valid).toBe(false); + const refusal = payload.errors.find((e) => JSON.stringify(e).includes('title-format-retired')); + expect(refusal, JSON.stringify(payload.errors)).toBeDefined(); + expect(JSON.stringify(refusal)).toContain('migrate to nameField'); + }, 120_000); + + it('CONTROL — the same object pointing nameField at the field validates clean', async () => { + const run = await runValidate(dirs.pointed); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect((JSON.parse(run.stdout) as { valid: boolean }).valid).toBe(true); + }, 120_000); +}); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 06a65c9ae40..e875650836f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -125,7 +125,7 @@ import { validateFlowTriggerReadiness, } from './validate-flow-trigger-readiness.js'; import { validateApprovalApprovers } from './validate-approval-approvers.js'; -import { validateRecordTitle } from './validate-record-title.js'; +import { validateRecordTitle, validateTitleFormatRetired } from './validate-record-title.js'; import { validateFieldConsumers } from './validate-field-consumers.js'; import { validateSemanticRoles } from './validate-semantic-roles.js'; import { validateFormLayout } from './validate-form-layout.js'; @@ -1548,9 +1548,31 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['flow'], run: (stack) => validateApprovalApprovers(stack), }, - // ADR-0079 — `titleFormat` is retired in favour of `nameField`, and an object - // with no resolvable title ships records with no meaningful name. Advisory: - // auto-provision and the `Record #` floor keep it from ever being fatal. + // ADR-0079 — `titleFormat` is retired in favour of `nameField`, and the + // ruling of record on #29007 (6574393684, letter B) refuses it: an object + // definition that declares one is refused at the metadata save gate and by + // `os validate`. `gating` because a declared template titles nothing — the + // explicit or stamped `nameField` outranks it — so a save that accepts it is + // a silent no-op. The schema still parses the key, so a stored object that + // carries it keeps loading; the runtime gate charges a finding on the + // written item to the write itself (`runtime-gate.ts`, "Re-saving a row is + // writing it"), so the row is refused when it is next saved, and a draft + // save passes until it is published (#4463 D1). Pre-parse: the predicate + // reads one authored key, and the finding must survive an unrelated schema + // error elsewhere in the stack. + { + name: 'validateTitleFormatRetired', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-record-title.ts', + surfaces: CLI_AND_RUNTIME, + runtimeTypes: ['object'], + run: (stack) => validateTitleFormatRetired(stack), + }, + // ADR-0079 — an object with no resolvable title ships records with no + // meaningful name. Advisory: auto-provision and the `Record #` floor + // keep it from ever being fatal. { name: 'validateRecordTitle', tier: 'advisory', diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 78304c86a69..9837ffc8c3e 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -235,6 +235,7 @@ export type { SourceStyleFinding, SourceStyleSeverity } from './validate-page-so export { validateRecordTitle, + validateTitleFormatRetired, TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE, } from './validate-record-title.js'; diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 14fb6b18b64..ca36ea0e103 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3039,8 +3039,12 @@ const TITLE_FORMAT_RETIRED_EXPLANATION: RuleExplanation = { 'write; point `nameField` at it. A formula with `returnType: \'text\'` is title-eligible too and ' + 'stays legal for a title shown for display only: its value is computed on read and stored nowhere, ' + 'so a sort by it is refused, search never reaches it, and an import cannot match a lookup cell ' + - 'against it. Then drop `titleFormat`, which this warning reports for as long as it is declared.', - 'A warning, not an error: the schema still parses the key, so existing metadata keeps loading.', + 'against it. Then drop `titleFormat`: an object definition that declares it is refused at the ' + + 'metadata save gate (an active save, a draft promotion, a restore) and by `os validate`, ' + + '`os build` and `os lint`.', + 'An error, not a warning, though the schema still parses the key: a stored object that carries it ' + + 'keeps loading and reading, and is refused only when it is next saved or validated. A draft ' + + 'save passes; its publish is refused.', ], }; diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index d6bd0181ecb..05d55b959b5 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -144,6 +144,10 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () // leaving the object door is caught by something even though this exact // list cannot move. 'validateReferenceIntegrity', + // [#29007] A ruled join (6574393684, letter B): `titleFormat` is refused + // at the metadata save gate and by `os validate`. Its refusal and the + // re-save of a stored row that carries it are in the cases below. + 'validateTitleFormatRetired', 'lintAutonumberFormats', // A ruled join: the card's acceptance names this door. R13 judges a // roll-up's aggregand against the child objects the snapshot carries @@ -454,6 +458,32 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () ); }); + it('REFUSES an object that declares a titleFormat (validateTitleFormatRetired)', () => { + // [#29007] Ruling of record 6574393684, letter B: the stamped or explicit + // `nameField` outranks the template, so a save that accepted it was a + // silent no-op. The refusal carries the describe's prescription. + const result = expectSingleRefusal(cleanObject({ titleFormat: '{owner} @ {owner}' }), 'title-format-retired'); + const f = result.errors.find((e) => e.rule === 'title-format-retired')!; + expect(f.message).toContain('migrate to nameField'); + expect(result.errors.map((e) => e.rule)).toEqual(['title-format-retired']); + }); + + it('REFUSES the re-save of a stored object that already carries a titleFormat — "Re-saving a row is writing it"', () => { + // [#29007] The stored row keeps parsing and reading; it is refused when it + // is next saved. The differential charges a finding on the written item + // to the write whatever the stored row held. + const stored = [{ ...STORED[0], titleFormat: '{owner}' }]; + const result = runRuntimeAuthoringRules({ + type: 'object', + item: cleanObject({ titleFormat: '{owner}' }), + context: { objects: stored }, + }); + expect(result.errors.map((e) => e.rule)).toEqual(['title-format-retired']); + // …and the same re-save without the key publishes (the control). + const healed = runRuntimeAuthoringRules({ type: 'object', item: cleanObject(), context: { objects: stored } }); + expect(healed.errors, JSON.stringify(healed.errors)).toEqual([]); + }); + // ── What must NOT change ───────────────────────────────────────────── it('publishes a clean object write with every rule having RUN', () => { @@ -467,6 +497,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () 'validateManagedApiMethods', 'validatePresetComparands', 'validateReferenceIntegrity', // [#15254] + 'validateTitleFormatRetired', // [#29007] — see the roster pin above 'lintAutonumberFormats', 'lintSummaryRollups', // see the roster pin above 'validateSecurityPosture', diff --git a/packages/lint/src/validate-record-title.test.ts b/packages/lint/src/validate-record-title.test.ts index cc4ea39f33f..2795f9b97c8 100644 --- a/packages/lint/src/validate-record-title.test.ts +++ b/packages/lint/src/validate-record-title.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data'; import { validateRecordTitle as validateRecordTitleUnrecorded, + validateTitleFormatRetired as validateTitleFormatRetiredUnrecorded, TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE, } from './validate-record-title.js'; @@ -16,6 +17,16 @@ const validateRecordTitle: typeof validateRecordTitleUnrecorded = (...args) => { fired.push(...findings); return findings; }; +const validateTitleFormatRetired: typeof validateTitleFormatRetiredUnrecorded = (...args) => { + const findings = validateTitleFormatRetiredUnrecorded(...args); + fired.push(...findings); + return findings; +}; +/** Both record-title rules, as one stack sees them across the two registry entries. */ +const bothRules = (stack: Record) => [ + ...validateTitleFormatRetired(stack), + ...validateRecordTitle(stack), +]; /** One-object stack; `fields` is a name-keyed map (the parsed-config shape). */ function objStack(obj: Record) { @@ -45,21 +56,36 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { expect(findings).toHaveLength(0); }); - it('warns (not errors) when titleFormat is declared', () => { - const findings = validateRecordTitle( - objStack({ - titleFormat: '{{record.first}} {{record.last}}', - fields: { name: { type: 'text' } }, - }), - ); + it('REFUSES (errors) when titleFormat is declared — ruling of record 6574393684, letter B', () => { + const stack = objStack({ + titleFormat: '{{record.first}} {{record.last}}', + fields: { name: { type: 'text' } }, + }); + const findings = validateTitleFormatRetired(stack); expect(findings).toHaveLength(1); - expect(findings[0].severity).toBe('warning'); + expect(findings[0].severity).toBe('error'); expect(findings[0].rule).toBe(TITLE_FORMAT_RETIRED); expect(findings[0].where).toBe('object "invoice"'); expect(findings[0].path).toBe('objects[0]'); expect(findings[0].message).toContain('titleFormat is retired (ADR-0079)'); expect(findings[0].message).toContain('migrate to nameField'); expect(findings[0].hint).toContain('nameField'); + // The title pair is the other entry's: a resolvable object with a + // template draws nothing from `validateRecordTitle`. + expect(validateRecordTitle(stack)).toHaveLength(0); + }); + + it('a single-field and an expression-envelope template are refused alike', () => { + for (const titleFormat of ['{name}', { dialect: 'template', source: '{name} · {code}' }]) { + const findings = validateTitleFormatRetired(objStack({ titleFormat, fields: { name: { type: 'text' } } })); + expect(findings.map((f) => [f.rule, f.severity]), JSON.stringify(titleFormat)).toEqual([ + [TITLE_FORMAT_RETIRED, 'error'], + ]); + } + }); + + it('an object with no titleFormat draws nothing from the refusal (control)', () => { + expect(validateTitleFormatRetired(objStack({ nameField: 'name', fields: { name: { type: 'text' } } }))).toEqual([]); }); it('warns (not errors) when no title is resolvable (status: none)', () => { @@ -83,29 +109,28 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { }); it('reports BOTH rules when an object has titleFormat and no resolvable title', () => { - const findings = validateRecordTitle( + const findings = bothRules( objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } }, }), ); expect(findings).toHaveLength(2); - expect(findings.map((f) => f.rule).sort()).toEqual( - [TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE].sort(), + expect(findings.map((f) => [f.rule, f.severity]).sort()).toEqual( + [[TITLE_FORMAT_RETIRED, 'error'], [TITLE_UNRESOLVABLE, 'warning']].sort(), ); - expect(findings.every((f) => f.severity === 'warning')).toBe(true); }); it('an empty-string titleFormat is not flagged', () => { - const findings = validateRecordTitle( + const findings = bothRules( objStack({ titleFormat: '', fields: { name: { type: 'text' } } }), ); expect(findings).toHaveLength(0); }); it('a stack with no objects is clean', () => { - expect(validateRecordTitle({})).toHaveLength(0); - expect(validateRecordTitle({ objects: [] })).toHaveLength(0); + expect(bothRules({})).toHaveLength(0); + expect(bothRules({ objects: [] })).toHaveLength(0); }); }); @@ -115,7 +140,7 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { describe('[#22161] one-line fixes — title-format-retired and title-unresolvable', () => { it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { // A long object name, so the bound is not read on a short one alone. - validateRecordTitle({ + bothRules({ objects: [{ name: 'regional_enterprise_account_hierarchy_snapshot', titleFormat: '{{record.a}}', fields: {} }], }); const converted = fired.filter((f) => f.rule === TITLE_FORMAT_RETIRED || f.rule === TITLE_UNRESOLVABLE); @@ -128,7 +153,7 @@ describe('[#22161] one-line fixes — title-format-retired and title-unresolvabl }); it('each reads as one remedy', () => { - const findings = validateRecordTitle(objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } } })); + const findings = bothRules(objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } } })); const byRule = new Map(findings.map((f) => [f.rule, f.hint])); expect(byRule.get(TITLE_FORMAT_RETIRED)).toBe( "Set nameField: '' for one field; a composite title to import, sort or search by is a text " + @@ -148,6 +173,8 @@ describe('[#22161] one-line fixes — title-format-retired and title-unresolvabl 'an explicit `nameField` takes precedence', 'ADR-0079', 'the schema still parses the key', + 'refused at the metadata save gate', + '`os validate`', ], [TITLE_UNRESOLVABLE]: [ 'auto-provisions a primary', diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts index 9473d8f3a68..59e6a8986d4 100644 --- a/packages/lint/src/validate-record-title.ts +++ b/packages/lint/src/validate-record-title.ts @@ -14,20 +14,28 @@ import { recordsOf } from './object-graph.js'; * the MCP authoring surface, and hand-authoring all get the coverage cloud * graph-lint already has (the ADR-0078 "not cloud-only" principle): * - * - `title-format-retired` — the object declares a `titleFormat`. That field - * is a RENDER-ONLY template the server can neither return nor query; ADR-0079 - * retires it in favour of `nameField`. The schema still parses it (existing - * metadata keeps loading), so this is advisory, not an error. - * - `title-unresolvable` — `objectTitleCompleteness` reports `status: 'none'`: - * no `nameField`/`displayNameField` pointer and no title-eligible field to + * - `title-format-retired` ({@link validateTitleFormatRetired}) — the object + * declares a `titleFormat`. That field is a RENDER-ONLY template the server + * can neither return nor query; ADR-0079 retires it in favour of `nameField`, + * and an explicit or stamped `nameField` outranks it, so a declared template + * titles nothing. An ERROR, under the ruling of record on #29007 (6574393684, + * letter B): an object definition that declares it is refused at the + * metadata save gate (the runtime publish gate, which an active save, a draft + * promotion and a restore all run) and by `os validate` / `os build` / + * `os lint`. The schema still parses the key, so a stored object that carries + * it keeps loading and reading; it is refused only when it is next saved or + * validated. Its own registry entry, apart from the title pair below, + * because the two rules sit on different tiers. + * - `title-unresolvable` ({@link validateRecordTitle}) — + * `objectTitleCompleteness` reports `status: 'none'`: no + * `nameField`/`displayNameField` pointer and no title-eligible field to * derive one from. Records will have no meaningful title (the runtime falls * back to the auto-provisioned primary / `Record #` floor), so this is a - * warning, not an error — nothing is fully broken. - * - * Both are warnings: the auto-provision transform and the id floor mean a - * green build never ships a fully title-less object. Reuses the shared spec - * predicate (`@objectstack/spec/data` → display-name) so cloud and framework - * classify titles identically. + * warning, not an error — nothing is fully broken: the auto-provision + * transform and the id floor mean a green build never ships a fully + * title-less object. Reuses the shared spec predicate + * (`@objectstack/spec/data` → display-name) so cloud and framework classify + * titles identically. */ export const TITLE_FORMAT_RETIRED = 'title-format-retired'; @@ -36,7 +44,7 @@ export const TITLE_UNRESOLVABLE = 'title-unresolvable'; export type RecordTitleSeverity = 'error' | 'warning'; export interface RecordTitleFinding { - /** Always `warning` today — both rules are advisory (see module note). */ + /** `error` for `title-format-retired`, `warning` for `title-unresolvable` (see module note). */ severity: RecordTitleSeverity; /** Diagnostic rule id (registry entry), e.g. `title-format-retired`. */ rule: string; @@ -53,12 +61,16 @@ export interface RecordTitleFinding { type AnyRec = Record; /** - * Validate every object's record-title declaration. Returns the list of - * findings (empty = clean). Both rules are advisory (`warning`): the caller - * must never fail the build on them alone — auto-provision + the `Record #` - * floor guarantee a resolvable title at runtime. + * Refuse every object that declares a `titleFormat` (ADR-0079; the ruling of + * record on #29007). Returns one `error` per declaring object, empty when none + * does. The message and the fix line are the prescription the `titleFormat` + * describe carries: a single-field title is `nameField`; a composite title is a + * stored computed field `nameField` points at. + * + * Reads the authored key only, so it runs pre-parse: an empty string is no + * declaration (the schema refuses a blank template on its own). */ -export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { +export function validateTitleFormatRetired(stack: AnyRec): RecordTitleFinding[] { const findings: RecordTitleFinding[] = []; const objects = recordsOf(stack.objects); @@ -68,12 +80,9 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { const where = `object "${objName}"`; const path = `objects[${i}]`; - // ── (a) titleFormat is retired (ADR-0079) ── - // Render-only template the server cannot return or query. Still parsed by - // the schema for back-compat, so advisory. if (obj.titleFormat !== undefined && obj.titleFormat !== null && obj.titleFormat !== '') { findings.push({ - severity: 'warning', + severity: 'error', rule: TITLE_FORMAT_RETIRED, where, path, @@ -88,8 +97,29 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { `text field with an expression, stored on write (a text formula is display-only); drop titleFormat`, }); } + } + + return findings; +} + +/** + * Validate every object's record-title resolvability (`title-unresolvable`). + * Returns the list of findings (empty = clean). Advisory (`warning`): the + * caller must never fail the build on it alone — auto-provision + the + * `Record #` floor guarantee a resolvable title at runtime. A declared + * `titleFormat` is {@link validateTitleFormatRetired}'s, not this rule's. + */ +export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { + const findings: RecordTitleFinding[] = []; + + const objects = recordsOf(stack.objects); + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const where = `object "${objName}"`; + const path = `objects[${i}]`; - // ── (b) no resolvable title (status: 'none') ── + // ── no resolvable title (status: 'none') ── // Reuse the shared spec predicate so cloud graph-lint and framework lint // classify titles identically. `none` = no pointer AND nothing derivable. const completeness = objectTitleCompleteness(obj as DisplayNameObjectMeta); diff --git a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts index bb4f8732a51..3b48edf20bd 100644 --- a/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts +++ b/packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts @@ -283,6 +283,24 @@ const CASES: Array<{ rule: string; type: string; item: { name: string } & Record }, }, }, + { + // [#29007] The card's reproduction: a `titleFormat` on an object that + // authors no `nameField`. The stamped pointer outranks the template, + // so the save was a silent no-op; the ruling of record (6574393684, + // letter B) refuses it at the save gate. + rule: 'title-format-retired', + type: 'object', + item: { + name: 'qar8_tf2', + label: 'Title Format', + sharingModel: 'public_read_write', + titleFormat: '{code} @ {city}', + fields: { + code: { type: 'text', label: 'Code' }, + city: { type: 'text', label: 'City' }, + }, + }, + }, { // Reproduction 5: a dashboard global filter on a field the bound // widget's object does not have. @@ -813,3 +831,45 @@ describe('[#27445] a stored dashboard carrying an undeclared filterBindings key' expect(issue.message).toContain('(declared: region)'); }); }); + +describe('[#29007] a stored object carrying the retired titleFormat', () => { + const titled = CASES.find((c) => c.rule === 'title-format-retired')!.item; + + it('still loads and reads the key — the schema parses it, no conversion drops it', async () => { + const { engine, rows } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + // A row written before the refusal: the save path with the authoring + // gate switched off stores the body verbatim, as a pre-ruling write did. + process.env.OS_ALLOW_UNLINTED_METADATA_WRITES = '1'; + try { + const saved: any = await (protocol as any).saveMetaItem({ type: 'object', name: titled.name, item: titled, packageId: PKG }); + expect(saved.success).toBe(true); + } finally { + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + } + expect([...rows.values()].filter((r) => r.name === titled.name).map((r) => r.state)).toEqual(['active']); + + const read: any = await protocol.getMetaItem({ type: 'object', name: titled.name, packageId: PKG } as any); + const tf = read.item.titleFormat; + expect(typeof tf === 'string' ? tf : tf?.source).toBe('{code} @ {city}'); + }); + + it('is refused on its next save with the prescription, and the same body without it saves', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const err: any = await (protocol as any) + .saveMetaItem({ type: 'object', name: titled.name, item: titled, packageId: PKG }) + .then(() => null, (e: unknown) => e); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'title-format-retired'); + expect(issue, JSON.stringify(err.issues)).toBeDefined(); + expect(issue.message).toContain('migrate to nameField'); + + // The control: the author's fix — the pointer, no template — saves. + const { titleFormat: _dropped, ...fixed } = titled as Record; + const saved: any = await (protocol as any).saveMetaItem({ + type: 'object', name: titled.name, item: { ...fixed, nameField: 'code' }, packageId: PKG, + }); + expect(saved.success).toBe(true); + }); +}); diff --git a/packages/platform-objects/src/audit/sys-attachment.object.ts b/packages/platform-objects/src/audit/sys-attachment.object.ts index b86b64f4e54..5db8da91877 100644 --- a/packages/platform-objects/src/audit/sys-attachment.object.ts +++ b/packages/platform-objects/src/audit/sys-attachment.object.ts @@ -33,7 +33,10 @@ export const SysAttachment = ObjectSchema.create({ isSystem: true, managedBy: 'platform', description: 'Polymorphic link between a sys_file and any other record', - titleFormat: '{file_name} → {parent_object}/{parent_id}', + // [ADR-0079] The record title: the file's name, the field the registry + // stamped when the retired `titleFormat` template stood here (an object + // definition that declares one is refused at save and by `os validate`). + nameField: 'file_name', highlightFields: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'], fields: { diff --git a/packages/platform-objects/src/audit/sys-email-template.object.ts b/packages/platform-objects/src/audit/sys-email-template.object.ts index f3809d9378a..01740512c52 100644 --- a/packages/platform-objects/src/audit/sys-email-template.object.ts +++ b/packages/platform-objects/src/audit/sys-email-template.object.ts @@ -34,7 +34,6 @@ export const SysEmailTemplate = ObjectSchema.create({ description: 'Outbound email template (subject + body + variables) resolved by name+locale', displayNameField: 'label', nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'label', 'category', 'locale', 'active'], fields: { diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 5a3beae402b..e013ce94b74 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -28,7 +28,6 @@ export const SysEmail = ObjectSchema.create({ description: 'Outbound email delivery log', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], fields: { diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts index ccb913bfefd..47d778ac470 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -48,7 +48,6 @@ export const SysIdentityEmail = ObjectSchema.create({ description: 'Outbound identity mail log — verification, password reset and sign-in mail about a person, not an organization', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], // `sys_email`'s fields, written out: the source scans that judge an diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 53c3a19151c..5c63f05bc44 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -29,7 +29,6 @@ export const SysImportJob = ObjectSchema.create({ description: 'Asynchronous bulk-import job state, progress, and history', displayNameField: 'object_name', nameField: 'object_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{object_name} import @ {created_at}', highlightFields: ['object_name', 'status', 'processed_rows', 'total_rows', 'created_at'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index b145a722d9e..ba1d05c4fe2 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -96,7 +96,6 @@ export const SysJobQueue = ObjectSchema.create({ description: 'Durable job/message queue including dead letters', displayNameField: 'queue', nameField: 'queue', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{queue} #{id}', highlightFields: ['queue', 'status', 'attempts', 'scheduled_for', 'last_error'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job-run.object.ts b/packages/platform-objects/src/audit/sys-job-run.object.ts index f55bb34903d..50e726d43ca 100644 --- a/packages/platform-objects/src/audit/sys-job-run.object.ts +++ b/packages/platform-objects/src/audit/sys-job-run.object.ts @@ -42,7 +42,6 @@ export const SysJobRun = ObjectSchema.create({ description: 'Background job execution history', displayNameField: 'job_name', nameField: 'job_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{job_name} @ {started_at}', highlightFields: ['job_name', 'status', 'started_at', 'duration_ms', 'attempt'], fields: { diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index a9d9fa6788d..236e6a4ae6d 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -38,7 +38,6 @@ export const SysJob = ObjectSchema.create({ description: 'Catalogue of registered background jobs', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'schedule_type', 'active', 'last_run_at', 'last_status'], fields: { diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index c43614f2c2c..24683f84561 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -44,7 +44,6 @@ export const SysNotification = ObjectSchema.create({ description: 'Every notification the platform sent, one row per event.', displayNameField: 'topic', nameField: 'topic', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{topic}', highlightFields: ['topic', 'severity', 'source_object', 'created_at'], listViews: { diff --git a/packages/platform-objects/src/identity/identity-display-title.test.ts b/packages/platform-objects/src/identity/identity-display-title.test.ts index 9a36e3c468a..8f1f68fb614 100644 --- a/packages/platform-objects/src/identity/identity-display-title.test.ts +++ b/packages/platform-objects/src/identity/identity-display-title.test.ts @@ -56,6 +56,26 @@ import { SysTeamMember } from './sys-team-member.object.js'; import { SysTwoFactor } from './sys-two-factor.object.js'; import { SysVerification } from './sys-verification.object.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + 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})', + sys_scim_group_member: '{scim_user_id} in {group_id}', + sys_scim_projection_grant: '{role} → {user_id}', + sys_scim_subject: '{user_id}', + sys_team_member: '{user_id} in {team_id}', + sys_two_factor: 'Two-factor for {user_id}', + sys_verification: 'Verification', +}; + type Row = Record; type Schema = { name: string; nameField?: string; displayNameField?: string; titleFormat?: unknown; fields: Record }; @@ -178,7 +198,7 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o 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).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[c.schema.name], c.row)); expect(title).not.toBe(c.row.id); expect(String(title)).not.toContain(String(c.row.id)); }); @@ -196,7 +216,7 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o const row = { id: 'ver_7Rk2', identifier: 'credential-shaped-identifier', value: 'credential-shaped-value' }; const title = String(evaluateDisplayTitle(schema, row)); for (const name of internal) expect(title).not.toContain(String(row[name as 'identifier' | 'value'])); - for (const name of internal) expect(renderTitleFormat(schema.titleFormat, row)).not.toContain(String(row[name as 'identifier' | 'value'])); + for (const name of internal) expect(renderTitleFormat(RETIRED_TITLE_TEMPLATES[schema.name], row)).not.toContain(String(row[name as 'identifier' | 'value'])); for (const name of internal) expect(schema.highlightFields ?? []).not.toContain(name); }); @@ -210,6 +230,6 @@ describe('[#20059] identity objects resolve a real record title under ADR-0079 o 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)); + expect(row[resolveDisplayField(schema as any) as 'user_id']).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[schema.name], row)); }); }); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 33b605b1468..e9f3560fe69 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -28,15 +28,15 @@ export const SysAccount = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. 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'], // Custom actions — sysadmins routinely need to revoke a user's OAuth diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 8378f017b4e..d08f60a3d92 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -77,7 +77,6 @@ export const SysApiKey = ObjectSchema.create({ description: 'API keys for programmatic access', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'prefix', 'user_id', 'expires_at', 'revoked'], // Custom actions — sys_api_key is managed-by 'better-auth' but the 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 618c866da11..87a45d77b98 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 @@ -24,20 +24,17 @@ export const SysBusinessUnitMember = ObjectSchema.create({ 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 + // columns `titleFormat` named. 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, and is the - // rung below the pointer where the title has no value: the console draws a - // lookup placeholder by the related record's name (else its email), so over - // an expanded row it renders the same text the formula does. - // `identity-display-title.test.ts` holds the two to the same text; - // `plugin-security/src/business-unit-member-title.test.ts` pins the title a - // read serves. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it, the rung below the pointer where + // the title had no value, is gone (refused at save and by `os validate`). + // `identity-display-title.test.ts` holds the formula to the text that + // template drew; `plugin-security/src/business-unit-member-title.test.ts` + // pins the title a read serves. 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'], fields: { diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index fc041d5a106..0706460c0cd 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -34,7 +34,6 @@ export const SysBusinessUnit = ObjectSchema.create({ description: 'Your company structure — divisions, departments, regions and offices — used to scope data and reports.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index dd80c97be38..b5f2291f1f2 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -48,7 +48,6 @@ export const SysDeviceCode = ObjectSchema.create({ }, description: 'OAuth 2.0 Device Authorization Grant (RFC 8628) pending requests', nameField: 'user_code', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{user_code}', highlightFields: ['user_code', 'status', 'client_id', 'expires_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index 01eee93d51f..bc61c78d037 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -49,18 +49,18 @@ export const SysInvitation = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. 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. - titleFormat: 'Invitation for {email}', highlightFields: ['email', 'organization_id', 'status'], // Custom actions — generic CRUD is suppressed (better-auth-managed). diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 8407a7f5b83..27c5d3d4dfc 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -35,21 +35,20 @@ export const SysMember = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `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. - titleFormat: '{user_id} ({role})', highlightFields: ['user_id', 'organization_id', 'role'], // Row-level actions: better-auth `organization/update-member-role` and diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 57e34de59fb..37a63b9fd65 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -35,7 +35,6 @@ export const SysOauthApplication = ObjectSchema.create({ description: 'Registered OAuth/OIDC client applications', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'client_id', 'type', 'disabled'], // Custom actions — all OAuth-application mutations are routed through diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 541b3c2b7d2..243be28b896 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -52,7 +52,6 @@ export const SysOrganization = ObjectSchema.create({ description: 'Organizations for multi-tenant grouping', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'slug'], // Custom actions — generic create / delete are suppressed (better-auth- diff --git a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts index 510b3d9ed7a..474eed11b8d 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts @@ -36,7 +36,6 @@ export const SysScimConnectionBinding = ObjectSchema.create({ description: 'Lifecycle state for a SCIM provisioning connection, including its decommission state machine', displayNameField: 'connection_id', nameField: 'connection_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{connection_id}', highlightFields: ['connection_id', 'provisioning_domain_id', 'decommission_status'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts index fb15ba32470..091fd7dd1c3 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts @@ -53,7 +53,6 @@ export const SysScimConnectionCredential = ObjectSchema.create({ description: 'Bearer credentials (one-way digests) that authenticate SCIM provisioning connections', displayNameField: 'connection_id', nameField: 'connection_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{connection_id}', highlightFields: ['connection_id', 'label', 'active', 'expires_at'], listViews: { 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 d27e840feb0..ce5dc7b8b3b 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 @@ -31,18 +31,17 @@ export const SysScimGroupMember = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `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'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-group.object.ts b/packages/platform-objects/src/identity/sys-scim-group.object.ts index 4842d5a2605..f9142bbff96 100644 --- a/packages/platform-objects/src/identity/sys-scim-group.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group.object.ts @@ -35,7 +35,6 @@ export const SysScimGroup = ObjectSchema.create({ description: 'IdP-pushed SCIM 2.0 groups, scoped per provisioning connection', displayNameField: 'display_name', nameField: 'display_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{display_name}', highlightFields: ['display_name', 'connection_id', 'updated_at'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts index 3689408974c..1215a492943 100644 --- a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts @@ -31,7 +31,6 @@ export const SysScimIdentityTombstone = ObjectSchema.create({ description: 'Tombstones of IdP-deleted SCIM identities, kept so a re-provision is recognized', displayNameField: 'external_id', nameField: 'external_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{external_id}', highlightFields: ['external_id', 'connection_id', 'deleted_at'], listViews: { 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 71d597447d0..bfd78c2786e 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 @@ -32,18 +32,17 @@ export const SysScimProjectionGrant = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `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'], listViews: { 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 30e8e4deaa9..afddcb39ab7 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -30,15 +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. + // [ADR-0079] Single-field title: the retired `titleFormat` named 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'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-scim-user.object.ts b/packages/platform-objects/src/identity/sys-scim-user.object.ts index 6e657e220d4..19b70f07379 100644 --- a/packages/platform-objects/src/identity/sys-scim-user.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-user.object.ts @@ -36,7 +36,6 @@ export const SysScimUser = ObjectSchema.create({ description: 'Per-connection projection of an IdP-provisioned user (SCIM 2.0 /Users)', displayNameField: 'user_name', nameField: 'user_name', // [ADR-0079] canonical primary-title pointer - titleFormat: '{user_name}', highlightFields: ['user_name', 'display_name', 'primary_email', 'active'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 705ab266175..37a21f4f020 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -63,7 +63,6 @@ export const SysSession = ObjectSchema.create({ description: 'Active user sessions', displayNameField: 'user_id', nameField: 'user_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: 'Session — {user_id}', highlightFields: ['user_id', 'ip_address', 'expires_at'], // Custom actions — sessions are managed by better-auth (generic CRUD diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 88bb9c49264..e54390e673c 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -67,7 +67,6 @@ export const SysSsoProvider = ObjectSchema.create({ description: 'External SSO identity providers (OIDC / SAML) this system federates login to', displayNameField: 'provider_id', nameField: 'provider_id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{provider_id}', highlightFields: ['provider_id', 'issuer', 'domain'], // All mutations go through @better-auth/sso's endpoints under 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 6309ed8bc46..1fce5098e50 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -28,18 +28,17 @@ export const SysTeamMember = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `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'], // Custom actions calling better-auth's team-member endpoints. Generic diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 5f86d6baa4c..0199ba88f2d 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -28,7 +28,6 @@ export const SysTeam = ObjectSchema.create({ description: 'Teams within organizations for fine-grained grouping', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'organization_id'], // Custom actions calling better-auth's team endpoints. Generic CRUD is 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 fceb27b7130..0f680039ee3 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -28,18 +28,17 @@ export const SysTwoFactor = ObjectSchema.create({ }, 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 + // columns `titleFormat` named. 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. + // wins over `titleFormat`) drew the raw id as the record page's H1. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `identity-display-title.test.ts` holds the formula to the + // text that template drew. `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'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index bbafb52f5f8..113760f0252 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -30,7 +30,6 @@ export const SysUserPreference = ObjectSchema.create({ managedBy: 'system-data', description: 'Per-user key-value preferences (theme, locale, etc.)', nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{key}', highlightFields: ['user_id', 'key'], listViews: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index ea2c0b42458..fa8b00956f4 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -86,7 +86,6 @@ export const SysUser = ObjectSchema.create({ description: 'User accounts for authentication', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'email', 'email_verified'], // Custom actions — generic CRUD is suppressed because user accounts are diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 2c7ca3fb2f8..93946abbb5a 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -37,9 +37,10 @@ export const SysVerification = ObjectSchema.create({ // 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. + // raw id as the record page's H1. The retired `titleFormat` template beside + // it is gone (refused at save and by `os validate`); + // `identity-display-title.test.ts` holds the formula to the text that + // template drew. // // [#21197] The title is a FIXED label, and neither it nor the highlights may // name `identifier` or `value`. Both columns are `internal` (below), and the @@ -50,7 +51,6 @@ export const SysVerification = ObjectSchema.create({ // own name rather than a value. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: 'Verification', highlightFields: ['expires_at', 'created_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy @@ -76,7 +76,7 @@ export const SysVerification = ObjectSchema.create({ // [ADR-0079] The record title (`nameField` above). A formula is computed on // read and has no stored column. [#21197] A fixed label: see the note on - // `titleFormat` for why it reads no column. + // `nameField` for why it reads no column. display_title: Field.formula({ label: 'Title', returnType: 'text', diff --git a/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts b/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts index e5257dd7dfe..b66d11692eb 100644 --- a/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts +++ b/packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts @@ -89,7 +89,12 @@ import { readdirSync, statSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; -import { MANAGED_API_METHOD_UNAFFORDABLE, validateManagedApiMethods } from '@objectstack/lint'; +import { + MANAGED_API_METHOD_UNAFFORDABLE, + TITLE_FORMAT_RETIRED, + validateManagedApiMethods, + validateTitleFormatRetired, +} from '@objectstack/lint'; /** * Seeded from `__dirname`, not from `fileURLToPath(import.meta.url)` as the @@ -352,3 +357,55 @@ describe('#7934 — code-shipped managed objects declare only apiMethods their o expect(findings[0].message).toContain('update'); }); }); + +// ── #29007: the retired `titleFormat`, over the same population ───────────── +// +// The ruling of record on #29007 (6574393684, letter B) refuses `titleFormat` +// on an object definition at the metadata save gate and by `os validate` +// (`validateTitleFormatRetired`, `@objectstack/lint`). This repo's own objects +// are code, registered at boot, and reach neither door — the gap #7934 names +// for the rule above, the same walk closes for this one. An object shipped +// with a template would also be refused the first time a tenant saved it back +// from Studio, so the sweep holds the population at zero. The verdict is the +// lint rule's own; this walk holds no second copy of it. +describe('#29007 — no code-shipped object declares the retired titleFormat', () => { + let loaded: LoadedObject[]; + + beforeAll(async () => { + loaded = await loadObjects(); + }, 120_000); + + const judgeTitles = (objects: readonly LoadedObject[]) => + objects.flatMap((o) => + validateTitleFormatRetired({ objects: [o.def] }).map((f) => ({ file: o.file, object: o.name, rule: f.rule })), + ); + + it('finds none across the repo-wide population', () => { + // Anti-vacuity: the same floor the sweep above asserts, on the same walk. + expect(loaded.length).toBeGreaterThanOrEqual(70); + expect( + judgeTitles(loaded), + 'a code-shipped object declares `titleFormat`, which is refused at save and by `os validate`: ' + + 'set `nameField` to the title field (a composite title is a stored computed field `nameField` ' + + 'points at) and drop the template', + ).toEqual([]); + }); + + it('goes red on an object that declares one, and stays silent on its control', () => { + const declaring: LoadedObject = { + file: 'test/fixture/titled.object.ts', + exportName: 'TitledFixture', + name: 'fixture_titled_object', + def: { name: 'fixture_titled_object', titleFormat: '{name} · {code}', fields: { name: { type: 'text' } } }, + }; + const control: LoadedObject = { + ...declaring, + exportName: 'PointedFixture', + name: 'fixture_pointed_object', + def: { name: 'fixture_pointed_object', nameField: 'name', fields: { name: { type: 'text' } } }, + }; + expect(judgeTitles([...loaded, declaring, control])).toEqual([ + { file: declaring.file, object: declaring.name, rule: TITLE_FORMAT_RETIRED }, + ]); + }); +}); diff --git a/packages/platform-objects/src/system/sys-exchange-rate.object.ts b/packages/platform-objects/src/system/sys-exchange-rate.object.ts index bd478ea8fd1..a797b7075c9 100644 --- a/packages/platform-objects/src/system/sys-exchange-rate.object.ts +++ b/packages/platform-objects/src/system/sys-exchange-rate.object.ts @@ -85,7 +85,6 @@ export const SysExchangeRate = ObjectSchema.create({ 'the rates foreign-currency amounts are converted to the home currency with.', displayNameField: 'currency', nameField: 'currency', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{currency} · {effective_date}', highlightFields: ['currency', 'rate', 'effective_date', 'source'], fields: { diff --git a/packages/platform-objects/src/system/sys-migration-journal.object.ts b/packages/platform-objects/src/system/sys-migration-journal.object.ts index 45288286637..a6cdf1bf632 100644 --- a/packages/platform-objects/src/system/sys-migration-journal.object.ts +++ b/packages/platform-objects/src/system/sys-migration-journal.object.ts @@ -80,7 +80,6 @@ export const SysMigrationJournal = ObjectSchema.create({ description: 'Append-only trace of migration runs: which chunks committed, which were compensated, and where a killed run stopped.', nameField: 'run_id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{run_id} #{seq} {kind}', highlightFields: ['run_id', 'seq', 'kind', 'chunk_index', 'created_at'], listViews: { recent: { diff --git a/packages/platform-objects/src/system/sys-migration.object.ts b/packages/platform-objects/src/system/sys-migration.object.ts index 5bbd2ad5b3b..7cf9a21ccb3 100644 --- a/packages/platform-objects/src/system/sys-migration.object.ts +++ b/packages/platform-objects/src/system/sys-migration.object.ts @@ -64,7 +64,6 @@ export const SysMigration = ObjectSchema.create({ requiredPermissions: ['manage_platform_settings'], description: 'Deployment-level data-migration flags: which gated data migrations ran here and whether their self-check passed.', nameField: 'id', // [ADR-0079] canonical primary-title pointer - titleFormat: '{id}', highlightFields: ['id', 'blocking', 'verified_at', 'last_run_at'], fields: { diff --git a/packages/platform-objects/src/system/sys-platform-setting.object.ts b/packages/platform-objects/src/system/sys-platform-setting.object.ts index 3da60fe30e7..6ddbb74d22c 100644 --- a/packages/platform-objects/src/system/sys-platform-setting.object.ts +++ b/packages/platform-objects/src/system/sys-platform-setting.object.ts @@ -58,7 +58,6 @@ export const SysPlatformSetting = ObjectSchema.create({ description: 'Deployment-wide settings values: the global rung of the settings cascade.', displayNameField: 'key', nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{namespace}.{key}', highlightFields: ['namespace', 'key', 'updated_at'], fields: { diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 98b328f7bd8..e47f751b466 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -49,7 +49,6 @@ export const SysSetting = ObjectSchema.create({ description: 'Generic K/V store backing the SettingsManifest contract.', displayNameField: 'key', nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{namespace}.{key}', highlightFields: ['namespace', 'key', 'scope', 'updated_at'], listViews: { diff --git a/packages/platform-objects/src/system/sys-trusted-key.object.ts b/packages/platform-objects/src/system/sys-trusted-key.object.ts index 2f375c23092..9ec5035c56c 100644 --- a/packages/platform-objects/src/system/sys-trusted-key.object.ts +++ b/packages/platform-objects/src/system/sys-trusted-key.object.ts @@ -65,7 +65,6 @@ export const SysTrustedKey = ObjectSchema.create({ description: "Publisher public keys this environment accepts a plugin artifact's signature from. Public keys only; written by `os plugin trust`.", nameField: 'key_id', - titleFormat: '{key_id}', highlightFields: ['key_id', 'status', 'source', 'fingerprint', 'added_at'], listViews: { all: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index a39efcdfa07..e75321dd083 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -24,14 +24,14 @@ export const SysApprovalAction = ObjectSchema.create({ managedBy: 'append-only', description: 'Append-only audit trail for approval actions', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw id. `titleFormat` - // stays for renderers that still read it first; - // `sys-approval-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-approval-display-title.test.ts` holds the formula to + // the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{action} · {step_name}', highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'acted_as', 'via_override', 'created_at'], // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds diff --git a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts index 7a0ff7e5140..24a51e3a653 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts @@ -34,14 +34,14 @@ export const SysApprovalApprover = ObjectSchema.create({ managedBy: 'engine-owned', description: 'Normalized pending-approver rows for indexed inbox queries', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw id. `titleFormat` - // stays for renderers that still read it first; - // `sys-approval-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-approval-display-title.test.ts` holds the formula to + // the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{approver} · {request_id}', highlightFields: ['request_id', 'approver', 'created_at'], fields: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts index ecc8cad4da6..3d3ce0672ff 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -42,12 +42,23 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_approval_delegation: '{delegator_id} → {delegate_id}', +}; + 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 tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; 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; diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 7b41d1e796c..063e30c6bbe 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -50,15 +50,15 @@ export const SysApprovalDelegation = ObjectSchema.create({ // membership-level approvers are not (`ApprovalService.expandApprovers`). description: 'While someone is away, approvals assigned to them personally go to their delegate.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. 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. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-approval-delegation-display-title.test.ts` + // holds the formula to the text that template drew. 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'], listViews: { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts index d02051df63e..7a3d5e5bedd 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts @@ -44,6 +44,19 @@ import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_approval_request: '{process_name}', + sys_approval_action: '{action} · {step_name}', + sys_approval_approver: '{approver} · {request_id}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -129,7 +142,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('Discount Sign-off'); // The pairing, restated: `titleFormat` keeps the machine source, and a // renderer reaches it only where the pointer's value is empty — not here. - expect(renderTitleFormat(SysApprovalRequest.titleFormat, row!)).toBe('flow:deal_desk'); + expect(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalRequest.name], row!)).toBe('flow:deal_desk'); }); it('sys_approval_request: with no flow label stored, the H1 is the template\'s "{process_name}", which display_title matches, never "null" and never the record id', async () => { @@ -143,10 +156,11 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(row).not.toBeNull(); expect(row!.process_label ?? null).toBeNull(); - // The pointer's value is empty, so ADR-0079's order falls through to the - // deprecated template: the machine source, the same text the formula draws. + // The pointer's value is empty, so ADR-0079's order falls through: to the + // retired template once, to the derived `display_title` now — the machine + // source either way, the same text the formula draws. expect(h1Of(SysApprovalRequest, row!) ?? null).toBeNull(); - const fallback = renderTitleFormat(SysApprovalRequest.titleFormat, row!); + const fallback = renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalRequest.name], row!); expect(fallback).toBe('flow:deal_desk'); expect(fallback).not.toContain('opp_42'); expect(fallback).not.toContain('null'); @@ -168,7 +182,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o const row = await engine.findOne('sys_approval_action', { where: { id: stepped.id } }, SYS); expect(h1Of(SysApprovalAction, row!)).toBe('approve · manager_review'); expect(h1Of(SysApprovalAction, row!)).not.toBe(row!.id); - expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(SysApprovalAction.titleFormat, row!)); + expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalAction.name], row!)); expect(resolveRecordTitle(SysApprovalAction, storedOnly(row!))).toBe('approve · manager_review'); const stepless = await engine.insert('sys_approval_action', { @@ -192,7 +206,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o expect(h1Of(SysApprovalApprover, row!)).toBe(`role:finance · ${request.id}`); expect(h1Of(SysApprovalApprover, row!)).not.toBe(row!.id); - expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(SysApprovalApprover.titleFormat, row!)); + expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysApprovalApprover.name], row!)); expect(resolveRecordTitle(SysApprovalApprover, storedOnly(row!))).toBe(`role:finance · ${request.id}`); }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts index 7ade70c0214..17c9f133e50 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts @@ -156,7 +156,7 @@ describe('#24665 — the approval request title is the flow label the approvals try { await engine?.destroy(); } catch { /* noop */ } }); - it('the declaration: the title pointer is a stored, declared column, and the template names no record id', async () => { + it('the declaration: the title pointer is a stored, declared column, and no title template is declared', async () => { expect(NAME_FIELD).toBe('process_label'); expect(SysApprovalRequest.displayNameField).toBe(NAME_FIELD); expect((SysApprovalRequest.fields as Record)[NAME_FIELD]).toMatchObject({ type: 'text' }); @@ -165,10 +165,9 @@ describe('#24665 — the approval request title is the flow label the approvals const columns = Object.keys(await driver.getKnex()('sys_approval_request').columnInfo()); expect(columns).toContain(NAME_FIELD); expect(columns).not.toContain('display_title'); - const template = SysApprovalRequest.titleFormat as unknown; - const source = typeof template === 'string' ? template : (template as { source?: string })?.source; - expect(source).toBe('{process_name}'); - expect(source).not.toContain('record_id'); + // The retired `titleFormat` (`{process_name}`) is gone: an object + // definition that declares one is refused at save and by `os validate`. + expect((SysApprovalRequest as { titleFormat?: unknown }).titleFormat).toBeUndefined(); }); it('readable subject: the get serves the flow label under nameField, with no record id and no machine name', async () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 15889204166..5362d99dde9 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -47,29 +47,27 @@ export const SysApprovalRequest = ObjectSchema.create({ description: 'Live approval instance tracked per submission', // [ADR-0079, ADR-0085] The record title is the flow's label, `process_label`: // a STORED column, so every door that serves the row serves its title. The - // approvals get (`rowFromRequest`) serves it by the display-label chain — - // the column, else the config snapshot's label, else the humanized machine - // name — so the request page reached through that get always has a title, - // and it is never `flow:NAME`. The pointer used to be `display_title`, a - // formula: a formula has no column, the approvals get served no value for - // it, and the page fell through to `titleFormat`, the machine name and the - // raw subject record id. + // approvals get (`rowFromRequest`) serves it by the display-label chain — the + // column, else the config snapshot's label, else the humanized machine name — + // so the request page reached through that get always has a title, and it is + // never `flow:NAME`. The pointer used to be `display_title`, a formula: a + // formula has no column, the approvals get served no value for it, and the + // page fell through to the since-retired `titleFormat`, the machine name and + // the raw subject record id. // // The title names no subject record. The request page names the subject on // the card the `record_id` / `object_name` pair draws in the Details tab, // which says so when the reader may not read the record or it was deleted; // a title cannot, so a bare record id is never shown there. // - // `titleFormat` is the deprecated fallback a renderer reaches only where - // `process_label` is empty: a row written before the column, until the boot - // fill labels it (`display-label-backfill.ts`). It names `{process_name}`, - // the machine source, because a placeholder template cannot say "else" — - // the same fallback `display_title` draws, so every door agrees on such a - // row too. `sys-approval-display-title.test.ts` and - // `sys-approval-request-title.integration.test.ts` pin both. + // Where `process_label` is empty — a row written before the column, until the + // boot fill labels it (`display-label-backfill.ts`) — the retired + // `titleFormat` template `{process_name}` drew the machine source; it is gone + // (refused at save and by `os validate`), and `display_title` draws that same + // fallback. `sys-approval-display-title.test.ts` and + // `sys-approval-request-title.integration.test.ts` pin the title. displayNameField: 'process_label', nameField: 'process_label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{process_name}', // [ADR-0085] The flow is named by its label, `process_label`, never by the // machine source `process_name` (`flow:NAME`), and the step by its label, // `current_step_label`, never by the node id `current_step`, as the request @@ -179,9 +177,9 @@ export const SysApprovalRequest = ObjectSchema.create({ // [ADR-0079] The title text the data API computes on read: the flow's // label, `process_label`, else the machine source `process_name` (required) // where no label is stored — the text the title ladder draws from - // `nameField` and `titleFormat` above, on every row the engine writes (a - // writer stores a label or null, never a blank). A formula has no - // stored column. It names no subject record, as the title does not. + // `nameField` above, on every row the engine writes (a writer stores a + // label or null, never a blank). A formula has no stored column. It names + // no subject record, as the title does not. // // It is no longer the title pointer (the approvals get serves no formula // value), and it stays declared because stored activity rows name it: the diff --git a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts index d383d1580d9..34bc4a358e4 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-activity.object.ts @@ -35,7 +35,6 @@ export const SysActivity = ObjectSchema.create({ description: 'Recent activity stream entries (lightweight, denormalized)', displayNameField: 'summary', nameField: 'summary', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{type} · {summary}', highlightFields: ['timestamp', 'type', 'actor_name', 'summary'], fields: { diff --git a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts index 62bbb7b149b..d3b1aa09969 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-comment.object.ts @@ -134,7 +134,6 @@ export const SysComment = ObjectSchema.create({ description: 'Threaded comments attached to records via thread_id', displayNameField: 'body', nameField: 'body', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{author_name}: {body}', highlightFields: ['created_at', 'author_name', 'body'], fields: { diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts index a85ad1f5c3f..6b19c9b690b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -39,7 +39,11 @@ export const SysAudienceBindingSuggestion = ObjectSchema.create({ // [ADR-0090 D5/D9] The everyone-anchor suggestion and its deployment-level // decision, in the words Setup shows. description: 'A package\'s suggestion to give one of its permission sets to everyone who signs in, and the decision taken on it.', - titleFormat: '{package_id}: {permission_set_name} → {anchor}', + // [ADR-0079] The record title: the suggested permission set, the field the + // registry stamped when the retired `titleFormat` template stood here (an + // object definition that declares one is refused at save and by + // `os validate`). + nameField: 'permission_set_name', highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'], fields: { diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index c20b5c0581d..366a9285954 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -51,6 +51,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysUserPosition } from './sys-user-position.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_user_position: '{user_id} → {position}', +}; + const SYS = { context: { isSystem: true } } as any; interface Case { @@ -69,7 +80,7 @@ const CASES: Case[] = [ /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; 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; diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index de72608c5c1..f0d14450552 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -40,13 +40,13 @@ export const SysUserPermissionSet = ObjectSchema.create({ // user and the set. 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` is - // the rung below the pointer: a renderer reaches it only where the title has - // no value (the user is not readable by this reader, or no longer exists). + // `titleFormat`) drew the raw id as the record page's H1. The retired + // `titleFormat` template, the rung below the pointer where the title has no + // value (the user is not readable by this reader, or no longer exists), is + // gone (refused at save and by `os validate`). // `sys-user-permission-set-person-title.test.ts` pins the title. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} → {permission_set}', highlightFields: ['user_id', 'permission_set', 'organization_id'], fields: { @@ -66,7 +66,7 @@ export const SysUserPermissionSet = ObjectSchema.create({ // `sys_user` applies, so the title carries nothing that reader could not // read on the user itself: a user the reader may not read, or one that no // longer exists, leaves the title null — never a value computed in its - // place — and a renderer falls to `titleFormat` below it. + // place — and a renderer falls to its derivation below it. // `user_id` is read only through the hop (a bare read beside a traversal // is refused, `traversal-bare-and-traversed`). Both columns are required // (the set by name, ADR-0131 D4); a legacy grant the v18 ceremony's diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index f86a671b4b2..a6bdb65122b 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -46,15 +46,15 @@ export const SysUserPosition = ObjectSchema.create({ // words Setup shows. description: 'Which positions each user holds, one row per assignment.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. 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. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-security-assignment-display-title.test.ts` + // holds the formula to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} → {position}', highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], fields: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts index cf8ef91b3cb..2d40cfbe1c4 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-assignment-cursor.object.ts @@ -29,7 +29,6 @@ export const SysAssignmentCursor = ObjectSchema.create({ description: 'Where round-robin assignment last left off, per assignment rule entry.', displayNameField: 'rule', nameField: 'rule', - titleFormat: '{rule} / {entry}', highlightFields: ['rule', 'entry', 'last_user_id', 'version'], fields: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts index 9c5e59e041a..a8da1d0f903 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue-member.object.ts @@ -29,7 +29,6 @@ export const SysQueueMember = ObjectSchema.create({ description: 'Who belongs to a queue: a user, a team, a position or a business unit.', displayNameField: 'queue', nameField: 'queue', - titleFormat: '{queue}', highlightFields: ['queue', 'recipient_type', 'recipient_id'], listViews: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts index 6b9bcb729d1..227b9cdba0e 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-queue.object.ts @@ -32,7 +32,6 @@ export const SysQueue = ObjectSchema.create({ description: 'A named group of people that unassigned records can wait in until one of them takes the record.', displayNameField: 'name', nameField: 'name', - titleFormat: '{label}', highlightFields: ['name', 'label'], listViews: { diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index cf003319238..5528f58979a 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -50,12 +50,11 @@ export const SysShareLink = ObjectSchema.create({ description: 'Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.', // [#24611] The record title is the link's own label, the free text its // creator gave it. Declared rather than left to the registry's designation, - // which derives the same field today. A link created without a label is - // titled by `titleFormat`: what the link grants and to whom, each value drawn - // as its option label on the record page. Neither title names the shared + // which derives the same field today. The retired `titleFormat` template that + // titled a link created without a label (what it grants and to whom) is gone + // (refused at save and by `os validate`). The title never names the shared // record's object; the Details tab's pointer card names it by its label. nameField: 'label', - titleFormat: '{permission} · {audience}', // Neither half of the pointer pair is listed, as on `sys_record_share`: the // strip would draw the type half as an API name and the id half as a raw id, // and would hide `record_id` from the Details tab, where it draws as the diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 052f73f76d0..72e80666279 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -40,7 +40,6 @@ export const SysSharingRule = ObjectSchema.create({ description: 'Grants a group of people access to the records that match a condition.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active'], listViews: { diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index ef5016938f1..59e16bfad6e 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -62,7 +62,6 @@ export const SysWebhook = ObjectSchema.create({ description: 'An outbound HTTP webhook your organization creates here or through the API. Webhooks that come built into an app are not listed here. Both kinds are sent through the same delivery queue.', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label}', highlightFields: ['name', 'object_name', 'url', 'active', 'updated_at'], listViews: { diff --git a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts index 99cd6b87d41..497396ccdcd 100644 --- a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts +++ b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts @@ -28,6 +28,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysAutomationRun } from './sys-automation-run.object.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_automation_run: '{flow_name} · {node_id}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -95,7 +106,7 @@ describe('[#20015] sys_automation_run resolves a real record title under ADR-007 expect(h1Of(row!)).toBe('quote_approval · manager_gate'); expect(h1Of(row!)).not.toBe(row!.id); - expect(h1Of(row!)).toBe(renderTitleFormat(SysAutomationRun.titleFormat, row!)); + expect(h1Of(row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[SysAutomationRun.name], row!)); expect(resolveRecordTitle(SysAutomationRun, storedOnly(row!))).toBe('quote_approval · manager_gate'); }); diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index da00f0d32b5..577731c65da 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -73,14 +73,14 @@ export const SysAutomationRun = ObjectSchema.create({ // refused / missed), aged out by the retention above. description: 'Paused automation runs waiting to resume, and the recent history of finished runs.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw run id. - // `titleFormat` stays for renderers that still read it first; - // `sys-automation-run-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw run id. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `sys-automation-run-display-title.test.ts` holds the + // formula to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{flow_name} · {node_id}', // The three operands of the broken-sweep first filter — // `selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0` — sit in // the highlight set on purpose (#4354): "selected 30, acted 0" has to be @@ -176,7 +176,7 @@ export const SysAutomationRun = ObjectSchema.create({ // consumed snapshot's node from it — there is no other column), so on that // one terminal-row class the writer (`AutomationEngine.recordLog`) puts // the PAUSED node here, not the last step. This column is also what the - // Runs surface titles and highlights a row with (`titleFormat` / + // Runs surface titles and highlights a row with (`display_title` / // `highlightFields` above), so the carve-out is visible there too, by // design: the row names the pause an operator can re-arm. node_id: Field.text({ diff --git a/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts index bf55c2afaf3..a90787f5609 100644 --- a/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mail-association.object.ts @@ -32,7 +32,6 @@ export const SysMailAssociation = ObjectSchema.create({ description: 'Links a mirrored inbound message to a record it belongs to', displayNameField: 'matched_address', nameField: 'matched_address', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{matched_address}', highlightFields: ['mail_message_id', 'object_name', 'record_id', 'matched_address'], fields: { diff --git a/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts index 98f75078630..4014f6044b2 100644 --- a/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mail-message.object.ts @@ -37,7 +37,6 @@ export const SysMailMessage = ObjectSchema.create({ description: 'Inbound mail message mirrored from a mailbox', displayNameField: 'subject', nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{subject}', highlightFields: ['subject', 'from_address', 'received_at', 'association_state'], fields: { diff --git a/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts index ba7dcf3fc5a..e99d3fefc0a 100644 --- a/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts +++ b/packages/services/service-mailbox/src/objects/sys-mailbox.object.ts @@ -35,7 +35,6 @@ export const SysMailbox = ObjectSchema.create({ description: 'An inbound mailbox the platform mirrors mail from: its address and its owner', displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{name}', highlightFields: ['name', 'address', 'owner_user_id'], fields: { diff --git a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts index c54fd8aa5a9..6429fe915c4 100644 --- a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts @@ -24,6 +24,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { HttpDelivery } from './http-delivery.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_http_delivery: '{label} → {url}', +}; + const SYS = { context: { isSystem: true } } as any; /** @@ -99,7 +110,7 @@ describe('[#20015] sys_http_delivery resolves a real record title under ADR-0079 expect(h1Of(row!)).toBe('order.created → https://hooks.example.com/in'); expect(h1Of(row!)).not.toBe(row!.id); - expect(h1Of(row!)).toBe(renderTitleFormat(HttpDelivery.titleFormat, row!)); + expect(h1Of(row!)).toBe(renderTitleFormat(RETIRED_TITLE_TEMPLATES[HttpDelivery.name], row!)); expect(resolveRecordTitle(HttpDelivery, storedOnly(row!))).toBe('order.created → https://hooks.example.com/in'); }); diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 2d130db6405..dbaa7bf6136 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -46,14 +46,14 @@ export const HttpDelivery = ObjectSchema.create({ // written only by this package — lives in the docblock above. description: 'Outgoing HTTP calls made by webhooks and flows, one row per call, with its delivery status.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. The pointer used to be `id`: once a + // same two columns `titleFormat` named. The pointer used to be `id`: once a // renderer honours ADR-0079's order (an explicit `nameField` wins over - // `titleFormat`), that made the record page's H1 the raw delivery UUID. - // `titleFormat` stays for renderers that still read it first; - // `http-delivery-display-title.test.ts` holds the two to the same text. + // `titleFormat`), that made the record page's H1 the raw delivery UUID. The + // retired `titleFormat` template beside it is gone (refused at save and by + // `os validate`); `http-delivery-display-title.test.ts` holds the formula + // to the text that template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{label} → {url}', highlightFields: ['source', 'url', 'status', 'attempts', 'next_retry_at'], listViews: { diff --git a/packages/services/service-messaging/src/objects/inbox-message.object.ts b/packages/services/service-messaging/src/objects/inbox-message.object.ts index e34a1814768..81a1dff8192 100644 --- a/packages/services/service-messaging/src/objects/inbox-message.object.ts +++ b/packages/services/service-messaging/src/objects/inbox-message.object.ts @@ -30,7 +30,6 @@ export const InboxMessage = ObjectSchema.create({ }, description: 'User-facing in-app notification rows materialized by the inbox messaging channel.', nameField: 'title', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{title}', highlightFields: ['title', 'user_id', 'severity', 'created_at'], listViews: { diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index b5f8a8ab8c1..1a8f1dee023 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -75,14 +75,13 @@ export const NotificationDelivery = ObjectSchema.create({ // 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; the formula names the - // recipient as a PERSON where `titleFormat` names the raw recipient, so the - // two no longer read the same text + // `titleFormat`) drew the raw id as the record page's H1. The retired + // `titleFormat` template beside it is gone (refused at save and by + // `os validate`); the formula names the recipient as a PERSON where that + // template named the raw recipient, so the two no longer read the same text // (`../delivery-recipient-user.integration.test.ts`). displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{channel} → {recipient_id}', highlightFields: ['notification_id', 'recipient_id', 'channel', 'status', 'attempts'], fields: { diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index 25256288c5f..96d78a89612 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -61,6 +61,17 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { NotificationReceipt } from './notification-receipt.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_notification_receipt: '{state}', +}; + const SYS = { context: { isSystem: true } } as any; const NOW = new Date('2026-09-25T00:00:00Z'); @@ -96,7 +107,7 @@ const CASES: Case[] = [ /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; 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; diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 7810d6d7119..841aed4d151 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -43,15 +43,16 @@ export const NotificationPreference = ObjectSchema.create({ // the docblock above (ADR-0030 Layer 3). description: 'Which topics and channels each user receives; admins set the defaults.', // [ADR-0079] The record title is `display_title`, a text formula over the - // same three columns `titleFormat` names. With no pointer declared, the + // same three columns `titleFormat` named. 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; `notification-display-title.test.ts` holds the two to the same text. + // record page's H1. The retired `titleFormat` template beside it is gone + // (refused at save and by `os validate`); + // `notification-display-title.test.ts` holds the formula to the text that + // template drew. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} · {topic} · {channel}', highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'], // The one list view this object ships, declared for its `emptyState`: with diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index c9d5f1a930e..62269724875 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -35,17 +35,17 @@ export const NotificationReceipt = ObjectSchema.create({ }, description: 'Per-recipient × channel receipt; the source of truth for notification read-state.', // [ADR-0079] The record title is `state`, the one column `titleFormat` - // names: a single-field title migrates to `nameField` directly, with no + // named: a single-field title migrates to `nameField` directly, with no // formula. With no pointer declared, the registry's designate-only pass // stamped `nameField: 'id'` (the first title-eligible field; a `select` is // never DERIVED), so a renderer honouring ADR-0079's order (an explicit // `nameField` wins over `titleFormat`) drew the raw id as the record page's // H1. An explicit pointer is honoured whatever the field's type (ADR-0079 - // D4). `titleFormat` stays for renderers that still read it first; - // `notification-display-title.test.ts` holds the two to the same text. + // D4). The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `notification-display-title.test.ts` holds + // the formula to the text that template drew. displayNameField: 'state', nameField: 'state', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{state}', highlightFields: ['notification_id', 'user_id', 'channel', 'state', 'at'], fields: { diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 36876b11a7d..5253a4e0fa2 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -68,7 +68,11 @@ export const NotificationTemplate = ObjectSchema.create({ // It names the two channels that read a row and says the inbox reads none // (TEMPLATE_CHANNELS above is the measurement behind both halves). description: 'The email and SMS text each notification topic sends, per language. The in-app inbox never uses these templates.', - titleFormat: '{topic} · {channel} · {locale}', + // [ADR-0079] The record title: the template's subject, the field the + // registry stamped when the retired `titleFormat` template stood here (an + // object definition that declares one is refused at save and by + // `os validate`). + nameField: 'subject', highlightFields: ['topic', 'channel', 'locale', 'is_active'], // The one list view this object ships, declared for its `emptyState`: with diff --git a/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts index 5ec8c42d0dd..37dfb82eb0e 100644 --- a/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts +++ b/packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts @@ -33,9 +33,20 @@ import { describe, it, expect } from 'vitest'; import { provisionPrimary, resolveDisplayField } from '@objectstack/spec/data'; import { SysPresence } from './sys-presence.object.js'; +/** + * The `titleFormat` template each object declared before the ruling of record + * on #29007 (6574393684, letter B) retired it: an object definition that + * declares one is refused at the metadata save gate and by `os validate`, so + * the object no longer carries it. Kept here as the reference text the title + * has to reproduce — the text the template drew — not as a title resolver. + */ +const RETIRED_TITLE_TEMPLATES: Readonly> = { + sys_presence: '{user_id} ({status})', +}; + /** The `titleFormat` source: the parsed schema carries it as an envelope. */ function titleFormatSource(schema: unknown): string { - const tf = (schema as { titleFormat?: unknown }).titleFormat; + const tf = RETIRED_TITLE_TEMPLATES[(schema as { name: string }).name]; 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; diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 8cc70f73c0e..a643eab1fbb 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -32,16 +32,15 @@ export const SysPresence = ObjectSchema.create({ requiredPermissions: ['manage_platform_settings'], description: 'Real-time user presence and activity tracking', // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the + // same two columns `titleFormat` named. 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-presence-display-title.test.ts` pins the pointer and the formula's - // inputs. + // page's H1. The retired `titleFormat` template beside it is gone (refused at + // save and by `os validate`); `sys-presence-display-title.test.ts` pins the + // pointer and the formula's inputs. displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{user_id} ({status})', highlightFields: ['user_id', 'status', 'last_seen'], fields: { diff --git a/packages/services/service-storage/src/objects/system-file.object.ts b/packages/services/service-storage/src/objects/system-file.object.ts index f7c568838c7..1515210f61f 100644 --- a/packages/services/service-storage/src/objects/system-file.object.ts +++ b/packages/services/service-storage/src/objects/system-file.object.ts @@ -100,7 +100,6 @@ export const SystemFile = ObjectSchema.create({ icon: 'file', description: 'Storage service file metadata (fileId ↔ key mapping)', nameField: 'name', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{name}', highlightFields: ['name', 'mime_type', 'size', 'status', 'created_at'], fields: { diff --git a/packages/services/service-storage/src/objects/system-upload-session.object.ts b/packages/services/service-storage/src/objects/system-upload-session.object.ts index 1450bdb62bf..6e0d0db08a2 100644 --- a/packages/services/service-storage/src/objects/system-upload-session.object.ts +++ b/packages/services/service-storage/src/objects/system-upload-session.object.ts @@ -18,7 +18,6 @@ export const SystemUploadSession = ObjectSchema.create({ icon: 'upload-cloud', description: 'Resumable multipart upload sessions tracked by service-storage', nameField: 'filename', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) - titleFormat: '{filename}', highlightFields: ['filename', 'status', 'uploaded_chunks', 'total_chunks', 'expires_at'], fields: { diff --git a/packages/spec/liveness/sharing_rule.json b/packages/spec/liveness/sharing_rule.json index 7b7121698c0..4daebad6c63 100644 --- a/packages/spec/liveness/sharing_rule.json +++ b/packages/spec/liveness/sharing_rule.json @@ -7,13 +7,13 @@ "verifiedAt": "2026-09-17", "evidence": "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#defineRule — the upsert key: it looks the existing row up by `(name, organization_id)` and writes `name` onto `sys_sharing_rule`, so the authored name decides whether a boot creates a second rule or updates the one already standing; packages/plugins/plugin-sharing/src/sharing-rule-service.ts#getRule resolves the REST by-id-or-name routes against the same column, and every `sys_record_share` row the rule materialises carries `reason: rule:{name}`.", "producer": "packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#translateDeclaredSharingRule — `if (!r?.name || !r?.object) return { skip: { kind: 'unnamed' } }` refuses a declared rule that has no name before anything else runs, and carries the authored value as the served rule's `name` — which packages/plugins/plugin-sharing/src/sharing-rule-service.ts#projectDeclaredRule makes its `id` too, the `source_id` its grants carry; nothing else supplies one.", - "note": "Identity, not display: `titleFormat` is `{label}`, and `name` is the object's `displayNameField`/`nameField` plus the first of its `highlightFields`." + "note": "Identity as well as the record title: `name` is the object's `displayNameField`/`nameField` plus the first of its `highlightFields`." }, "label": { "status": "live", - "verifiedAt": "2026-09-17", + "verifiedAt": "2026-11-02", "evidenceScope": "in-repo", - "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — `titleFormat: '{label}'` makes the authored label the record title of every `sys_sharing_rule` row, and `label` is a column of all four declared listViews (`active`, `inactive`, `by_object`, `all_rules`); ui/packages/components/src/renderers/layout/containers.tsx interpolates `objectSchema.titleFormat` for the rendered title, so the authored string is what an admin reads in Setup.", + "evidence": "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts#SysSharingRule — `label` is a column of all four declared listViews (`active`, `inactive`, `by_object`, `all_rules`) and the sort key of each, so the authored string is what an admin reads in Setup's rule lists.", "producer": "packages/plugins/plugin-sharing/src/declared-sharing-rules.ts#translateDeclaredSharingRule — `label: String(r.label ?? r.name)` threads the authored label into the served rule, which the sharing-rule service lists as the rule's `label`; without that thread the rule NAME would stand for every declared rule, which is exactly what an unauthored `label` still produces.", "note": "Display-shaped, so the #7131 split settles it: being shown to a human IS the whole of the claimed effect, and there is no second layer where a \"real\" consumer would live. The read point is the platform object's own record surface, NOT a metadata-admin preview — this type has none (enumerated in the file note above)." }, diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 21b8b191362..26fb52b2c70 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3399,7 +3399,7 @@ const ObjectSchemaBase = strictObject( * cross-repo back-compat). New metadata should set `nameField`. */ displayNameField: z.string().optional().describe('[DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField.'), - titleFormat: TemplateExpressionInputSchema.optional().describe('[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 title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), + titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. An object definition that declares it is refused at the metadata save gate and by os validate; a stored object that carries it still parses and reads. Migrate a single-field title to nameField; a composite title that import, sort or search must match to a stored computed field (a non-formula field with an expression, computed and stored on every write) designated as nameField — a text formula designated as nameField stays legal for a title shown for display only. Placeholders may be written {{field}} or {field} — the title renderers treat the two as equivalent, normalizing {{field}} to {field} before substituting; neither spelling is judged at parse time.'), /** * [#21182 — ruling A on objectstack-ai/hotcrm#1199] The record's PICTURE: * names the field whose value the record page header — the record chrome diff --git a/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts b/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts new file mode 100644 index 00000000000..1952beb05c9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// `titleFormat` on an object is refused at the metadata save gate and by +// `os validate` (ruling of record 6574393684 on #29007, letter B). Not deleted +// from the schema and not converted: a stored object that carries it still +// parses and reads, and no D2 conversion drops it, because which field a +// composite template should become is the author's choice — a single field, or +// a stored computed field — and the platform does not guess. +export const entry: SemanticMigration = { + id: 'object-title-format-refused', + surface: 'object.titleFormat', + replacement: + 'Point `nameField` at the record\'s title field and drop `titleFormat`. A single-field template ' + + '(`{name}`) becomes `nameField: \'name\'`. A composite template that an import, a sort or a search ' + + 'must match becomes a stored computed field: a `text` field that declares an `expression`, which the ' + + 'engine computes and stores on every write, designated as `nameField`; a `formula` with ' + + '`returnType: \'text\'` stays legal for a title shown for display only. `os validate`, `os build`, ' + + '`os lint` and the metadata save gate (`422 INVALID_METADATA`) refuse the key as `title-format-retired`, ' + + 'an error.', + reason: + 'ADR-0079 retired `titleFormat` in favour of `nameField`: a render-only template the server can neither ' + + 'return nor query. An explicit `nameField` outranks it, and the registry stamps a derived `nameField` ' + + 'on every object that authors none, so a declared template titled nothing while its save and its ' + + 'publish both succeeded. The refusal makes the no-op loud at the doors an author uses. No mechanical ' + + 'move is lossless: a composite template names several fields, and whether its title must be stored ' + + '(matched by an import, sorted, searched) or only shown is not recorded. ADR-0079 / ADR-0087.', + acceptanceCriteria: + 'Load every object (`os validate` or `os build`): no `title-format-retired` finding, and every object ' + + 'that declared a template now declares `nameField`. A stored object row that carries `titleFormat` ' + + 'still loads and is served; its next save, publish or restore is refused until the key is dropped. ' + + 'Done when no authored or stored object carries `titleFormat`.', +};