The overlay contract
What a projects/<name>/ subproject may rely on, what it may not, and what
happens when something it relies on has to change.
Two other documents already own neighbouring ground, and this one does not
repeat them: Downstream projects describes the
mechanics — how an overlay attaches, how it is activated and built — and
CHANGELOG.md defines what major, minor and patch mean
here. The question neither answers is the one a team has to answer before
pinning a CMS core to their product: which surfaces does the major-bump
promise actually cover? That is this document.
The promise in one sentence: a minor release must leave your projects/
tree compiling, booting, and reading its own data unchanged. If it cannot, the
release is a major, and mix kiln.update will refuse to move your pin
without --allow-major.
Every surface, labelled
Every surface of the core carries exactly one of three labels. The sections after this one argue the overlay contract in detail; this table is the summary, and the README carries the same table word for word.
| Label | What it means | Surfaces |
|---|---|---|
| Covered | A rename, a removal or a change of meaning needs a major release and goes through the deprecation path. Additions are not breaking | The overlay contract: the KilnCMS.CMS.Content options and the two *_pattern token vocabularies; the __kiln_*__/0 functions; the workflow action names on a content resource; the merge-argument convention; Kiln.Plugin callbacks; Kiln.Block and its DSL, the _type and _version attributes, Kiln.Block.Renderer and Kiln.Block.Info; Kiln.FieldType; Kiln.Advisory and Kiln.Forms.SpamCheck; Kiln.SafeFetch; Kiln.Plugins, Kiln.Version, Kiln.Updates and Kiln.Tokens; KilnCMS.Blocks, KilnCMS.Blocks.Upcaster and KilnCMS.CMS.ContentTypes; KilnCMS.Firing.refire/3 and refire!/3; KilnCMS.Migrations; KilnCMS.SchemaExport and KilnCMS.Branding; KilnCMSWeb.PluginRouter and KilnCMSWeb.AshJsonApiRouter; the config keys; the build conventions; the public-* CSS hook classes; the documented environment variables; mix kiln.update with its documented flags, mix kiln.plugins.doctor and mix kiln.search.check. Over HTTP, under the API guide's own, stricter policy: /api/json and /gql, reads and documented writes; /api/auth/sign_in; /api/content/:type/:slug and /api/content/:type/:id/revisions; /api/sync; /api/search (the request and the response, not the order); /api/resolve; /api/menus and /api/menus/:key; /api/ask; /api/locales; /api/schema; /api/provenance/verify/:type/:slug, /api/provenance/:type/:slug and /api/provenance/public-key (provenance is on by default since 1.7); /api/media, /api/media/import-url and /api/media/uploads[/complete]; /api/forms/:slug and embed.js; /mcp; bridge.js; /sitemap.xml, /news-sitemap.xml, /feed.xml and /feed.json; /preview/:token; image transform URLs, /media/:id/t/:ops; and webhook deliveries: event names, payloads, and the x-kilncms-webhook-signature scheme |
| Covered, off by default | Not a fourth label. These ship switched off and are supported when enabled: bugs in them are fixed like any other, and "off" is a default, not a stability rating. Each one's switch is a covered environment variable or config key, its console screens are internal like every other admin path, and its HTTP surface, where it has one, is covered | AI block assist; the SEO generator; content experiments; oEmbed cards; demo mode; the compliance gates; referrer analytics; SSO (OIDC) and two-factor authentication, both compile-gated; the per-site integrations for AI, search, storage, push, SSO and mail |
| Internal | May change in any release, including a patch, without a deprecation | Everything under KilnCMSWeb.* except the two routers above: LiveViews, components, the component kit's class names, and the /editor/... admin paths, including the per-site integration screens; AshAdmin at /admin; KilnCMS.* internals not named above (changes, calculations, workers, and anything @doc false); the internal write actions a content resource carries for its own bookkeeping; the console's side-* classes and admin design tokens; core Oban queue names; search ranking behaviour and its tuning defaults; the generated GraphQL schema module itself, as distinct from the schema it produces; seeds, fixtures, projects/example/ and test/support/; to_markdown/1 on a block module; a hand-rolled @behaviour when a callback is added; every other mix kiln.* task, and the code mix kiln.gen.content and mix kiln.gen.plugin write |
| Experimental | Incomplete, or resting on a dependency that is not stable yet. May change or be withdrawn in a minor | Semantic and hybrid search and the Meilisearch backend (both feature-flagged off, and the models need a KILN_ML=1 build); /api/content/:type/:slug/related, which is empty without semantic search; the Bumblebee cross-encoder reranker (rerank: false; its model path is not exercised in CI); the CRDT collaborative-editing prototype (:collab_prototype, dev and test only); GraphQL subscriptions over /ws/gql (on, but built on ash_graphql's subscription DSL, which is still beta) |
Covered surfaces
A rename, a removal, or a change of meaning in anything below is a breaking
change and forces a major bump. Additions to these surfaces are not breaking —
for a module that implements a behaviour through its use form. A module that
hand-rolls @behaviour instead is covered for the callbacks that exist, but a
minor may add one, and that is not a major (see What a minor may still do to
you).
| Surface | What is promised |
|---|---|
KilnCMS.CMS.Content options | :type, :plural, :table, :domain, :excerpt?, :schema_org_type, :slug_pattern, :alias_pattern, :seo_title_pattern, :seo_description_pattern, and since 1.1 :localization (field-level localization, #1327), keep their names, defaults and meaning. This is the covered subset, not the full option list: :dynamic? is core-internal and :published? is ignored, deprecated in 0.12 and removed at 1.0 (see Deprecated surfaces below) — neither is promised. Since 0.12 an option outside this list and those two warns at compile time, at your use line, instead of being silently ignored: a later minor may give that name a meaning. 2.0 makes it a compile error |
The two token vocabularies the *_pattern options accept | KilnCMS.Slug.Pattern validates :slug_pattern and :alias_pattern; KilnCMS.Seo.Pattern validates the two SEO patterns, and they accept different tokens. Both run at build time, so a withdrawn token breaks your compile |
The __kiln_*__/0 functions the macro injects | The seam between a resource and every core registry. The double underscores read private; they are contract |
| Workflow action names on a content resource | :read, :published, :public_by_slug, :trashed, :create, :update, :autosave, :submit_for_review, :return_to_draft, :publish, :unpublish, :archive, :unarchive, :restore, :destroy. Note :restore undoes trashing and :unarchive undoes :archive — they are not the same inverse |
| The merge-argument convention | tag_ids / add_tag_ids / remove_tag_ids, and related_<type>_ids with the same three forms; remove_* stays idempotent |
Kiln.Plugin callbacks | All fifteen, their arities and their return shapes, plus the nav_item and admin_route types |
Kiln.Block and its DSL | The block, field and migrate entities, their options, the declared field-type vocabulary, and the Kiln-to-Ash type mapping |
The _type and _version attributes | Injected on every block, and the discriminator a stored block map is resolved by |
Kiln.Block.Renderer | The :web, :json and :json_ld surfaces, and that an implementation returning nil for an unhandled surface is correct |
Kiln.Block.Info | Introspection over a block's definition, version, fields and migrations |
Kiln.FieldType | cast/2 stays required; the optional callbacks stay optional; parse_float/1 keeps Float.parse/1's return shape and stays total (it is how a cast/2 parses a number without inheriting Float.parse/1's toolchain-dependent raise) |
Kiln.Advisory, Kiln.Forms.SpamCheck | Their check/1 callbacks, outcome shapes, and registries |
Kiln.Plugins, Kiln.Version, Kiln.Updates, Kiln.Tokens | Their documented functions and return shapes |
Kiln.SafeFetch | Since KilnCMS 1.6 (kiln_plugin 1.3, #2047): get/2, head/2, post/3 and request/3, their documented options and {:ok, %{status, headers, body}} / {:error, reason} shapes, and the guarantees in its moduledoc — the address checked and pinned, redirects followed only on request and re-checked per hop, the body capped. The wording of reason is not covered. This is how a plugin fetches a URL it didn't write; KilnCMS.SafeFetch, the core implementation behind it, is not itself covered |
KilnCMS.Blocks, KilnCMS.Blocks.Upcaster, KilnCMS.CMS.ContentTypes | The registry and dispatch functions an overlay calls |
KilnCMS.Firing.refire/3 and refire!/3 | Re-firing named documents from an overlay (#2048): its arguments, the :overlay system actor it admits, its return value, and that each fire cascades to dependents and is announced as <type>.updated (webhooks and the CDN purge), as an edit of live content is. The job it queues and the queue it runs on are not promised |
KilnCMS.Migrations | The search-vector helpers every content type's hand-written migration calls |
KilnCMS.SchemaExport, KilnCMS.Branding | The exported schema shape, and the theme list a preset is selected from |
KilnCMSWeb.PluginRouter | The three route macros |
KilnCMSWeb.AshJsonApiRouter | That a domain registered in :content_domains is exposed on the JSON:API surface with no core edit |
| Config keys | :ash_domains, :content_domains, :plugins, :audiences, :mcp_tools, config/project.exs as the import point, and config/project_deps.exs as the dependency hook (a list of Mix dependency tuples, locked in config/project.lock; no override:) (#1967) |
| Build conventions | The PROJECT Docker build arg, and that an overlay's priv/repo/migrations and priv/resource_snapshots merge into the core's priv/ |
public-* CSS hook classes | Named in public theming; site stylesheets and presets both hang off them |
| Environment variables | The name and meaning of every variable in Environment variables. The promise is that a minor leaves your tree booting, and a deployment boots from these |
mix kiln.update, mix kiln.plugins.doctor, mix kiln.search.check | The task names and their documented flags: the checks Protecting your overlay in CI below tells your pipeline to run. Every other mix kiln.* task is operator or developer tooling, and is not |
Since #1909, Kiln.Plugin, Kiln.Block and its Kiln.Block.* modules,
Kiln.FieldType, Kiln.Tokens, Kiln.Forms.SpamCheck (with its Context),
Kiln.ImageBackend and, since kiln_plugin 1.3, Kiln.SafeFetch ship in the kiln_plugin
package, which the core takes as a path: dependency, so that a plugin can
depend on them without depending on the core. The names did not change and
the promises above apply unchanged; an overlay compiles exactly as before.
The package versions on its own (kiln_plugin-vX.Y.Z) under the same rule:
minors add, and a major comes only with a core major. Kiln.Plugins,
Kiln.Advisory, Kiln.Version, Kiln.Updates and the three registries stay
in the core.
Delivery URLs, GraphQL field names and JSON:API routes are derived from :type
and :plural, so they inherit the promise above — but the HTTP contract has
its own, stricter policy, including a versioned-prefix migration path. See
Versioning & stability in the API guide; where
the two documents disagree about an HTTP surface, that one wins.
Not covered
These may change in any release, including a patch. If your overlay reaches for one, it is reaching past the contract, and an upgrade may break it without that counting as a major.
Everything under
KilnCMSWeb.*exceptKilnCMSWeb.PluginRouterandKilnCMSWeb.AshJsonApiRouter— LiveViews, components, the component kit's class names, and the/editor/...admin paths themselves.KilnCMS.*internals not named above — changes, calculations, workers, firing internals, and anything marked@doc false.The console's own
side-*classes and admin design tokens. Those are the admin skin, and they are re-cut whenever the console is redesigned. Only thepublic-*classes on delivery pages are contract.Core Oban queue names, and the internal write actions a content resource carries for its own bookkeeping — the ones outside the workflow set named above, which exist to record an embedding, a search reindex, or a published version id. One caveat on the queues: the name space is shared even though individual names are not promised, so a core minor that adds a queue your plugin already declares turns
mix kiln.plugins.doctorred.Search ranking behaviour and its tuning defaults. New legs and re-tuned weights ship in minors; a query's result order is not a stable interface, even though the functions that run it are.
The generated GraphQL schema module itself, as distinct from the schema it produces.
Seeds, fixtures,
projects/example/andtest/support/. The example overlay is a worked reference that tracks the core; it is not an API. Nor is core test support:test/support/compiles only in the core's own:testenv, and nothing in it —KilnCMS.FixturePluginincluded — is a name your overlay may hold.One in-tree exception, which is not a pattern to copy.
projects/example/project.exsrestatesKilnCMS.FixturePluginin its:testplugin list. That is not the example depending on a fixture; it is the replace semantics of:plugins.config/test.exsregisters the fixture plugin,config/project.exsis imported last, and an Elixir config list is replaced rather than merged — so activating the example inside this repository without restating it would deregister the plugin the core's own suite is written against. The example lives in the core's repo and is compiled by the core's:testenv; your overlay is not, and has its own test suite. What does transfer is the mechanic::pluginsreplaces, exactly as:ash_domainsand:content_domainsdo, so every core entry you want kept has to be restated — and re-synced when you bump the pin.to_markdown/1on a block module.KilnCMS.Blocks.to_markdown/1calls it for thellmfired surface when a block module exports it, and falls back to the block'ssearch_text/1otherwise. It is probed withfunction_exported?/3, not declared onKiln.Block.Renderer, and nothing tests an overlay block's implementation of it. Your block may define it, and thellmsurface will use it today; a minor may rename or replace the probe.A hand-rolled
@behaviour, when a callback is added. Theuseform inherits a default for every callback, so an addition is invisible to it. A module that declares@behaviour Kiln.Plugin(or any behaviour above) itself gets a missing-callback warning instead, which--warnings-as-errorsturns into a failed build.Kiln.Plugindeclares no optional callbacks at all, andKiln.FieldTypemarks only its late additions optional. Hand-rolling is allowed (mix kiln.plugins.doctoraccepts it); it is not protected against additions.Every other
mix kiln.*task — the operator tasks (backfills, re-encryption, exports, imports, re-firing) and the developer ones — and the codemix kiln.gen.contentandmix kiln.gen.pluginwrite, which is a starting point you own once it is generated. A release that needs you to run a task names it, spelled as it is in that release, in its upgrade notes.
What a minor may still do to you
Three things a minor is allowed to do that can still cost you work. None of them breaks compilation, which is why none of them is a major.
It may add migrations, including to the tables your content types project.
The KilnCMS.CMS.Content macro contributes attributes to your resources, so
a core migration that adds a column adds it to every overlay table too — and
your tree carries its own migrations and snapshots, which the core's codegen
cannot see. This has taken production down twice: a seo_keywords column
(#452, fixed by #459) and a path_alias column (#488, fixed by #504), each
time as undefined_column on the first request to a content API. The core's
own overlay_drift CI job exists to catch exactly this against the in-tree
example. Run its equivalent in your repo — see below.
It may add a callback to a behaviour. With use Kiln.Plugin you inherit a
default for every callback, so an addition is invisible to you. A module that
hand-rolls @behaviour Kiln.Plugin instead gets a missing-callback warning,
which is an error under --warnings-as-errors. Use the use form; it is the
documented path, and it is what keeps additions non-breaking.
It may change what a new install defaults to. Defaults are not data
migrations: an existing deployment keeps its stored settings. Where a default
changes in a way an operator should know about, the release carries an
### Upgrading note, and mix kiln.update prints it before moving your pin.
When a covered surface must change
Additive first, always: a new option, a new optional callback, a new function beside the old one. Where that is impossible:
- Keep the old surface working for at least one minor, with the
replacement documented beside it and a
### Deprecatedentry naming both and the major that removes it. - Mark it deprecated where the compiler can carry the marker, so the
warning reaches you at build time rather than in a changelog you skimmed:
@deprecatedon a function, a compile-time warning at your ownuseline for a macro option. Where the compiler cannot see the surface — a route, a stored row, a job already in the queue — the core logs a warning each time it is used (KilnCMS.Deprecations), andmix kiln.deprecationslists the data that still depends on it. - Remove it only in a major, with an
### Upgrade notessection that names the mechanical edit — what to rename, to what, and in which files.
A name is never silently repurposed. Reusing an existing option or callback name for different behaviour is the one change that defeats every check here, because it compiles.
Deprecated surfaces
The policy above was first used in 0.12 (#1537, #1538), and 1.0 removed what 0.12 deprecated (#1543).
Removed at 1.0
| Removed | What happens now | Instead |
|---|---|---|
published?: on use KilnCMS.CMS.Content | the generic unknown-option warning at your use line (a compile error from 2.0) | remove it; every content type has the :published read |
/editor/pages/:id and /editor/posts/:id as editor routes | a 301 to the new route, for old bookmarks and mail. The redirect is a courtesy, not a covered surface | /editor/content/page/:id and /editor/content/post/:id |
The User.audiences fallback for an account with no organization membership | such an account reads no gated content until it holds a membership. After every deploy a background job gives it one on the default organization, carrying its audiences (KilnCMS.Accounts.LegacyAudiencesWorker) | an OrgMembership carrying the audiences. Run mix kiln.deprecations --migrate-audiences on 0.12 before upgrading, so there is no moment without access |
Webhook and newsletter jobs enqueued without org_id, and the pre-ledger webhook job shape | cancelled when they run, with an error in the log; their work is not done | nothing to change in code; let the queue drain on 0.12 before upgrading. mix kiln.deprecations counts what is left |
User.audiences itself is kept: no access decision reads it, but it is the
only record of what a legacy account held, and billing still writes the
cross-organization union there. 2.0 may drop the column.
Still deprecated
| Deprecated | How you are told | Instead |
|---|---|---|
The legacy block bridge — removed at 1.0: TypedBlocks.to_legacy/1, from_legacy/1, KilnCMS.CMS.Block and the legacy type/content/data write shape, which a write now refuses with a cast error. Stored rows in that shape are still read. RichText.legacy_html is kept, deprecated, as the fallback for HTML Portable Text cannot hold faithfully | a compile error for the functions and the module; a cast error for the write shape; deprecated in the block JSON Schema | to_typed/1 in place of from_legacy/1; render from typed blocks; write the typed shape (%{"_type" => "heading", "text" => …}) |
mix kiln.deprecations (in a release,
bin/kiln_cms eval 'KilnCMS.Release.deprecations()') exits non-zero while
this instance still holds data that only a removed surface read — accounts on
the audiences fallback, or jobs in a pre-0.12 shape — so it can gate an
upgrade to 1.0.
Protecting your overlay in CI
Four checks, but they do not all run in the same place — two need more than a source tree, which is the detail that makes people skip them.
On every build, with your config/project.exs in place and your overlay's
priv/ merged, exactly as the image build does it:
mix ash.codegen --check # the core's overlay_drift job, run against you
mix kiln.plugins.doctor # domains registered, no name collisionsWithout the activation step the first one checks the core alone: it passes while your tables drift, which is the failure it exists to catch.
Against a migrated database — post-deploy, or in a job with Postgres, as it inspects the live schema rather than your source:
mix kiln.search.check # every content type has its search-vector migrationFrom inside your pinned Kiln checkout, not your overlay repo — the task
refuses to run anywhere else, and it fetches from the network unless you pass
--no-fetch:
mix kiln.update --check --exit-codePin to tags rather than a branch. mix kiln.update --ref main deliberately
skips both the version comparison and the upgrade notes, so tracking main
means opting out of every guarantee on this page.
Known soft spots
Stated rather than discovered later.
- The block upcast path has run once, on one step.
headingis at version 2, andmix kiln.blocks.backfill(#1537) rewrites stored blocks through the samemigratechain the lazy read uses — tested against a corpus of every stored shape (test/support/legacy_block_corpus.ex), not yet against a large production archive. A missingmigratestep is a compile warning (Kiln.Block.MigrationChain; an error from Kiln 2.0), and a stored block behind the gap is refused — left as stored,_versionincluded — rather than stamped current;KilnCMS.Blocks.Upcaster.try_upcast/2says why. If your overlay versions a block, add the stored shapes it replaces to a corpus like that one, and run the backfill with--dry-runfirst. - Upcasting is lazy until you run the backfill. Stored blocks behind the
head version are upcast on every read, and rewritten on disk only by
mix kiln.blocks.backfill, which you run — nothing schedules it. Either way, already-fired artifacts need re-firing (mix kiln.refire_all) after a block's shape changes. - Rows written before the typed block storage are converted on read until
the backfill has run. The tolerant read stays after 1.0 — for the rows the
backfill reports rather than rewrites, and for version history, which is
hash-chained and keeps being read in whatever shape it was written. A block
of a type this build no longer has, or an element that is not a block, reads
as a
customblock carrying its payload, so delivery never fails on it. Only the legacy write shape was removed at 1.0 (#1543). to_markdown/1on a block module is probed informally, not declared on the renderer behaviour. Treat it as unstable until it is.- The hand-rolled
@behaviourpath is fragile against additions, on every behaviour here.Kiln.Plugindeclares no optional callbacks at all, andKiln.FieldTypemarks only its three late additions optional — the rest are required-but-defaulted, exactly likeKiln.Plugin's. So a module that declares@behaviourinstead of using theuseform gets missing-callback warnings when a callback is added, which--warnings-as-errorsturns into a failed build. Theuseform is what makes additions safe; prefer it.
Status of this document
From 1.0.0 this page is the definition of what a major bump means — and the reason a major is rare rather than routine. Removing a covered surface, or changing what one means, takes a major release and goes through the deprecation path above; adding one does not.