Architecture: engine, application and extensions

Craft Engine provides a framework for composing applications from extensions. A CMS can offer installation, activation and theme selection through an admin screen while using the framework's routing, ORM, authorization and tests. That CMS is an application built on the engine; it is not implicitly present in every project generated by craft new.

The three responsibilities

Engine: container, HTTP, ORM, security, events, views, extension lifecycle
    Application: configuration, identity, composition, shared contracts
        Modules: business domains
        Plugins: capabilities that extend behavior
        Themes: presentation
        Connectors: external integrations

The engine never imports application business logic. The application configures extension roots, identity models and service providers. Extensions contribute through the APIs the engine exposes. See extensions.md for the manifest and lifecycle commands.

All four modern extension kinds share ExtensionManager and the extensions state table. The older ModuleManager, PluginManager, modules/plugins tables and plugins/<slug>/plugin.py format remain compatibility paths. ModuleManager delegates route availability for recognized extension slugs to the modern manager. New extension code must use the modern lifecycle; toggling a legacy row is not how a declared extension is activated.

Generated code belongs to the application. make auth and make admin generate application code; they do not turn the engine into an installed CMS. The RBAC panel generated by make admin and the engine update screen are not an extension marketplace or a content editor.

Choosing a kind

Kind Owns Typical examples Technical boundary
Module A business domain: records, invariants, services and screens Content, catalog, orders, billing May contribute routes, providers, migrations, views, translations and assets
Plugin A capability used across domains or attached to explicit hooks Audit trail, pricing adjustments, document validation Same contribution API as modules; the distinction is responsibility
Theme How existing functionality is presented Brand layout, product page appearance Views, translations and assets only; no provider, routes or migrations
Connector Communication with an external system Payment transport, accounting sync, inbound webhooks Providers, services, migrations, translations and routes; no views or assets

Choose a module when it is the authority for a business concept. Choose a plugin when it adds a capability or changes behavior through a documented extension point. Plugins may own supporting records, such as an audit log; owning a table does not automatically make a capability a module.

Module and plugin are not different execution environments. Their contribution permissions are intentionally identical, so this responsibility distinction is a design contract, not something the manifest validator can infer.

A payment connector transports requests and responses. A billing module owns invoice and payment business rules. A pricing plugin adjusts calculations at an explicit filter. Keep these responsibilities separate even when they use the same external provider.

Data ownership and communication

Each domain module owns its tables, migrations and rules. Another extension requests behavior from its public service contract instead of importing its models or depending directly on its table layout. The boundary gate rejects cross-extension Python imports; it does not police arbitrary SQL access. Respecting data ownership therefore remains an application design obligation.

Need Mechanism Contract to document
Obtain an immediate answer Internal proxy Alias, exposed methods, arguments, return shape, domain errors
React to something that happened Events Event name/type, payload, timing and retry/idempotency expectations
Adjust a value Filters Filter name, input/output shape, priority and acceptable transformations

Contracts shared by several extensions can live in an application-owned package outside extension roots. They contain interfaces and payload types, not another extension's implementation. The engine stays independent of them.

Declare a mandatory extension dependency in extension.toml under [requires], including the supported version range. Activation needs those dependencies active. Deactivation is refused while active dependents remain. Optional plugins need a real hook with a useful default behavior when no plugin is present.

The proxy carries the caller's context. It does not authorize a business operation or select a tenant. The owning service must enforce authorization and tenant boundaries, including calls made by connectors, jobs or other modules. Proxy method exposure alone is not an access policy.

Example: an extensible store

  1. catalog owns products and exposes a price service through the proxy.
  2. orders owns orders and depends on a compatible active catalog.
  3. member_pricing attaches to a catalog price filter; the catalog works with its default price when the plugin is absent.
  4. The orders module publishes an event after its business operation succeeds.
  5. A payment connector handles external delivery with explicit error and retry behavior; the orders module still owns the order's state transitions.
  6. A theme overrides the views that present these results.

A theme consumes the data prepared for a view. It does not move pricing, authorization or persistence rules into templates. Active themes are available to the view engine; the application's view.theme setting or theme resolver selects which one a request uses. Activating a theme does not select it.

Lifecycle and updates

Discovery finds code on disk. Installation applies its schema and seeds missing translations. Activation registers runtime contributions. Deactivation withdraws owned service bindings, aliases, proxy exposures, listeners, filters and view layers. Existing routes remain known and are refused with MODULE_DISABLED; route registration is not an active capability by itself. Uninstallation changes state and preserves tables, records and translations.

context.app wraps the application container for provider registrations. Its bind, singleton, scoped, instance and alias operations are owned by the extension and undone even when activation fails. An existing service or alias cannot be overwritten. Other contributions must use the context's APIs or register their cleanup through context.on_unload.

Activation and deactivation of unchanged code can converge across workers without restarting. Updating Python code is a deployment operation with coordinated worker restarts; it is not hot reloading. See the update procedure.

The manager validates engine compatibility, applied versions and dependency versions during activation, boot and worker reconciliation. Installed versions are distinct from deployed manifest versions. A lifecycle state change must not claim that a new schema/version was applied.

What isolation guarantees

The runtime contains ordinary extension exceptions, tracks failures and uses a circuit breaker to refuse unhealthy routes and proxy calls. It does not provide a separate process, a security sandbox, transaction isolation between extensions or protection from an infinite loop or exhausted memory.

Extensions execute trusted application code in the same process. They can retain objects, start threads or access global services; the context cannot automatically undo arbitrary Python side effects. A retained service reference is not revoked on unload, and a request already running is not cancelled. Drain requests and background workers before applying an update.

The breaker is local to each worker. Persisted extension state is shared when the extension store's database is available. Before the schema exists, the store has an in-memory fallback; that fallback is not cross-worker coordination.

Building the CMS experience

A CMS application combines content/media modules, identity, permissions, an extension management module and a theme. Its admin screens invoke the same ExtensionManager operations as the CLI and show translated refusals. They must enforce authorization and CSRF for state-changing actions.

The engine currently discovers local extension directories. extension install does not download packages. A package catalog, acquisition/signature checks, content editing and site administration require application-level features and explicit deployment contracts. They are not implied by the existence of an extension loader.

This division lets a CMS provide a simple administration experience while other applications use the same engine without inheriting CMS-specific data models, screens or content assumptions.

Maintaining commercial applications with agents

Keep each product in its own repository, with the engine installed as a package from an approved, pinned release. Start with craft new, not a clone of the framework development repository. Product features belong to modules, plugins, themes, connectors and application composition. The engine release is a separate maintenance decision.

New generated projects include application-specific AGENTS.md instructions, tool symlinks to that single source, and tools/check_engine_changes.py. Before a feature task, record its starting commit. Before completion, run:

python tools/check_engine_changes.py --base <starting-commit>

The check compares protected engine, lock and governance paths against that baseline, including commits the agent made during the task, staged/unstaged changes and untracked files. A package-mode project also refuses a local engine copy, including one hidden by ignore rules. In CI, use the target branch's reviewed merge-base; resetting the comparison to the agent's latest commit would miss an already committed change.

An existing application can adopt these instructions and the guard from the project skeleton in a deliberate setup task. The guard needs the Git executable, a Git repository and a baseline commit containing craft-engine.lock; it fails closed when those are absent. It is a workflow gate, not an OS sandbox, and cannot inspect installed dependencies outside the repository or infer arbitrary monkey patches.

When a feature reveals a genuine missing engine contract, document a minimal reproduction and the proposed seam. A separate owner-authorized framework task implements and tests the generic capability, releases it, and then the application deliberately adopts that release. Agents continue independent product work instead of modifying the installed or vendored engine.

Framework adoption and governance changes use their own reviewed maintenance workflow. An ordinary feature task cannot bypass or modify this guard to authorize its own engine edit.