Skip to content

feat(content): read the title from the field the model declares - #192

Merged
ABB65 merged 2 commits into
mainfrom
feat/title-field-consumption
Aug 13, 2026
Merged

feat(content): read the title from the field the model declares#192
ABB65 merged 2 commits into
mainfrom
feat/title-field-consumption

Conversation

@ABB65

@ABB65 ABB65 commented Aug 13, 2026

Copy link
Copy Markdown
Member

Closes T07b. Stacked on #191 (the migration) — merge that first; without it a bumped Studio fails validation on its own dictionary.

An integration group listed as i-lucide-bot, an article as its slug, a hero slide as f3a81c09d24e. Each is the same bug: Studio guessed which field was an entry's title, and the guess ranked slug alongside string and had no opinion about icon.

@contentrain/types@1.0.0 replaces the guess with a declaration.

package from to
@contentrain/types 0.9.0 1.0.2
@contentrain/mcp 2.3.0 3.0.2
@contentrain/query 7.0.1 7.0.4

One resolver, two call sites

app/utils/entry-title.ts. The entry list and a relation's label need the same answer — a hero slide naming an article one way while the article list names it another is how this started.

The old inference survives one step down, minus its two bad rankings (slug demoted, icon/color excluded). Hand-edited models, the migration window and older CLI output all still exist, and Studio has to render them rather than 500.

1. model.title_field          ← the contract
2. a name-like key            title / name / label / heading
3. a required text field
4. any text field that is not icon / color / slug / code / url
5. the first field            so something renders

updated_at had to be written, not just read

The brief asked only that Studio read the new field. Reading it would have returned nothing forever:

  • applyStudioMetaOverrides replaces MCP's meta wholesale to keep Studio's autoPublish / updated_by semantics
  • updateEntryStatus builds meta by hand

MCP mints the stamp; both of those threw it away. Both stamp it now — one timestamp per call, so a bulk status change reads as the single operation it was. Without this, T06's "sort by recently edited" would have had no data for precisely the entries Studio users create.

The picker writes directly, not through chat

Every other action in that header goes through sendChatPrompt. This one does not: an MCP write takes ~17-18s, and a radio button that stays unconfirmed that long reads as broken.

[modelId].patch.ts gains a titleField branch, deliberately outside the forms.enabled gate — which field titles an entry is part of the model contract, not a forms feature, and gating it would leave listings unreadable on every plan without forms. There is a test that fails if someone moves it back inside. Owner/admin is kept: it writes a model definition and produces a git commit.

The chat path needs nothing — the agent already calls contentrain_model_save with the full definition.

What the bump moved, beyond title_field

The standing rule after an MCP major is to diff its git internals rather than trust a green suite (1.8.0's mergeBranch silently started deleting the source branch). Doing that:

  • GitHubProvider is byte-identical bar import hashes. Studio's write path is unchanged.
  • "a document save no longer replaces what it did not send" is neutral here. Studio already merges frontmatter and body before handing the entry to planContentSave, for both documents and collections — the two merges compose.
  • planVocabularySave / planVocabularyDelete are new. Studio's vocabulary PATCH still does its own read-modify-write; not switched in this PR, because the retry loop from fix(vocabulary): stop concurrent saves from dropping each other's terms #186 guards a lost-update race the plan helper does not. Worth revisiting.

Verified

  • pnpm lint — 0 errors (7 pre-existing warnings)
  • pnpm typecheck — clean, including against the now-required ModelDefinition.title_field
  • pnpm test — 143 files / 1225 tests
  • 17 unit tests on the resolver (each of the three reported miss-titles has one), 4 more on relation labels, 7 integration tests on the PATCH branch, 2 on the updated_at stamps

Not verified — needs staging

The acceptance criterion the brief calls non-negotiable for an MCP major:

  • a real entry saved on staging. Mocked git tests hide write-path behaviour changes; the diff above is evidence, a real save is proof.
  • the picker end to end, and the list re-rendering after it
  • "make the title field title" through chat, and the list refreshing
  • the three reported models actually reading correctly (Integration Groups, Articles, Hero Slides)

Contentrain added 2 commits August 14, 2026 00:37
`@contentrain/types@1.0.0` makes `ModelDefinition.title_field` required.
Every consumer used to guess which field was an entry's title, and the
guess was wrong in ways that made listings unreadable — a hero slide
titled by a relation ID, an article titled by its slug.

This is the migration, so the bump has something valid to land on. It
has to come first: without it, a bumped Studio would fail validation on
its own dictionary.

Produced by `contentrain@0.9.2 validate --fix`, not by hand — the point
of the tool is that the choice and the rule behind it are the CLI's, and
a hand-written `title_field` would be exactly the guess this replaces.

    4 dictionaries  → key       (the entry key is the title; the only legal value)
    3 collections   → name      over slug / subject / description
    1 singleton     → headline
    1 collection    → name      (starters)

Errors go 10 → 1. The one left is a false positive: the plan feature
whose id is `cdn-api-keys` has `key: "cdn.api_keys"`, and the secret
detector reads the name as a credential. Worth reporting upstream now
that `contentrain validate` exits non-zero — a CI step running it would
fail on a correctly-named feature flag.

Carried along, because the same command writes them:

- model files pick up canonical key order from `MODEL_FIELD_ORDER`,
  which moved into `@contentrain/types`. `plans.json` had its top-level
  keys scrambled; fields sort alphabetically now.
- two `error-messages` keys swap into canonical order.
- orphan meta records for `plans/community` and nine plan features.
  They land as `source: import`, `status: draft` — the same shape every
  neighbouring record already has, so nothing changes what the app
  renders.
- `context.json` regenerates.

Run in an isolated copy rather than here: `--fix` works through the
`contentrain` branch, and this repo's is 44 commits behind `main` — the
CLI refused rather than writing onto stale content, which is the right
refusal and a separate thing to fix. Studio's own dictionary lives on
`main` and is edited by PR; the resulting files were brought over and
re-validated in place.
An integration group listed as `i-lucide-bot`, an article as its slug, a
hero slide as `f3a81c09d24e`. Each is the same bug: Studio guessed which
field was an entry's title, and the guess ranked `slug` alongside
`string` and had no opinion about `icon`.

`@contentrain/types@1.0.0` replaces the guess with a declaration.
Bumps types 0.9.0 → 1.0.2, mcp 2.3.0 → 3.0.2, query 7.0.1 → 7.0.4.

The resolution lives in one place, `app/utils/entry-title.ts`, because
two call sites need the same answer — the entry list and a relation's
label — and a hero slide naming an article one way while the article
list names it another is how this started. The old inference survives
one step down, minus its two bad rankings: hand-edited models, the
migration window and older CLI output all still exist, and Studio has to
render them rather than fail.

Relation labels take the target model, so `ARTICLE: f3a81c09d24e` becomes
the article's headline.

## What the bump moved, beyond `title_field`

The rule after an MCP major is to diff its git internals rather than
trust a green suite. Doing that:

- `GitHubProvider` — byte-identical bar import hashes. Studio's write
  path is unchanged.
- "a document save no longer replaces what it did not send" is neutral
  here: Studio already merges frontmatter and body before handing the
  entry to `planContentSave`, for both documents and collections. The
  two merges compose.
- `planVocabularySave` / `planVocabularyDelete` are new, and Studio's
  vocabulary PATCH still does its own read-modify-write. Not switched in
  this PR — the retry loop from #186 guards a race the plan helper does
  not — but worth revisiting.

## `updated_at` had to be written, not just read

The brief asked only that Studio *read* the new field. Reading it would
have returned nothing forever: `applyStudioMetaOverrides` replaces MCP's
meta wholesale to keep Studio's autoPublish / updated_by semantics, and
`updateEntryStatus` builds meta by hand. MCP mints the stamp; both of
those threw it away.

Both stamp it now, one timestamp per call, so a bulk status change reads
as the single operation it was. Without this, T06's "sort by recently
edited" would have had no data for precisely the entries Studio users
create.

## The picker writes directly, not through chat

Every other action in that header goes through `sendChatPrompt`. This
one does not: an MCP write takes ~17-18s, and a radio button that stays
unconfirmed that long reads as broken. `[modelId].patch.ts` gains a
`titleField` branch, deliberately OUTSIDE the `forms.enabled` gate —
which field titles an entry is part of the model contract, not a forms
feature, and gating it would leave listings unreadable on every plan
without forms. Owner/admin is kept: it writes a model definition and
produces a git commit.

The chat path needs nothing; the agent already calls
`contentrain_model_save` with the full definition.
@ABB65
ABB65 merged commit 6b4ba0e into main Aug 13, 2026
2 checks passed
@ABB65
ABB65 deleted the feat/title-field-consumption branch August 13, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant