Skip to main content

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.

LabelWhat it meansSurfaces
CoveredA rename, a removal or a change of meaning needs a major release and goes through the deprecation path. Additions are not breakingThe 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 defaultNot 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 coveredAI 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
InternalMay change in any release, including a patch, without a deprecationEverything 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
ExperimentalIncomplete, or resting on a dependency that is not stable yet. May change or be withdrawn in a minorSemantic 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).

SurfaceWhat 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 acceptKilnCMS.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 injectsThe 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 conventiontag_ids / add_tag_ids / remove_tag_ids, and related_<type>_ids with the same three forms; remove_* stays idempotent
Kiln.Plugin callbacksAll fifteen, their arities and their return shapes, plus the nav_item and admin_route types
Kiln.Block and its DSLThe block, field and migrate entities, their options, the declared field-type vocabulary, and the Kiln-to-Ash type mapping
The _type and _version attributesInjected on every block, and the discriminator a stored block map is resolved by
Kiln.Block.RendererThe :web, :json and :json_ld surfaces, and that an implementation returning nil for an unhandled surface is correct
Kiln.Block.InfoIntrospection over a block's definition, version, fields and migrations
Kiln.FieldTypecast/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.SpamCheckTheir check/1 callbacks, outcome shapes, and registries
Kiln.Plugins, Kiln.Version, Kiln.Updates, Kiln.TokensTheir documented functions and return shapes
Kiln.SafeFetchSince 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.ContentTypesThe registry and dispatch functions an overlay calls
KilnCMS.Firing.refire/3 and refire!/3Re-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.MigrationsThe search-vector helpers every content type's hand-written migration calls
KilnCMS.SchemaExport, KilnCMS.BrandingThe exported schema shape, and the theme list a preset is selected from
KilnCMSWeb.PluginRouterThe three route macros
KilnCMSWeb.AshJsonApiRouterThat 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 conventionsThe 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 classesNamed in public theming; site stylesheets and presets both hang off them
Environment variablesThe 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.checkThe 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.* except KilnCMSWeb.PluginRouter and KilnCMSWeb.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 the public-* 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.doctor red.

  • 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/ and test/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 :test env, and nothing in it — KilnCMS.FixturePlugin included — is a name your overlay may hold.

    One in-tree exception, which is not a pattern to copy. projects/example/project.exs restates KilnCMS.FixturePlugin in its :test plugin list. That is not the example depending on a fixture; it is the replace semantics of :plugins. config/test.exs registers the fixture plugin, config/project.exs is 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 :test env; your overlay is not, and has its own test suite. What does transfer is the mechanic: :plugins replaces, exactly as :ash_domains and :content_domains do, so every core entry you want kept has to be restated — and re-synced when you bump the pin.

  • to_markdown/1 on a block module. KilnCMS.Blocks.to_markdown/1 calls it for the llm fired surface when a block module exports it, and falls back to the block's search_text/1 otherwise. It is probed with function_exported?/3, not declared on Kiln.Block.Renderer, and nothing tests an overlay block's implementation of it. Your block may define it, and the llm surface will use it today; a minor may rename or replace the probe.

  • A hand-rolled @behaviour, when a callback is added. The use form 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-errors turns into a failed build. Kiln.Plugin declares no optional callbacks at all, and Kiln.FieldType marks only its late additions optional. Hand-rolling is allowed (mix kiln.plugins.doctor accepts 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 code mix kiln.gen.content and mix kiln.gen.plugin write, 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:

  1. Keep the old surface working for at least one minor, with the replacement documented beside it and a ### Deprecated entry naming both and the major that removes it.
  2. 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: @deprecated on a function, a compile-time warning at your own use line 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), and mix kiln.deprecations lists the data that still depends on it.
  3. Remove it only in a major, with an ### Upgrade notes section 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

RemovedWhat happens nowInstead
published?: on use KilnCMS.CMS.Contentthe 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 routesa 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 membershipsuch 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 shapecancelled when they run, with an error in the log; their work is not donenothing 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

DeprecatedHow you are toldInstead
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 faithfullya compile error for the functions and the module; a cast error for the write shape; deprecated in the block JSON Schemato_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 collisions

Without 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 migration

From 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-code

Pin 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. heading is at version 2, and mix kiln.blocks.backfill (#1537) rewrites stored blocks through the same migrate chain 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 missing migrate step 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, _version included — rather than stamped current; KilnCMS.Blocks.Upcaster.try_upcast/2 says 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-run first.
  • 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 custom block carrying its payload, so delivery never fails on it. Only the legacy write shape was removed at 1.0 (#1543).
  • to_markdown/1 on a block module is probed informally, not declared on the renderer behaviour. Treat it as unstable until it is.
  • The hand-rolled @behaviour path is fragile against additions, on every behaviour here. Kiln.Plugin declares no optional callbacks at all, and Kiln.FieldType marks only its three late additions optional — the rest are required-but-defaulted, exactly like Kiln.Plugin's. So a module that declares @behaviour instead of using the use form gets missing-callback warnings when a callback is added, which --warnings-as-errors turns into a failed build. The use form 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.