Runtime / marketplace extensibility — decision & marketplace design
Status: decision (amends nothing — it ratifies the stance D18/D4 already
took) plus a design for the "marketplace" future those decisions deferred.
Issue: #333.
Relies on: Kiln.Plugin (D18, docs/plugin-system-plan.md), dynamic
content types (D17, docs/dynamic-content-types-plan.md), Kiln.FieldType,
declarative editorial automation (#342).
TL;DR
Kiln does not hot-load arbitrary plugin code at runtime, by design. A plugin is compile-time OTP code activated by one config line (D18). The "marketplace" is a catalog of vetted, git-distributed, compile-time plugins — Mix dependencies on
kiln_plugin, or vendored intoprojects/when they need the core (#1909) — installers, not a code-execution sandbox — plus the parts of a live instance that are already extensible without code: dynamic content types (D17), theKiln.FieldTyperegistry, and declarative automation rules (#342). A genuine runtime-code story (out-of-process plugins, a WASM runtime) is a separate, dedicated, security-reviewed effort, deferred until a proven demand justifies it.
1. The decision: no runtime hot-loading of arbitrary code
The issue itself flags the instinct — "Hot-loading arbitrary OTP code is a big security/stability surface." That instinct is correct and load-bearing.
Why the BEAM makes this the wrong target
OTP genuinely supports hot code loading, so it is tempting to reach for it. The problem is not whether code can be loaded — it is what confines it once loaded:
- There is no in-process sandbox on the BEAM. Hot-loaded code runs in the
same node, in the same VM, with the same privileges as core. A loaded
module can call
System.cmd/2, read or:sys.get_state/1any process, open the database with full credentials, spawn unlimited processes, or crash the scheduler. There is no capability boundary — no seccomp, no namespace, no per-module permission — that a plugin could be confined to. Erlang's distribution security model assumes mutually trusting nodes; it is not an isolation primitive. - Untrusted third-party code therefore means full compromise. The moment arbitrary code from a marketplace runs in-node, every guarantee above it is void: an attacker's "star-rating widget" can exfiltrate secrets or tamper with published artifacts.
- It contradicts what Kiln sells. Kiln's differentiators — immutable published artifacts, supervision-tree reliability, content provenance (#4/#5) — all assume the node is trusted. Other CMS marketplaces (Strapi/Craft/Directus) run on Node/PHP VMs that also lack strong in-process isolation; they accept that risk because their trust story is weaker to begin with. Adopting their model would trade away exactly the property Kiln is built to offer.
Compile-time inclusion, by contrast, puts every line of a plugin through the
same review, mix kiln.plugins.doctor, CI gate, and release build as core
code. Trust is established before the code is in the node, which is the only
point at which the BEAM lets you establish it.
What a genuine runtime-code story would require
This is not an incremental feature flag away — each option below is a major architecture project with its own threat model, and all are explicitly deferred until proven demand justifies the investment:
- Out-of-process plugins. Run untrusted extension code as a separate OS
process (or container) behind an RPC/port boundary, with an explicit,
narrow protocol (think Language Server Protocol, or
erlang:open_port/2with a byte contract). The OS enforces the isolation the BEAM cannot. - Separate, restricted BEAM nodes. Give plugins their own node with a constrained distribution channel and no shared ETS/Mnesia; the core node brokers every capability. Distribution hardening and a capability broker are the hard parts.
- A WASM runtime (e.g. Wasmex). Compile plugin logic to WebAssembly and run it in a sandbox with an explicit host ABI — memory-isolated, syscall-free by default, capabilities granted one at a time. The most promising path for true third-party code, but it forces a narrow value-passing ABI (no sharing Ash structs/PIDs), a defined extension- point surface, and resource metering. A dedicated effort, not a bolt-on.
None of these is required for the marketplace below, which delivers the practical value of "extend my instance" without opening any of these fronts.
2. The marketplace: vetted compile-time plugins + data-driven runtime config
The realization behind this design: most of what a "live marketplace" delivers elsewhere, Kiln can offer as data or as vetted installers — with no runtime code loading at all.
2a. Catalog of vetted, distributable plugins
A "marketplace plugin" is an ordinary Kiln.Plugin (D18) kept in its own
git repository. It reaches a site one of two ways, and which one depends on
what it contributes:
| Shape | How it's installed | What it can contribute |
|---|---|---|
Mix dependency — Hex, git: or path: | Depends on kiln_plugin, the package the plugin contract ships in (#1909); the site lists it in the overlay's config/project_deps.exs (#1967) | Blocks, field types (no colocated client hooks), spam checks, advisory checks, image backends, nav items, supervision children, Oban queues; routes only to a LiveView that doesn't use KilnCMSWeb |
projects/<name>/ overlay | Vendored into the overlay and compiled inside the core | Everything above, plus content types (KilnCMS.CMS.Content), Ash domains, and LiveViews built on KilnCMSWeb |
A dependency compiles before the core and sees only its own dependencies, so
it can build on kiln_plugin but not on anything that lives in the core.
That is the whole split: a plugin that needs the core's repo, domains,
policies or web layer is an overlay.
- Add it — as a dependency: one entry in the overlay's
config/project_deps.exs(#1967), written as in amix.exsdeps list:{:kiln_ratings, "~> 1.0"}(Hex) or agit:/path:dep. The core's ownmix.exsandmix.locknever change: while that file exists, Mix locks intoconfig/project.lock, seeded frommix.lockso the core's dependencies stay at the release's versions. Both files are git-ignored in the core and committed in the overlay, likeconfig/project.exs, andmix kiln.updatere-seeds the lock when it moves the pin. An entry may not setoverride: true. As an overlay: copy,git subtreeorgit submodulethe plugin's repository intoprojects/<name>/in your overlay, pinned to a tag. - Register it — one line:
config :kiln_cms, :plugins, [Ratings.Plugin](plus its domains in:ash_domains/:content_domainsif it ships content types — Ash's own mix tasks read those keys directly). - Verify —
mix kiln.plugins.doctor(already gates precommit) asserts the contract holds: domains registered, no block/field-type/queue name collisions, well-formed routes, and, for a dependency, that itskiln_pluginrequirement accepts the version the core bundles. (The core takeskiln_pluginwithoverride: true, so Mix itself never checks it.) Withconfig/project_deps.exsit also fails a core dependency that moved inconfig/project.lock, notes a plugin installed there but not registered, and names each plugin's source. - Discover what's installed —
mix kiln.plugins.listprints every installed plugin with its catalog metadata and contribution surface.
The mix kiln.gen.plugin <Name> scaffold produces a distribution-shaped
skeleton in projects/<name>/; an overlay plugin is that directory, filled in
and published as its own git repository. A dependency plugin is an ordinary
Mix project depending on kiln_plugin: mix kiln.gen.plugin <Name> --dep
scaffolds one at ../<name> (or --path), and sdk/fixtures/dep_plugin is
a worked, CI-compiled example.
2b. Catalog metadata (the "registry")
The registry is cheap, declarative data carried by the plugin itself — no central service required for v1. Each plugin optionally declares:
| Field | Callback | Purpose |
|---|---|---|
| Name | name/0 | Machine name (already required) |
| Version | version/0 | Display/version pin (defaults to nil) |
| Summary | summary/0 | One-line catalog description |
| Homepage | homepage/0 | Repo / docs URL — where screenshots, changelog and docs live |
Kiln.Plugins.manifests/0 collects these plus each plugin's contribution
counts (domains, blocks, field types, nav items, admin routes, queues,
children) into plain maps — the data a catalog UI or mix kiln.plugins.list
renders. Screenshots and long-form docs deliberately live in the plugin's own
repository / README, not in the running node: the node carries a pointer
(homepage/0), not a media library.
The curated index — "which plugins are vetted" — is a governance artifact,
not a code feature: a docs table (or, later, a hosted static index) listing
approved plugins with their git coordinates (repository + tag) and homepage. Vetting is a
human review that a package is safe to compile in, exactly the bar core code
clears. This keeps the security property intact: nothing is "installed" until a
maintainer adds it to the build (a dependency line or a vendored projects/
directory) and re-releases.
A hosted index — that governance artifact, built out as a public catalog on
kilncms.dev with a submission path, a compile-and-doctor verification
pipeline and per-version compatibility data — is designed in
Plugin registry on kilncms.dev (#1447). It changes
nothing above: it distributes metadata and trust, never code, and is never in
the install path.
2c. What's already runtime-configurable without code
For the "extend a live instance without a rebuild" cases, Kiln already ships the safe, data-driven answers — this is the real moat, because it delivers the agility of a live marketplace while staying compile-time-safe:
Dynamic content types (D17). Admins define a new content type and its fields from the UI, backed by the generic
Entryresource — strings end-to-end, no runtime atoms or modules. This is the Directus/Strapi "create a collection live" capability without code loading.Custom field types (
Kiln.FieldType). Plugin-contributed field types are compile-time code, but admins select and configure them per field at runtime in the fields admin (/editor/fields). A type declarescast/2plus how it edits:input_type/0+input_attrs/1for a single control, orinput_parts/1for a composite value rendered as one labelled input per part and cast as a map. An optionaldescription/0is shown under the type picker once an admin selects the type, next to the core types' own descriptions.A type that needs behaviour in the browser (an address type-ahead, a map picker) declares
input_hook/1(#1918): the editor wraps its widget in an element carrying thatphx-hook. The hook is a LiveView colocated hook in the type's own module, so it is bundled into the host'sapp.jsat build time under a module-namespaced name — no runtime-loaded script, nothing new for the Content-Security-Policy. A hook that needs the server pushes"kiln:field_event"; the editor runs the type'shandle_input_event/3in a task and answers with a"kiln:field_reply"push event, so API keys stay server-side and a slow lookup never blocks the editor. Hooks act on their own field's inputs and dispatchinput; they do not see the form's changeset, andcast/2still validates what they fill in.mix kiln.plugins.doctorchecks every named hook was actually bundled.Two implementations ship in-tree and are written against exactly this contract rather than special-cased in the host, so they double as reference examples:
KilnCMS.CMS.FieldTypes.Geolocation(#428, composite) andKilnCMS.CMS.FieldTypes.Computed(#429, derived). Their names are reserved —mix kiln.plugins.doctorrejects a plugin that claims either, the same way it rejects one claiming a core type.Declarative editorial automation (#342). "When X, do Y" rules are Oban- backed data, authored in the admin UI — the safe substitute for the "run this script on publish" hooks that other marketplaces sell.
A plugin author reaches for compile-time code only for genuinely new behavior (a new block renderer, a new integration worker); everything schema- or rule-shaped is data an operator changes without a deploy.
3. Scope for this change
- Decision documented (this file) and cross-linked from
docs/plugin-system-plan.md§5. - Lightweight registry, implemented here:
- optional
version/0/summary/0/homepage/0metadata on theKiln.Plugincontract (defaults keep every existing plugin valid); Kiln.Plugins.manifests/0— the plain-data registry view;mix kiln.plugins.list— local discovery of installed plugins.
- optional
- Explicitly out of scope, by design: runtime loading of arbitrary plugin code; a hosted marketplace service (since designed as a catalog in Plugin registry on kilncms.dev, #1447); screenshot/media hosting in-node.
- Deferred to a future dedicated effort: any true third-party runtime-code sandbox (WASM / out-of-process), per §1.
Issue #333 stays open as the tracking issue for that future effort.