Skip to content

remill

One definition, six surfaces

Slugone-definition-six-surfaces
ExcerptA single collection definition generates six surfaces at once — storage, validation, two admin views, the REST API, and the MCP tools. How remill's schema engine works, and why field types are code but collections are data.

If there's a single sentence that explains remill, it's this: one collection definition generates six surfaces. Everything else in the codebase is supporting infrastructure for that one idea. So it's worth slowing down and looking at exactly what the sentence means, because the whole design follows from taking it seriously.

The six surfaces

When you define a collection in remill — say, the articles collection holding this post — you write one description of its fields. From that single description, six things are generated:

  1. Storage. How each field is persisted and indexed for querying.
  2. Validation. A single validator that guards every write, from every door.
  3. The admin list view. The columns and cells you see in a table of records.
  4. The admin edit form. The widget you get for each field when editing.
  5. The REST API. The JSON shape and its OpenAPI description.
  6. The MCP tools. The functions an AI agent sees for reading and writing that content.

Blogmill, the 2018 ancestor of this project, generated the first four. remill's contribution is to extend the same trick to the two surfaces that matter in a world of API clients and agents: REST and MCP. (There's arguably a seventh consumer now — the public page renderer — but it reads from the same definition rather than adding a parallel one, which is exactly the point.)

The rule that keeps this honest is short: a change that serves one surface but breaks generation for another is wrong. And its corollary: if a feature can't be generated from the collection definition, question whether it belongs in the platform at all. That sounds austere, but it's what stops the six surfaces from quietly drifting into six half-compatible codebases.

Field types are code; collections are data

Here's the distinction that makes the whole thing work, and it took me a while to get it exactly right.

A field typetext, markdown, media, relation, slug, and about a dozen others — is code. It lives in a module, it ships real logic: how to validate a value, how to index it, what widget to render, what JSON schema to advertise. Adding a new field type is a code change and a deploy, because a field type is genuinely new behaviour.

A collectionarticles, or whatever you model next — is data. It's a row in a table, holding a list of field descriptors that compose those code-backed types. Adding or changing a collection is a runtime operation. No deploy. No migration.

This split is the hinge everything turns on. If collections were code, you'd redeploy every time you added a content type — the Payload and Directus and old-Blogmill way. If field types were data too, there'd be nowhere for the validation and UI logic to actually live. Splitting them — types as code, collections as data — is what makes it possible for an agent to define a content type over MCP and have every surface light up at once. It's the difference between a platform that's agent-native and one that just has an agent bolted on.

I dogfood this hard. remill's own system needs — its settings singleton, its media metadata — are themselves collections riding the same engine. The reasoning is deliberately unforgiving: if the engine can't express its own configuration, the engine isn't good enough yet. Fix the engine; don't special-case around it.

The contract every field type signs

Each field type implements one interface — the most important interface in the codebase. You can read the six surfaces straight off its members:

  • valueSchema is the single source of validation truth. It generates surface 2, and because every door uses it, there is exactly one definition of what a valid value is.
  • toIndex says how a value is stored and made queryable — surface 1.
  • CellComponent renders the list cell (surface 3); EditComponent renders the edit widget (surface 4).
  • jsonSchema describes the field to machines, which drives both the REST API and the MCP tools (surfaces 5 and 6).
  • toSearchText feeds full-text search, so finding things works without anyone wiring up search per collection.

The discipline is that a field type must implement every non-optional member — no partial types — and that adding one shouldn't require editing anything else. If adding a field type forces a change somewhere else in the system, that's a leak in the registry, and the fix is to seal the registry, not to sprinkle special cases through the rest of the code. Generation only stays cheap if the abstraction stays clean.

One save pipeline, and it rejects surprises

All six surfaces converge on a single write path, and this is where the security story and the schema story turn out to be the same story.

When anything writes a document — the admin form, a REST client, an agent over MCP — the pipeline loads the collection definition, builds a validator from the field types, and validates the incoming payload against only the declared fields. It's a strict whitelist. An undeclared field isn't quietly dropped; it's rejected, loudly. I chose reject-over-strip on purpose: a silent strip hides a mistake, while a rejection teaches the caller — especially an agent — what the schema actually is. Then a beforeSave hook runs, the authorization check runs, and finally the write happens: the document data, the query index, and a new revision, all committed atomically.

This is the direct, structural fix for the mass-assignment hole that Blogmill had wide open. There's no path that writes a field you didn't declare, because the only path that writes anything at all is this one, and it won't. You don't have to remember to be safe. Safety is the only thing the code knows how to do.

Why I keep coming back to this

The reason I find the six-surfaces framing worth defending is that it collapses a category of work that normally never ends. In most systems, adding a field means touching the migration, the API serializer, the admin form, the list view, the validation, and — if you're being modern about it — the tool schema your AI integration exposes. Six places, six chances to disagree, and they slowly do.

In remill it's one place. You describe the field once, and the storage, the validation, the two admin views, the REST endpoint, and the agent tools are all derived from that description and cannot fall out of sync, because they're not independent things — they're six views of one fact.

I defined the collection you're reading this in with a single call over MCP. The edit form, the JSON API, and the agent's own create_articles and publish_articles tools didn't need to be built afterward. They already existed the instant the definition did. That's not a convenience feature. That's the product.