From 6ed2b219b7adad5ecf65560249614bf429aff259 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:56:05 +0000 Subject: [PATCH 1/4] fix(lint): a composite record title that must be queried is a stored computed field; a formula title is for display only The title-format-retired and title-unresolvable diagnostics, the object/missing-name-field fix line, the os explain text, ADR-0079 D3's migration sentence and the formulas.mdx worked example no longer steer a composite title that import, sort or search must match into a formula field. Wording only: no verdict, severity, code, key or export moves. Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/25071-composite-title-stored.md | 11 +++++++++++ content/docs/data-modeling/formulas.mdx | 2 +- docs/adr/0079-record-display-name.md | 8 ++++++-- packages/lint/src/data-model-rules.fix-lines.test.ts | 4 ++-- packages/lint/src/data-model-rules.ts | 4 ++-- packages/lint/src/rule-explanations.ts | 10 +++++++--- packages/lint/src/validate-record-title.test.ts | 8 ++++---- packages/lint/src/validate-record-title.ts | 11 ++++++----- 8 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 .changeset/25071-composite-title-stored.md diff --git a/.changeset/25071-composite-title-stored.md b/.changeset/25071-composite-title-stored.md new file mode 100644 index 00000000000..5e950f6ce07 --- /dev/null +++ b/.changeset/25071-composite-title-stored.md @@ -0,0 +1,11 @@ +--- +'@objectstack/lint': patch +--- + +fix(lint): the record-title diagnostics no longer steer a composite title that must be queried into a formula field + +Clause-②: no (wording only: no verdict, severity, code, key or export moves) + +The `title-format-retired` and `title-unresolvable` messages and fix lines, the `object/missing-name-field` fix line, and the `os explain title-format-retired` text now say the same thing: a composite record title that an import, a sort or a search must match is a stored computed field, a non-formula field (such as a `text` field) that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`. A `formula` field with `returnType: 'text'` stays a legal `nameField` 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. + +No finding fires on a different object than before, and no severity changes. Only the text an author reads changes. diff --git a/content/docs/data-modeling/formulas.mdx b/content/docs/data-modeling/formulas.mdx index b9de42b18e2..7ee771f9a30 100644 --- a/content/docs/data-modeling/formulas.mdx +++ b/content/docs/data-modeling/formulas.mdx @@ -96,7 +96,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; export const Invoice = ObjectSchema.create({ name: 'invoice', sharingModel: 'private', - nameField: 'display_title', // ADR-0079 — the record title is a designated field; composite titles migrate off the deprecated `titleFormat` to a text formula + nameField: 'display_title', // ADR-0079 — the record title is a designated field; a composite title migrates off the deprecated `titleFormat` to a text formula for display only, or to a stored computed field (a non-formula field with an `expression`, written on write) when import, sort or search must match it fields: { // Composite record title as a text formula, surfaced via `nameField` above. display_title: Field.formula({ diff --git a/docs/adr/0079-record-display-name.md b/docs/adr/0079-record-display-name.md index 08f161de65a..359efcaa750 100644 --- a/docs/adr/0079-record-display-name.md +++ b/docs/adr/0079-record-display-name.md @@ -138,8 +138,12 @@ the diagnostic is advisory, not an error: > consumed by the release) **Migration is stated, not left to the author**: a single-field title becomes -`nameField: ''`; a **composite** title becomes a `formula` field with -`returnType: 'text'`, designated as the `nameField` +`nameField: ''`; a **composite** title that must be queried (matched by +an import, sorted, searched) becomes a stored computed field (a non-formula +field with an `expression`, written on write), designated as the `nameField`, +the remedy addendum item 9 states for sort and search and ruling record +6308445871 extends to import; a `formula` field with `returnType: 'text'` +designated as the `nameField` stays legal for a title shown for display only (`packages/lint/src/validate-record-title.ts`; the worked example is `content/docs/data-modeling/formulas.mdx`). diff --git a/packages/lint/src/data-model-rules.fix-lines.test.ts b/packages/lint/src/data-model-rules.fix-lines.test.ts index 03ee7fe969e..58b12cff259 100644 --- a/packages/lint/src/data-model-rules.fix-lines.test.ts +++ b/packages/lint/src/data-model-rules.fix-lines.test.ts @@ -150,8 +150,8 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => { it('object/missing-name-field: the pointer, and where the titleFormat reasoning lives', () => { const fix = fixOf(record(lintDataModel([{ name: LONG_OBJECT, fields: { amount: { type: 'number' } } }])), MISSING_NAME_FIELD); expect(fix).toBe( - "Set `nameField: ''` (ADR-0079's title pointer) to a stored text/autonumber field or a " + - "`returnType: 'text'` formula; `titleFormat` does not count (`os explain title-format-retired`).", + "Set `nameField: ''`; a composite title to import, sort or search by is a text field with an " + + "`expression` (a text formula is display-only); `titleFormat` does not count (`os explain title-format-retired`).", ); // The command the line names answers, and says why `titleFormat` does not count. expect(explainRule('title-format-retired')!.paragraphs.join('\n')).toContain('render-only'); diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index ca5eb1276ad..7fbdc122765 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -738,8 +738,8 @@ export function lintDataModel(objects: any[]): LintIssue[] { // [#22161] One remedy line; why `titleFormat` does not count is // `title-format-retired`'s `os explain` text. fix: - "Set `nameField: ''` (ADR-0079's title pointer) to a stored text/autonumber field or a " + - "`returnType: 'text'` formula; `titleFormat` does not count (`os explain title-format-retired`).", + "Set `nameField: ''`; a composite title to import, sort or search by is a text field with an " + + "`expression` (a text formula is display-only); `titleFormat` does not count (`os explain title-format-retired`).", }); } diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 057d618d91a..9527198ce3b 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -2644,9 +2644,13 @@ const TITLE_FORMAT_RETIRED_EXPLANATION: RuleExplanation = { 'alias), or one derived from the object\'s fields. `titleFormat` is a render-only template: the ' + 'server can neither return the title it draws nor query by it, the shared title predicate ' + '(`objectTitleCompleteness`) never reads it, and an explicit `nameField` takes precedence over it.', - 'For a single-field title, set `nameField` to that field. For a composite title, add a formula ' + - 'field with `returnType: \'text\'` and point `nameField` at it: a formula whose result is text is ' + - 'title-eligible. Then drop `titleFormat`, which this warning reports for as long as it is declared.', + 'For a single-field title, set `nameField` to that field. A composite title that must be queried ' + + '(matched by an import, sorted, searched) is a stored computed field: a non-formula field, such as ' + + 'a `text` field, that declares an `expression`, which the engine computes and stores on every ' + + '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.', ], }; diff --git a/packages/lint/src/validate-record-title.test.ts b/packages/lint/src/validate-record-title.test.ts index 97de3d503c8..cc4ea39f33f 100644 --- a/packages/lint/src/validate-record-title.test.ts +++ b/packages/lint/src/validate-record-title.test.ts @@ -131,12 +131,12 @@ describe('[#22161] one-line fixes — title-format-retired and title-unresolvabl const findings = validateRecordTitle(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 a single-field title, or add a formula field (returnType: 'text') and " + - 'designate it via nameField; then drop titleFormat', + "Set nameField: '' for one field; a composite title to import, sort or search by is a text " + + 'field with an expression, stored on write (a text formula is display-only); drop titleFormat', ); expect(byRule.get(TITLE_UNRESOLVABLE)).toBe( - "Set nameField to a text/email field (or a formula field with returnType: 'text'), or add a text " + - 'field named "name"/"title"', + 'Set nameField to a text/email field (a composite title to import, sort or search by: a text field ' + + 'with an expression; a text formula is display-only), or add a text field named "name"/"title"', ); }); diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts index c16ccd5538f..9473d8f3a68 100644 --- a/packages/lint/src/validate-record-title.ts +++ b/packages/lint/src/validate-record-title.ts @@ -79,12 +79,13 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { path, message: `${objName}: titleFormat is retired (ADR-0079) — migrate to nameField ` + - `(single field) or a formula field designated nameField`, + `(single field); a composite title that import, sort or search must match is a stored ` + + `field with an expression designated nameField (a formula title is for display only)`, // [#22161] One remedy line; why the template is retired (render-only, // neither returned nor queryable) is `os explain title-format-retired`. hint: - `Set nameField: '' for a single-field title, or add a formula field (returnType: 'text') ` + - `and designate it via nameField; then drop titleFormat`, + `Set nameField: '' for one field; a composite title to import, sort or search by is a ` + + `text field with an expression, stored on write (a text formula is display-only); drop titleFormat`, }); } @@ -104,8 +105,8 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { // [#22161] One remedy line; the runtime's fallback title, and why this // is only a warning, are `os explain title-unresolvable`. hint: - `Set nameField to a text/email field (or a formula field with returnType: 'text'), or add a ` + - `text field named "name"/"title"`, + `Set nameField to a text/email field (a composite title to import, sort or search by: a text ` + + `field with an expression; a text formula is display-only), or add a text field named "name"/"title"`, }); } } From dd5d71f9401283fd3887e47701134016ad9d3a5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:14:57 +0000 Subject: [PATCH 2/4] fix(lint): keep the missing-name-field fix line within its 200-character bound Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/lint/src/data-model-rules.fix-lines.test.ts | 4 ++-- packages/lint/src/data-model-rules.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/data-model-rules.fix-lines.test.ts b/packages/lint/src/data-model-rules.fix-lines.test.ts index 58b12cff259..5663fdffbe7 100644 --- a/packages/lint/src/data-model-rules.fix-lines.test.ts +++ b/packages/lint/src/data-model-rules.fix-lines.test.ts @@ -150,8 +150,8 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => { it('object/missing-name-field: the pointer, and where the titleFormat reasoning lives', () => { const fix = fixOf(record(lintDataModel([{ name: LONG_OBJECT, fields: { amount: { type: 'number' } } }])), MISSING_NAME_FIELD); expect(fix).toBe( - "Set `nameField: ''`; a composite title to import, sort or search by is a text field with an " + - "`expression` (a text formula is display-only); `titleFormat` does not count (`os explain title-format-retired`).", + "Set `nameField`; a composite title to import, sort or search by is a text field with an `expression` " + + "(a text formula is display-only); `titleFormat` never counts (`os explain title-format-retired`).", ); // The command the line names answers, and says why `titleFormat` does not count. expect(explainRule('title-format-retired')!.paragraphs.join('\n')).toContain('render-only'); diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index 7fbdc122765..0099108becd 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -738,8 +738,8 @@ export function lintDataModel(objects: any[]): LintIssue[] { // [#22161] One remedy line; why `titleFormat` does not count is // `title-format-retired`'s `os explain` text. fix: - "Set `nameField: ''`; a composite title to import, sort or search by is a text field with an " + - "`expression` (a text formula is display-only); `titleFormat` does not count (`os explain title-format-retired`).", + "Set `nameField`; a composite title to import, sort or search by is a text field with an `expression` " + + "(a text formula is display-only); `titleFormat` never counts (`os explain title-format-retired`).", }); } From e9e0ec7bab54205a938229efb4288885db124e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 15:36:08 +0000 Subject: [PATCH 3/4] chore(changeset): declare Metadata-protocol: none for the record-title wording fix Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/25071-composite-title-stored.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/25071-composite-title-stored.md b/.changeset/25071-composite-title-stored.md index 5e950f6ce07..9f2fe58a008 100644 --- a/.changeset/25071-composite-title-stored.md +++ b/.changeset/25071-composite-title-stored.md @@ -5,6 +5,7 @@ fix(lint): the record-title diagnostics no longer steer a composite title that must be queried into a formula field Clause-②: no (wording only: no verdict, severity, code, key or export moves) +Metadata-protocol: none The `title-format-retired` and `title-unresolvable` messages and fix lines, the `object/missing-name-field` fix line, and the `os explain title-format-retired` text now say the same thing: a composite record title that an import, a sort or a search must match is a stored computed field, a non-formula field (such as a `text` field) that declares an `expression`, which the engine computes and stores on every write, designated as `nameField`. A `formula` field with `returnType: 'text'` stays a legal `nameField` 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. From a16a9c02bcb59793cc651d78a5c542a87f67cb34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:02:17 +0000 Subject: [PATCH 4/4] fix(lint): the missing-name-field fix line cites ADR-0079 again Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- packages/lint/src/data-model-rules.fix-lines.test.ts | 4 ++-- packages/lint/src/data-model-rules.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/data-model-rules.fix-lines.test.ts b/packages/lint/src/data-model-rules.fix-lines.test.ts index 5663fdffbe7..7e4fad66152 100644 --- a/packages/lint/src/data-model-rules.fix-lines.test.ts +++ b/packages/lint/src/data-model-rules.fix-lines.test.ts @@ -150,8 +150,8 @@ describe('[#22161] data-model-rules.ts — one remedy line per fix', () => { it('object/missing-name-field: the pointer, and where the titleFormat reasoning lives', () => { const fix = fixOf(record(lintDataModel([{ name: LONG_OBJECT, fields: { amount: { type: 'number' } } }])), MISSING_NAME_FIELD); expect(fix).toBe( - "Set `nameField`; a composite title to import, sort or search by is a text field with an `expression` " + - "(a text formula is display-only); `titleFormat` never counts (`os explain title-format-retired`).", + "Set `nameField` (ADR-0079), never `titleFormat` (`os explain title-format-retired`); a composite title " + + "to import/sort/search by is a text field with an `expression` (a text formula is display-only).", ); // The command the line names answers, and says why `titleFormat` does not count. expect(explainRule('title-format-retired')!.paragraphs.join('\n')).toContain('render-only'); diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index 0099108becd..167736fcf9b 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -738,8 +738,8 @@ export function lintDataModel(objects: any[]): LintIssue[] { // [#22161] One remedy line; why `titleFormat` does not count is // `title-format-retired`'s `os explain` text. fix: - "Set `nameField`; a composite title to import, sort or search by is a text field with an `expression` " + - "(a text formula is display-only); `titleFormat` never counts (`os explain title-format-retired`).", + "Set `nameField` (ADR-0079), never `titleFormat` (`os explain title-format-retired`); a composite title " + + "to import/sort/search by is a text field with an `expression` (a text formula is display-only).", }); }