@contentrain/skills 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -127,7 +127,7 @@ This reduces always-loaded context from thousands of lines to just the essential
127
127
 
128
128
  `@contentrain/skills` is kept in lockstep with the MCP tool registry via cross-package parity tests (`tests/mcp-parity.test.ts`):
129
129
 
130
- - `skills/contentrain/references/mcp-tools.md` must have an `### <tool>` heading for every tool in the MCP `TOOL_NAMES` registry (currently 24: 19 core + 5 media).
130
+ - `skills/contentrain/references/mcp-tools.md` must have an `### <tool>` heading for every tool in the MCP `TOOL_NAMES` registry (currently 26: 21 core + 5 media).
131
131
  - Key skills (normalize, translate) must not reference legacy `contentrain/{operation}/...` branch prefixes — MCP now emits `cr/*`.
132
132
 
133
133
  When MCP's surface changes, these tests fail until the skill docs catch up.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentrain/skills",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "license": "MIT",
5
5
  "description": "AI agent skills for Contentrain — workflow procedures, framework integration guides",
6
6
  "type": "module",
@@ -61,7 +61,8 @@
61
61
  "tsdown": "^0.21.0",
62
62
  "typescript": "^5.7.0",
63
63
  "vitest": "^3.0.0",
64
- "@contentrain/mcp": "2.3.0"
64
+ "@contentrain/mcp": "3.1.8",
65
+ "@contentrain/rules": "0.7.1"
65
66
  },
66
67
  "scripts": {
67
68
  "build": "tsdown src/index.ts --format esm,cjs --dts",
@@ -16,7 +16,7 @@ Contentrain consists of 6 packages that work together:
16
16
 
17
17
  | Package | Role | How agent uses it |
18
18
  |---|---|---|
19
- | @contentrain/mcp | 19 MCP tools (scan, apply, validate, merge, doctor...) | MCP tool calls |
19
+ | @contentrain/mcp | 22 MCP tools (scan, apply, validate, merge, reconcile, doctor...) | MCP tool calls |
20
20
  | contentrain (CLI) | init, serve, generate, doctor, diff, status | Shell commands |
21
21
  | @contentrain/types | Shared TypeScript contracts | Type safety |
22
22
  | @contentrain/query | Generated SDK client (Prisma-pattern) | `import from '#contentrain'` |
@@ -121,8 +121,9 @@ slug: getting-started
121
121
  | `contentrain_scan` | Scan project for structure or content candidates |
122
122
  | `contentrain_apply` | Apply normalize operation (extract or reuse) |
123
123
  | `contentrain_validate` | Validate content against model schemas |
124
- | `contentrain_submit` | Push contentrain/* branches to remote |
124
+ | `contentrain_submit` | Push cr/* branches to remote |
125
125
  | `contentrain_merge` | Merge a review-mode branch into contentrain locally |
126
+ | `contentrain_reconcile` | Content-aware three-way merge of a diverged contentrain ↔ base pair |
126
127
  | `contentrain_bulk` | Run batch operations on existing content |
127
128
 
128
129
  ## Calling Sequences
@@ -81,6 +81,7 @@ Model definitions live at `.contentrain/models/{model-id}.json`. One file per mo
81
81
  "kind": "document",
82
82
  "domain": "blog",
83
83
  "i18n": true,
84
+ "title_field": "title",
84
85
  "description": "Blog articles with markdown body",
85
86
  "fields": {
86
87
  "title": { "type": "string", "required": true, "max": 120 },
@@ -100,6 +101,7 @@ Model definitions live at `.contentrain/models/{model-id}.json`. One file per mo
100
101
  | `kind` | string | Yes | `singleton`, `collection`, `document`, `dictionary` |
101
102
  | `domain` | string | Yes | Organizational group (maps to content subdirectory) |
102
103
  | `i18n` | boolean | Yes | Whether model supports multiple locales |
104
+ | `title_field` | string | Yes | Field shown as an entry's title. Must name a field on this model whose type can render as text; dictionaries use `"key"` |
103
105
  | `description` | string | No | Model description for docs and agent context |
104
106
  | `fields` | object | Yes (except dictionary) | Field definitions. Dictionary has NO fields |
105
107
  | `content_path` | string | No | Framework-relative path. When set, content writes here instead of `.contentrain/content/` |
@@ -183,6 +183,7 @@ Several fields are managed by the platform. Agents MUST NOT set or modify them.
183
183
  | `status` | `.contentrain/meta/` | Platform workflow engine |
184
184
  | `source` | `.contentrain/meta/` | Set by MCP: `"agent"`, `"human"`, or `"import"` |
185
185
  | `updated_by` | `.contentrain/meta/` | Set by MCP: agent name or user email |
186
+ | `updated_at` | `.contentrain/meta/` | Set by MCP on every write, ISO 8601 UTC. Absent on entries written before the field existed — absent means unknown, never backfilled |
186
187
  | `approved_by` | `.contentrain/meta/` | Set by Studio on review approval |
187
188
 
188
189
  **Rule:** When creating content via `contentrain_content_save`, provide only the content fields defined in the model. Do not include `id`, `createdAt`, `updatedAt`, `status`, or any metadata fields.
@@ -88,6 +88,7 @@ Create or update a model definition (upsert).
88
88
  | `kind` | string | Yes | `singleton`, `collection`, `document`, `dictionary` |
89
89
  | `domain` | string | Yes | Organizational group |
90
90
  | `i18n` | boolean | Yes | Whether model supports localization |
91
+ | `title_field` | string | Yes | Field shown as an entry's title. Must name a field on this model typed `string`, `text`, `slug`, `email`, `url`, `code`, `markdown` or `richtext`. Dictionaries have no fields — use `"key"` |
91
92
  | `fields` | object | No | Field definitions (not for dictionary) |
92
93
  | `description` | string | No | Model description |
93
94
  | `content_path` | string | No | Framework-relative path for content files |
@@ -95,6 +96,8 @@ Create or update a model definition (upsert).
95
96
 
96
97
  Notes:
97
98
  - Upserts by model ID: existing model is updated, new model is created
99
+ - `title_field` is required on create AND must stay correct when you rename or drop a field
100
+ - Pick what a reader uses to tell entries apart — the headline, the name, the question. Not the slug, not the icon, not a relation ID
98
101
  - `locale_strategy: "none"` requires `i18n: false`
99
102
  - `content_path` overrides default `.contentrain/content/` location
100
103
 
@@ -129,7 +132,9 @@ Each entry in the `entries` array:
129
132
  "id": "optional-entry-id",
130
133
  "slug": "optional-slug",
131
134
  "locale": "en",
132
- "data": { "field_name": "field_value" }
135
+ "data": { "field_name": "field_value" },
136
+ "publish_at": "2026-04-01T00:00:00Z",
137
+ "expire_at": "2026-06-30T23:59:59Z"
133
138
  }
134
139
  ```
135
140
 
@@ -142,6 +147,8 @@ Each entry in the `entries` array:
142
147
  | **Singleton** | `locale`, `data` | No `id` or `slug` needed |
143
148
  | **Dictionary** | `locale`, `data` | `data` is flat key-value: `{ "auth.login": "Log In" }` |
144
149
 
150
+ **Scheduling** (`publish_at`, `expire_at`, optional, ISO 8601) sits next to `data`, never inside it. It is stored in meta only and **never changes `status`** — a past `publish_at` does not publish a draft; the dates gate delivery of an entry that is already `published`. Pass `null` to clear a date; omit the key to leave it unchanged. To publish, use `contentrain_bulk update_status`.
151
+
145
152
  **NEVER include system fields** (`status`, `source`, `updated_by`, `updated_at`, `createdAt`, `updatedAt`) in `data`.
146
153
 
147
154
  ### contentrain_content_delete
@@ -161,6 +168,46 @@ Delete a content entry.
161
168
  > mapped onto `data.json` + the default-locale meta. Omit `locale`; use
162
169
  > `contentrain_validate` `fix: true` to clean up stray per-locale meta.
163
170
 
171
+ ### contentrain_vocabulary_save
172
+
173
+ Add or update canonical vocabulary terms. Merges — a term you omit is untouched.
174
+
175
+ | Parameter | Type | Required | Description |
176
+ |-----------|------|----------|-------------|
177
+ | `terms` | object | Yes | `{ "term-slug": { "en": "…", "tr": "…" } }` |
178
+
179
+ The **outer** key is the term (kebab-case, locale-independent); the **inner**
180
+ key is a locale code. Grouping by locale at the top level is the common
181
+ mistake and produces a vocabulary that matches nothing — the tool rejects it.
182
+
183
+ ```json
184
+ {
185
+ "terms": {
186
+ "sign-in": { "en": "Sign in", "tr": "Giriş yap" },
187
+ "add-to-cart": { "en": "Add to cart", "tr": "Sepete ekle" }
188
+ }
189
+ }
190
+ ```
191
+
192
+ Notes:
193
+ - This is the only supported way to write `.contentrain/vocabulary.json` — do not edit it by hand
194
+ - Replacing an existing translation is allowed and reported in `advisories`
195
+ - Two terms sharing a translation is reported too; that is what a vocabulary exists to prevent
196
+
197
+ ### contentrain_vocabulary_delete
198
+
199
+ Remove canonical terms by slug. Content already using a term is not touched —
200
+ the vocabulary only advises.
201
+
202
+ | Parameter | Type | Required | Description |
203
+ |-----------|------|----------|-------------|
204
+ | `terms` | string[] | Yes | Term slugs to remove |
205
+ | `confirm` | boolean | Yes | Must be `true` |
206
+
207
+ A slug that is not in the vocabulary is reported in `missing`, not an error,
208
+ and a call that matches nothing returns `status: "no-op"` without creating a
209
+ branch.
210
+
164
211
  ### contentrain_content_list
165
212
 
166
213
  List content entries for a model (read-only).
@@ -270,7 +317,7 @@ Validate project content against model schemas.
270
317
  | Parameter | Type | Required | Description |
271
318
  |-----------|------|----------|-------------|
272
319
  | `model` | string | No | Validate specific model only (omit for all) |
273
- | `fix` | boolean | No | Auto-fix structural issues (canonical sort, orphan meta, missing locale files, stray non-i18n meta layout). Default: `false` |
320
+ | `fix` | boolean | No | Auto-fix structural issues (canonical sort, orphan meta, missing locale files, stray non-i18n meta layout, `publish_at`/`expire_at` copies an older `content_save` left in document frontmatter) and backfill a missing model `title_field`. Default: `false` |
274
321
 
275
322
  ### contentrain_submit
276
323
 
@@ -297,7 +344,28 @@ Target the branch by exact name, or resolve it by model:
297
344
 
298
345
  \* Provide either `branch` or `model`. Ambiguous model matches return the candidate branches so you can pick one (or pass `latest: true`).
299
346
 
300
- Returns `{ branch, action, commit, sync }` — `sync.skipped[]` lists files the selective sync skipped because the developer has uncommitted changes. The CLI surfaces this as a warning.
347
+ Returns `{ branch, action, commit, sync, base_advance, remote_push }` — `sync.skipped[]` lists files the selective sync skipped because the developer has uncommitted changes; `base_advance: "blocked_diverged"` means the merge landed on `contentrain` but the base branch has diverged and was not advanced (run `contentrain_reconcile`). The CLI surfaces these as warnings.
348
+
349
+ ### contentrain_reconcile
350
+
351
+ Content-aware three-way merge of a diverged `contentrain` ↔ base-branch pair. The divergence exists when the base branch holds commits `contentrain` does not (typically a dual-domain migration PR); until it is reconciled, every auto-merge write reports `base_advance: "blocked_diverged"`. Everything one side changed merges mechanically (entry-, key-, and term+locale-level); only the same item changed differently on both sides becomes a conflict. Local-filesystem only (`localWorktree`).
352
+
353
+ | Parameter | Type | Required | Description |
354
+ |-----------|------|----------|-------------|
355
+ | `dry_run` | boolean | No | Default `true` — preview the plan and conflicts, touch nothing. Set `false` to execute |
356
+ | `resolutions` | array | No | Decisions for conflicts from a previous dry-run: `{ id, choose: "ours"\|"theirs" }` or `{ id, value }` |
357
+
358
+ ```json
359
+ { "dry_run": true }
360
+ ```
361
+
362
+ Returns `{ status: "in_sync" | "preview" | "conflicts" | "reconciled", summary, changes, conflicts, ... }`. On execute it lands as a two-parent merge commit on `contentrain` and fast-forwards the base branch.
363
+
364
+ Notes:
365
+ - Always dry-run first. Conflicts are content decisions — ask the editor/user which side wins; never guess.
366
+ - Conflict ids hash position AND values: a resolution made against a stale preview is dropped and the conflict re-reported with a fresh id.
367
+ - Document bodies are never text-merged; two-sided body edits always conflict.
368
+ - Non-content files that conflict (source code committed to both branches) abort with `RECONCILE_SOURCE_CONFLICT` — those need a manual git merge.
301
369
 
302
370
  ### contentrain_branch_list
303
371
 
@@ -344,7 +412,9 @@ Run git-backed batch operations on existing content entries.
344
412
  | `model` | string | Yes | Target model ID |
345
413
  | `source_locale` | string | Conditional | For `copy_locale` |
346
414
  | `target_locale` | string | Conditional | For `copy_locale` |
347
- | `entry_ids` | string[] | Conditional | For `update_status` and `delete_entries` |
415
+ | `entry_ids` | string[] | Conditional | For `update_status` on collections, and for `delete_entries` |
416
+ | `slugs` | string[] | Conditional | For `update_status` on documents — documents are keyed by slug, not entry ID |
417
+ | `locale` | string | No | Scope `update_status` to one locale (i18n models); default is every supported locale |
348
418
  | `status` | string | Conditional | For `update_status` |
349
419
  | `confirm` | boolean | Conditional | For `delete_entries` (must be `true`) |
350
420
 
@@ -353,7 +423,7 @@ Run git-backed batch operations on existing content entries.
353
423
  | Operation | Applicable Kinds | Required Params |
354
424
  |-----------|-----------------|-----------------|
355
425
  | `copy_locale` | collection, singleton, dictionary (i18n only) | `source_locale`, `target_locale` |
356
- | `update_status` | collection only | `entry_ids`, `status` |
426
+ | `update_status` | collection (`entry_ids`), document (`slugs`), singleton and dictionary (neither) | `status`, plus the list that matches the kind |
357
427
  | `delete_entries` | collection only | `entry_ids`, `confirm: true` |
358
428
 
359
429
  #### Rules
@@ -28,6 +28,7 @@ Contentrain has four model kinds, each with distinct storage format, ID manageme
28
28
  "kind": "singleton",
29
29
  "domain": "marketing",
30
30
  "i18n": true,
31
+ "title_field": "title",
31
32
  "fields": {
32
33
  "title": { "type": "string", "required": true, "max": 80 },
33
34
  "subtitle": { "type": "text", "max": 200 },
@@ -73,6 +74,7 @@ Contentrain has four model kinds, each with distinct storage format, ID manageme
73
74
  "kind": "collection",
74
75
  "domain": "marketing",
75
76
  "i18n": true,
77
+ "title_field": "name",
76
78
  "fields": {
77
79
  "name": { "type": "string", "required": true },
78
80
  "role": { "type": "string", "required": true },
@@ -148,6 +150,7 @@ MCP tools return collections as arrays with `id` injected:
148
150
  "kind": "document",
149
151
  "domain": "blog",
150
152
  "i18n": true,
153
+ "title_field": "title",
151
154
  "fields": {
152
155
  "title": { "type": "string", "required": true, "max": 120 },
153
156
  "slug": { "type": "slug", "required": true, "unique": true },
@@ -204,7 +207,8 @@ Your markdown body content here...
204
207
  "name": "Error Messages",
205
208
  "kind": "dictionary",
206
209
  "domain": "system",
207
- "i18n": true
210
+ "i18n": true,
211
+ "title_field": "key"
208
212
  }
209
213
  ```
210
214
 
@@ -165,6 +165,8 @@ In v1, media fields store URL/path strings only. Upload and processing are out o
165
165
  | `accept` | `image`, `video`, `file` | Allowed MIME types: `"image/png,image/jpeg"` |
166
166
  | `maxSize` | `image`, `video`, `file` | Maximum file size in bytes |
167
167
  | `description` | ALL | Human-readable hint (shown in Studio UI tooltip, used as agent context) |
168
+ | `label` | ALL | Editor label: a string, or an object keyed by locale (`{ "en": "Body", "tr": "Gövde" }`). Absent shows the raw field name |
169
+ | `order` | ALL | Display position, ascending. Absent sorts last, alphabetically. Fractional values allowed |
168
170
 
169
171
  ## Omission Rules
170
172
 
@@ -157,6 +157,19 @@ Contentrain's storage format is designed to minimize Git merge conflicts:
157
157
  - When a conflict occurs, the agent should NOT attempt to resolve it. Inform the user and direct them to Studio.
158
158
  - Collection object-map format with sorted keys means ~0.3% chance of conflict for two new entries in a 350-entry collection.
159
159
 
160
+ ### 4.4 Branch Divergence and Reconcile
161
+
162
+ The `contentrain` branch normally contains the base branch, so advancing the base is always a fast-forward. The invariant breaks legitimately in exactly one case: a **dual-domain migration** — a package bump and a `.contentrain/` schema change that must ship as one atomic PR to the base branch. After it lands, the branches have **diverged**.
163
+
164
+ - Divergence is a product state, not an error. Writes still land on `contentrain` and report `base_advance: "blocked_diverged"`; nothing is lost.
165
+ - The exit is `contentrain_reconcile` (CLI: `npx contentrain reconcile`) — a content-aware three-way merge. Always dry-run first.
166
+ - Everything one side changed merges mechanically (entry-, key-, and term+locale-level). Only the same item changed differently on both sides becomes a `ConflictItem`.
167
+ - Conflicts are content decisions: take them to the editor/user, collect `{ id, choose }` resolutions, run again. NEVER guess a side.
168
+ - Conflict ids hash position AND values — a decision made against a stale preview is dropped and re-asked. This is intended, not a bug.
169
+ - The result is a two-parent merge commit on `contentrain`; the fast-forward advance works again afterwards.
170
+
171
+ **Dual-domain migration recipe**: (1) build the migration on an isolated branch and land it on the base branch via PR — never hand-edit `.contentrain/` on the base outside this exception; (2) immediately after the PR merges, run the reconcile (Studio does this from its health check; locally run `npx contentrain reconcile`); (3) resume normal writes.
172
+
160
173
  ---
161
174
 
162
175
  ## 5. Workflow States
@@ -39,13 +39,14 @@ Confirm:
39
39
  ### 2. Pick the Correct Bulk Operation
40
40
 
41
41
  - `copy_locale`: clone one locale to another for i18n-enabled `collection`, `singleton`, or `dictionary` models
42
- - `update_status`: update metadata state — many entries at once for `collection`, or the single record of a `singleton`/`dictionary`
42
+ - `update_status`: update metadata state — many entries at once for `collection` (by `entry_ids`) or `document` (by `slugs`), or the single record of a `singleton`/`dictionary`
43
43
  - `delete_entries`: remove many collection entries at once
44
44
 
45
45
  ### 3. Apply Safety Rules
46
46
 
47
47
  - never use `copy_locale` on non-i18n models
48
- - `update_status` takes `entry_ids` for `collection` models and **rejects them** for `singleton`/`dictionary` — those have one meta record per locale, so omit `entry_ids` entirely. Document models are not supported (their meta is keyed by slug).
48
+ - `update_status` addresses records the way their meta is laid out: `entry_ids` for `collection` models, `slugs` for `document` models (meta is one file per slug — the same identity `contentrain_content_save` uses), and **neither** for `singleton`/`dictionary`, which have one meta record per locale. The wrong list is rejected with a message naming the right one.
49
+ - `publish_at` on `contentrain_content_save` does **not** publish — it gates delivery of an already-published entry. `update_status` is the only way to change status.
49
50
  - `delete_entries` is collection-only
50
51
  - `update_status` rewrites every supported locale unless you pass `locale`. Pass it when restoring one locale's status, or you will change the other's too.
51
52
  - confirm entry IDs before delete operations
@@ -85,7 +86,19 @@ Scoped to one locale — leaves every other locale's status untouched:
85
86
  }
86
87
  ```
87
88
 
88
- A singleton or dictionary — one meta record, so no `entry_ids`:
89
+ A document model — meta is one file per slug, so pass `slugs`, not `entry_ids`:
90
+
91
+ ```json
92
+ {
93
+ "operation": "update_status",
94
+ "model": "guide-sections",
95
+ "slugs": ["instagram-9", "instagram-10", "instagram-11"],
96
+ "status": "published",
97
+ "locale": "tr"
98
+ }
99
+ ```
100
+
101
+ A singleton or dictionary — one meta record, so no `entry_ids` or `slugs`:
89
102
 
90
103
  ```json
91
104
  {
@@ -63,7 +63,7 @@ If the user's request does not match an existing model, offer to create one firs
63
63
 
64
64
  - Check `.contentrain/vocabulary.json` for canonical terms. All content must use these terms consistently across locales. Do not use alternative spellings or synonyms for vocabulary-defined terms.
65
65
  - Check `.contentrain/context.json` for tone conventions (professional, casual, technical).
66
- - **For dictionaries:** Before creating any new key, call `contentrain_content_list` on the target model and scan existing values. If the value you intend to write already exists under a different key, REUSE the existing key instead of creating a new one. MCP will also warn you via `advisories` in the save response if duplicates are detected.
66
+ - **For dictionaries:** Before creating any new key, call `contentrain_content_list` on the target model and scan existing values. If the value you intend to write already exists under a different key, REUSE the existing key instead of creating a new one. MCP will also warn you via `advisories` in the save response if duplicates are detected. Replacing an existing key's value is allowed — the response reports what was replaced, so read the advisories rather than assuming a save was purely additive.
67
67
 
68
68
  ### 5. Generate Content
69
69
 
@@ -167,7 +167,7 @@ For large content operations (10+ entries), also recommend running `contentrain
167
167
  Call `contentrain_submit` to push branches to remote:
168
168
 
169
169
  - Write operations already create branches and commit changes automatically.
170
- - `contentrain_submit` pushes unmerged `contentrain/*` branches to the remote.
170
+ - `contentrain_submit` pushes unmerged `cr/*` branches to the remote.
171
171
  - In `auto-merge` mode, branches are already merged to the base branch during the write operation.
172
172
  - In `review` mode, branches are pushed for team review.
173
173
 
@@ -65,7 +65,7 @@ Default rules:
65
65
  Follow these rules:
66
66
 
67
67
  - model IDs must be kebab-case
68
- - field names must be snake_case
68
+ - field names must be snake_case — for *new* fields. A field an existing model already has under a legacy name (`creativeWork`) is kept by `contentrain_model_save` and reported in `schema_warnings`; never rename it just to satisfy the rule, since the rename must reach its content keys in every locale and every consumer too
69
69
  - relation fields must define a target `model`
70
70
  - prefer small, explicit schemas over large generic `object` blobs
71
71
  - only mark fields as `required` when the content truly cannot function without them
@@ -95,6 +95,7 @@ Before writing, show:
95
95
  - domain
96
96
  - `i18n` behavior
97
97
  - field list with types
98
+ - `title_field` — which field titles an entry, and why that one
98
99
  - relation targets
99
100
  - any custom `content_path` / `locale_strategy`
100
101
 
@@ -113,6 +114,7 @@ Example:
113
114
  "kind": "singleton",
114
115
  "domain": "marketing",
115
116
  "i18n": true,
117
+ "title_field": "title",
116
118
  "fields": {
117
119
  "title": { "type": "string", "required": true },
118
120
  "subtitle": { "type": "text" },
@@ -122,6 +124,29 @@ Example:
122
124
  }
123
125
  ```
124
126
 
127
+ `title_field` is required. It names the field shown as an entry's title in
128
+ listings, pickers and relation references, and must be a field on this model
129
+ typed `string`, `text`, `slug`, `email`, `url`, `code`, `markdown` or
130
+ `richtext`. Dictionary models have no fields, so they use the reserved value
131
+ `"key"` — the entry key is the title.
132
+
133
+ Choose what a reader uses to tell one entry from another: the headline, the
134
+ name, the question. Not the slug, not the icon, not a relation ID. Consumers
135
+ that had to infer this picked the icon over the headline, which is why the
136
+ property exists.
137
+
138
+ **When updating an existing model**, re-check `title_field` in the same call:
139
+ renaming or removing the field it points at makes the model invalid.
140
+
141
+ Give every field a `label` and an `order`. Fields are stored alphabetically, so
142
+ without `order` an editor lists them alphabetically — on a sixteen-field article
143
+ model that puts `author` first and `title` fifteenth. Without `label` it shows
144
+ the raw key: `body_public`, `is_category_hero`.
145
+
146
+ Number `order` in tens (10, 20, 30) so a field can be inserted later without
147
+ renumbering. Use a per-locale `label` (`{ "en": "…", "tr": "…" }`) on a
148
+ multilingual project; a plain string is one label for every locale.
149
+
125
150
  ### 8. Validate Downstream Impact
126
151
 
127
152
  After changing a model:
@@ -67,6 +67,7 @@ After evaluation, write the plan as `.contentrain/normalize-plan.json`:
67
67
  "kind": "singleton",
68
68
  "domain": "marketing",
69
69
  "i18n": true,
70
+ "title_field": "title",
70
71
  "fields": {
71
72
  "title": { "type": "string", "required": true },
72
73
  "subtitle": { "type": "string" }
@@ -45,6 +45,13 @@ Auto-fix candidates:
45
45
  - stray non-i18n meta layout (an `i18n: false` model with per-locale meta files
46
46
  left by older versions) — pruned or migrated deterministically, never merging
47
47
  or downgrading a status
48
+ - `publish_at`/`expire_at` copies an older `content_save` left in document
49
+ frontmatter — stripped only when the meta record holds the same key, so the
50
+ value is never lost and an author's own frontmatter field is never touched
51
+ - a missing model `title_field` — backfilled by the same ranking Studio's title
52
+ resolver uses (required name-like field first; slug/url/code never inferred),
53
+ reported as a notice naming the rule. Read it: if the pick is wrong, correct it
54
+ with `contentrain_model_save`
48
55
  - structural housekeeping reported by the validator
49
56
 
50
57
  Manual fix candidates:
@@ -100,6 +100,7 @@ contentrain_content_save({
100
100
  - **collection:** `{ "id": "optional-id", "locale": "en", "data": { "name": "John", "role": "CEO" } }` -- omit `id` to auto-generate, provide `id` to update.
101
101
  - **document:** `{ "slug": "getting-started", "locale": "en", "data": { "title": "Getting Started", "body": "# Welcome\n..." } }` -- `slug` is required, use `"body"` key for markdown content.
102
102
  - **dictionary:** `{ "locale": "en", "data": { "auth.login": "Log In", "auth.logout": "Log Out" } }` -- flat key-value pairs, no `id` or `slug`.
103
+ Saves merge: keys you omit are kept, and replacing an existing key's value is allowed. The response reports what was replaced, so read `advisories` rather than assuming a save was purely additive.
103
104
 
104
105
  **Critical rules:**
105
106
  - NEVER include system fields (`status`, `source`, `updated_by`, `approved_by`) in `data`. These are managed in meta files, not content. Temporal data (created/updated timestamps) comes from git history.
@@ -87,6 +87,7 @@ Before writing, show:
87
87
  - domain
88
88
  - `i18n` behavior
89
89
  - field list with types
90
+ - `title_field` — which field titles an entry, and why that one
90
91
  - relation targets
91
92
  - any custom `content_path` / `locale_strategy`
92
93
 
@@ -105,6 +106,7 @@ Example:
105
106
  "kind": "singleton",
106
107
  "domain": "marketing",
107
108
  "i18n": true,
109
+ "title_field": "title",
108
110
  "fields": {
109
111
  "title": { "type": "string", "required": true },
110
112
  "subtitle": { "type": "text" },
@@ -114,6 +116,29 @@ Example:
114
116
  }
115
117
  ```
116
118
 
119
+ `title_field` is required. It names the field shown as an entry's title in
120
+ listings, pickers and relation references, and must be a field on this model
121
+ typed `string`, `text`, `slug`, `email`, `url`, `code`, `markdown` or
122
+ `richtext`. Dictionary models have no fields, so they use the reserved value
123
+ `"key"` — the entry key is the title.
124
+
125
+ Choose what a reader uses to tell one entry from another: the headline, the
126
+ name, the question. Not the slug, not the icon, not a relation ID. Consumers
127
+ that had to infer this picked the icon over the headline, which is why the
128
+ property exists.
129
+
130
+ **When updating an existing model**, re-check `title_field` in the same call:
131
+ renaming or removing the field it points at makes the model invalid.
132
+
133
+ Give every field a `label` and an `order`. Fields are stored alphabetically, so
134
+ without `order` an editor lists them alphabetically — on a sixteen-field article
135
+ model that puts `author` first and `title` fifteenth. Without `label` it shows
136
+ the raw key: `body_public`, `is_category_hero`.
137
+
138
+ Number `order` in tens (10, 20, 30) so a field can be inserted later without
139
+ renumbering. Use a per-locale `label` (`{ "en": "…", "tr": "…" }`) on a
140
+ multilingual project; a plain string is one label for every locale.
141
+
117
142
  ### 8. Validate Downstream Impact
118
143
 
119
144
  After changing a model:
@@ -93,6 +93,7 @@ After evaluating candidates, write the plan as `.contentrain/normalize-plan.json
93
93
  "kind": "singleton",
94
94
  "domain": "marketing",
95
95
  "i18n": true,
96
+ "title_field": "title",
96
97
  "fields": { "title": { "type": "string", "required": true }, "subtitle": { "type": "string" } }
97
98
  }
98
99
  ],