Skip to content

Commit 3557366

Browse files
feat(spec)!: refuse an object that declares titleFormat at save and os validate, with the nameField prescription (#29499)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9XPP2t6ZazH2FfFDug17NNH)_ Fixes #29007 Clause-②: no (narrowing) Metadata-protocol: narrowed ## 维护者速读 1. 对象定义里再写 `titleFormat`,保存、发布、恢复和 `os validate` 都会直接拒绝,并提示改用 `nameField`。 2. 已存的旧对象照常读取和展示,不做任何数据转换;只有下次保存时才会被要求改掉。 3. 记录标题的取法不变:先看 `nameField`,没有就按原规则推导,`titleFormat` 一直没生效,现在只是不再假装接受。 4. 平台自带的 68 个系统对象已去掉 `titleFormat`;其中 3 个补上了本来就在用的标题字段,用户看到的标题不变。 5. 这是破坏性变更(BREAKING),升级指南已登记迁移说明,dogfood 全量回归已跑。 6. 草稿保存仍会落库但标记为无效、无法发布;是否连草稿也拒绝,留作待定问题。 ## What changes Ruling of record 6574393684, letter B: `titleFormat` is refused, not deleted and not honoured. - **One judge at every door.** `title-format-retired` moves out of the advisory `validateRecordTitle` into a new gating registry rule, `validateTitleFormatRetired` (`packages/lint/src/validate-record-title.ts`, registered in `authoring-rules.ts` with `surfaces: CLI_AND_RUNTIME`, `runtimeTypes: ['object']`). The registry is what `os validate`, `os build`, `os lint` and the runtime publish gate already run, so the CLI and the metadata save gate (`422 INVALID_METADATA` on an active save, a draft promotion and a restore) refuse with the same finding. The message and the fix line are byte-identical to the existing prescription; no new wording. `validateRecordTitle` now reports `title-unresolvable` only. - **Schema.** `object.zod.ts`: the `titleFormat` describe gains one sentence (refused at the metadata save gate and by `os validate`; a stored object that carries it still parses and reads). The key stays in the schema; no conversion drops it; `provisionPrimary`, console ordering and ADR-0079 D3/D9 are untouched. - **ADR-0087.** New D3 semantic entry `object-title-format-refused` (`packages/spec/src/migrations/entries/semantic/18.object-title-format-refused.ts`) with the replacement, reason and acceptance criteria; the changeset registers it. - **Every code-shipped hit moves to `nameField`.** 68 system objects across platform-objects, plugin-approvals, plugin-audit, plugin-security, plugin-sharing, plugin-webhooks, service-automation, service-mailbox, service-messaging, service-realtime and service-storage drop `titleFormat`. 65 already declared the `nameField` that titles them. `sys_attachment` (`file_name`), `sys_audience_binding_suggestion` (`permission_set_name`) and `sys_notification_template` (`subject`) now declare the field the registry already stamped, so every record title reads as before. Comments that described the template were rewritten in place. No example or template in the repo declared it. - **Docs.** `data-modeling/objects.mdx`, `schema-design.mdx`, `frontend/building-crud-app.mdx`, `docs/notes/crm-development-standards.mdx`; references regenerated; four CLI transcripts now print "author-time rules (62)" (`deployment/cli.mdx`, `deployment/validating-metadata.mdx`, `getting-started/build-with-claude-code.mdx`, `ui/react-pages.mdx`). - **Liveness.** `packages/spec/liveness/sharing_rule.json`: the name note no longer cites `titleFormat`; the label evidence now cites the list view columns and sort keys. ### Forced test edits These tests read `X.titleFormat` from the shipped object to prove a formula matched the old template. The template no longer ships, so each file now keeps the retired template text in a local `RETIRED_TITLE_TEMPLATES` map and reads `RETIRED_TITLE_TEMPLATES[X.name]`; the assertions are otherwise unchanged: - `packages/platform-objects/src/identity/identity-display-title.test.ts` - `packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts` - `packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts` (one comment adjusted) - `packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts` - `packages/services/service-automation/src/sys-automation-run-display-title.test.ts` - `packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts` - `packages/services/service-messaging/src/objects/notification-display-title.test.ts` - `packages/services/service-realtime/src/objects/sys-presence-display-title.test.ts` `packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts` asserted the shipped template; it now asserts `titleFormat` is undefined on the shipped object (test renamed accordingly). ## Tests - `packages/lint/src/validate-record-title.test.ts`: the refusal is an error with the prescription, the envelope and controls; the explain text names the save gate and `os validate`. - `packages/lint/src/runtime-gate.object-writes.test.ts`: the rule is on the object door roster; an object carrying `titleFormat` is refused; re-saving a stored row that carries it is refused, and the healed row saves. - `packages/metadata-protocol/src/protocol.draft-publish-verdict.test.ts`: a `title-format-retired` case in the publish verdict table; a stored object carrying `titleFormat` still loads, its next save is refused 422 with the prescription, and the `nameField` control saves. - `packages/cli/test/validate-title-format-retired.test.ts` (new, integration): `os validate --json` exits 1 with `title-format-retired` and "migrate to nameField"; the `nameField` control exits 0. - `packages/platform-objects/src/managed-api-method-affordance-sweep.test.ts`: a repo-wide pin that no code-shipped object declares `titleFormat`, with a red fixture and a control. ## Acceptance notes - **Draft saves.** Per the publish-gate contract (#4463 D1) a draft save is not gated: it still stores, its `_diagnostics` reads `valid: false` with this refusal, and its promotion is refused. The ruling names the save gate; I read that as the gate that already exists. Refusing drafts too would be a gate-wide change, so it is left as an open question. - **Out of scope (governed).** `skills/objectstack-data/SKILL.md` still calls `titleFormat` "Deprecated, not removed". It is a governed path, so this PR leaves it for a follow-up. - **Still-parsed readers kept.** The metadata-core FLS contract fixture and the ui renderers still read the key; the ruling keeps it parseable, so they stay. - **Title text.** Dropping a template changes only the blank-pointer fallback text on objects whose `nameField` value is empty; main-path titles are unchanged (measured per object). ## Verification **Round 2 (head `98601175`, main merged through `os-regen-merge.sh`).** Contract review FAIL fixes: the declaration line is now `Metadata-protocol: narrowed` (a refused value is narrowed), and the four CLI transcripts print 62, the count `check:docs-transcript-drift` derives on the merged tree (#29424 landed one more rule on main). References were regenerated from the merged tree. On this head: lint, spec, platform-objects, plugin-security, service-automation, metadata-protocol and cli suites green (cli 6879 passed / 9 skipped); typecheck green for cli, lint and metadata-protocol; `check:generated` green; dogfood battery green (397 files passed / 3 skipped; 3277 tests passed / 28 skipped; lock VERDICT command-exit 0); `dispatch-gates --ran` 113 derived / 113 run / 0 NOT-MEASURED / 0 red; `check-changeset-no-major --event` and `check-adr-0087-registration` exit 0. **Round 1** readings, on head `0885931864`, under `os-verify-lock` (slot `card-29007`). - **Package suites.** These were all green: lint 7834, spec 23451 (1 todo), platform-objects 1505, plugin-approvals 1381, plugin-audit, plugin-security, plugin-sharing, plugin-webhooks, service-automation, service-mailbox, service-messaging, service-realtime, service-storage, metadata-protocol and cli (6871 passed, 9 skipped, unit and integration). - **Dogfood battery** (`pnpm --filter @objectstack/dogfood test`): 393 files passed and 3 skipped; 3243 tests passed and 28 skipped. - **Typecheck.** `run typecheck` passed for all 15 touched packages, including the test layers. The cli test layer ran out of memory at the default node heap; with the CI heap (`--max-old-space-size=4136`) it passed. - **Derived gates.** `dispatch-gates --ran` reports 113 derived, 113 run, 0 NOT-MEASURED and 0 red. - The first pass found `check:docs-transcript-drift` stale: four CLI transcripts print "author-time rules (60)", and the new gating rule makes the count 61. This PR fixes those transcripts. - `check-plugin-teardown-shape --self-test` exited 3 in the shallow clone. It went green after fetching its pinned commit. - **Clause-② and Metadata-protocol lines.** These were checked offline with `check-changeset-no-major.mjs --base origin/main --event` against this body, which accepts `no (narrowing)` with `narrowed` (exit 0). - **Ablation.** The new rule's severity was changed from `'error'` to `'warning'` and the dist preflight was rerun. That turned lint red (5 tests), metadata-protocol red (2) and the cli `os validate` test red (1). Restoring the severity made them green again, with a clean tree and the preflight `--absent` check passing. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmQaszscubbRkbhxD9k <!-- signed: domain:spec#1 · os-steve · session_01ADHkmQaszscubbRkbhxD9k --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0814b89 commit 3557366

101 files changed

Lines changed: 728 additions & 273 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/platform-objects': minor
5+
'@objectstack/plugin-approvals': minor
6+
'@objectstack/plugin-audit': minor
7+
'@objectstack/plugin-security': minor
8+
'@objectstack/plugin-sharing': minor
9+
'@objectstack/plugin-webhooks': minor
10+
'@objectstack/service-automation': minor
11+
'@objectstack/service-mailbox': minor
12+
'@objectstack/service-messaging': minor
13+
'@objectstack/service-realtime': minor
14+
'@objectstack/service-storage': minor
15+
---
16+
17+
feat!: an object that declares `titleFormat` is refused at save and by `os validate`, with the `nameField` prescription (#29007)
18+
19+
Clause-②: no (narrowing)
20+
Metadata-protocol: narrowed
21+
22+
<!-- adr-0087: registered object-title-format-refused -->
23+
24+
**BREAKING** accept-set narrowing on `object.titleFormat`. The key stays in the schema; what changes is which doors accept it.
25+
26+
- **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.
27+
- **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.
28+
- **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.
29+
- **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.
30+
- **`@objectstack/lint`.** `validateTitleFormatRetired` is a new export and a new gating registry entry. `validateRecordTitle` now reports `title-unresolvable` only.
31+
32+
**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`.

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export const Account = ObjectSchema.create({
6767
| Property | Type | Required | Description |
6868
| :--- | :--- | :--- | :--- |
6969
| `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. |
70-
| `titleFormat` | `string` | optional | Deprecated (ADR-0079 → `nameField`). Render-only title template (e.g. `'{{record.name}} - {{record.code}}'`); an explicit `nameField` takes precedence |
70+
| `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 |
7171
| `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. |
7272
| `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) |
7373
| `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) |

‎content/docs/data-modeling/schema-design.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const MyObject = ObjectSchema.create({
5656
| `icon` | string | Icon identifier | `'building'` |
5757
| `description` | string | Help text | `'Companies...'` |
5858
| `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'` |
59-
| `titleFormat` | string | Deprecated (ADR-0079 → `nameField`). Render-only title template (`{{record.field}}` interpolation); an explicit `nameField` now takes precedence | `'{{record.name}} - {{record.id}}'` |
59+
| `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}}'` |
6060
| `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']` |
6161

6262
### Enable Features

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,7 @@ os compile --json # JSON output for CI pipelines
771771
→ Normalizing stack definition...
772772
→ Lowering inline handlers...
773773
→ Validating protocol compliance...
774-
→ Running author-time rules (62)...
774+
→ Running author-time rules (63)...
775775
→ Checking that every required capability has a provider installable in this edition...
776776
→ Collecting package docs (ADR-0046)... 0 collected
777777
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ A clean run walks the registry and reports timing:
760760
Config: /path/to/support-desk/objectstack.config.ts
761761
Load time: 21ms
762762
→ Validating against ObjectStack Protocol...
763-
→ Running author-time rules (62)...
763+
→ Running author-time rules (63)...
764764
→ Checking that every required capability has a provider installable in this edition...
765765
→ Checking package docs (ADR-0046)...
766766

‎content/docs/frontend/building-crud-app.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export const TaskSchema = {
123123
name: 'task',
124124
label: 'Task',
125125
icon: 'check-circle-2',
126-
titleFormat: '{title}',
126+
nameField: 'title',
127127
fields,
128128
listViews: {
129129
all: {

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ visible: 'status != "resolved"'
310310
◆ Validate
311311
────────────────────────────────────────
312312
→ Validating against ObjectStack Protocol...
313-
→ Running author-time rules (62)...
313+
→ Running author-time rules (63)...
314314
315315
✗ Author-time rules failed (1 issue)
316316
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,7 @@ Metadata query with filtering, sorting, and pagination
958958
| **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). |
959959
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
960960
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
961-
| **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. |
961+
| **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. |
962962
| **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). |
963963
| **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. |
964964
| **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. |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ const result = ApiMethod.parse(data);
162162
| **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). |
163163
| **nameField** | `string` | optional | [ADR-0079] Canonical primary title field — the stored field used as the record display name (e.g. "name", "title"). |
164164
| **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. |
165-
| **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. |
165+
| **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. |
166166
| **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). |
167167
| **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. |
168168
| **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. |

0 commit comments

Comments
 (0)