Drupal AI Best Practices: The 2026 Way¶
A contrib-first reference for Drupal CMS 2.0 — AI Core, Tool API, Tool Belt, MCP, ECA, FlowDrop, Modeler API, Orchestration, AI Context, and Canvas.
1. What changed, and why this matters¶
Drupal in 2026 is an AI orchestration layer, a structured content graph, and an agent execution runtime. It is no longer "a CMS that can also call OpenAI." Treating it as the latter is the source of every architectural problem teams encounter today.
The single most important rule: Contrib-first, every time. Before any custom PHP class, controller, queue worker, or service is written, ask: What Drupal core or contrib module already owns this concern? If the answer is anything other than "nothing," delete or do not write the custom class.
Layer ownership is absolute. Every capability you build maps to exactly one layer, and the layer above it consumes it through a typed contract. Stop thinking in modules; start thinking in layers. NO custom PHP handlers anymore.
2. The Six Buckets¶
Every module in the ecosystem falls into exactly one bucket:
- Framework (
drupal/ai): Provider abstraction, operation types, normalization. - Execution (
drupal/ai_agents): Agent orchestration, tool-calling loop, task decomposition. - Integration (Provider modules): Connect external LLM, embedding, and vector DB services.
- Protocol (
drupal/mcp_server+drupal/mcp_client): External tool and resource access via MCP. - Workflow (
drupal/eca+drupal/flowdrop): Event-driven logic, automation, approval flows, pipelines. - Orchestration (
drupal/orchestration): Exposes Drupal capabilities to Activepieces, n8n, Zapier, Make. NOT a local workflow engine.
Everything else (Tool API, Tool Belt, Recipes, Canvas) are extensions that plug into these buckets.
3. Enforcement Rules (A–J)¶
These rules are absolute and enforceable in CI.
- Rule A (AI Core owns provider abstraction): All LLM and embedding calls go through the
ai.providerservice: injected in classes (constructor DI),\Drupal::service('ai.provider')only in procedural code where injection is impossible. No direct SDK usage. - Rule B (Agents own execution): Agent orchestration uses
drupal/ai_agents. No custom ReAct loops. - Rule C (Tools own the execution contract): Every executable capability is a
@Toolplugin with typed JSON Schema input/output. - Rule D (MCP owns the protocol): External tool access uses MCP Server (outbound) or Client (inbound). No custom REST endpoints for tools.
- Rule E (ECA owns workflow): Event-driven automation uses ECA models. No custom event subscribers for workflow logic.
- Rule F (Registry is metadata only): OSSA manifests and agent cards are config entities describing capabilities, not implementing them.
- Rule G (MCP gateway is orchestration only): Routes and authenticates. No business logic in the protocol layer.
- Rule H (Namespace is mandatory): Every custom module uses the
bluefly_prefix. No generic names. - Rule I (Recipes over modules): Express capabilities as config + recipe + ECA. Modules are for new plugin/entity types only.
- Rule J (Delete duplicates): If custom code duplicates a contrib capability, delete the custom code and adopt contrib.
4. The Drupal AI Landscape (334+ Modules & Growing)¶
Canonical Catalog & Registry: See
Engineering-Standard/catalog/drupal-ai-ecosystem.mdfor the complete indexed list of 334+ Drupal AI ecosystem modules, classified by capability and tagged with Bluefly approval dispositions (APPROVED_OPERATING,APPROVED_AVAILABLE,IN_EVALUATION,UPSTREAM_COMMUNITY).
The modern AI stack is divided into distinct categories:
- Core Frameworks:
Drupal AI(foundation),ECA(workflows),Tool API/Tool Belt,Modeler API(visual design),Model Context Protocol (MCP)(context bridging). - Major Cloud Providers:
OpenAI Provider,Anthropic Provider,Gemini Provider,Azure AI Services,AWS Bedrock Provider,Google Vertex Provider. - Privacy & Open Source (Local):
Ollama Provider,LMStudio Provider,Mistral Provider,HuggingFace Provider,vLLM Provider,MLX Provider. - Speed & Specialized Inference:
Groq Provider,DeepSeek Provider,Fireworks AI,Cloudflare Workers AI Provider. - Vector DB Storage:
Qdrant VDB Provider,Postgres VDB Provider (pgvector),Milvus VDB Provider,OpenSearch VDB Provider,Elasticsearch VDB Provider,SQLite VDB Provider. - Field Actions & In-Form AI:
Field Widget Actions+ECA Field Widget Actions(declarative UI buttons on entity forms). - Structured Data & AEO:
AI Schema.org JSON-LD(native JSON-LD generation for AI discoverability). - Developer Tools & Observability:
AI AutoEvals(scoring),AI Usage(costs/limits),AI Drush Agents,Langfuse. - Aggregators:
LiteLLM AI Provider,OpenRouter Provider,amazee.ai AI Provider.
5. Strategy: Drupal AI Contrib Ownership Playbook (2026)¶
EXECUTIVE MANDATE: Stop custom module building. The Drupal AI ecosystem is the default. Do not replace existing Drupal AI primitives with custom abstractions. All development must prioritize established contrib surfaces to ensure security and maintainability.
P0 PREREQUISITE: Bootstrap Recovery¶
NOT_ESTABLISHED (2026-08-24): this section previously claimed Drupal bootstrap
was blocked by stale contextcontrol_amcs references in active configuration.
No source repository, environment, or bead reference for that claim could be
found (Forge, 2026-08-24: zero matches across all repos touched for the
site_template_amcs conformance work; not actionable without a pointer to the
specific Drupal instance). Per Founder Decisions
(Engineering-Standard/glossary/platform-glossary.md): names do not create
ownership, and any surviving ContextControl/Drupal integration should be
named as an adapter (e.g. contextcontrol_drupal), not as if AMCS owns the
ContextControl engine. If this bootstrap-blocking condition is real and
current, file it as a bead with the specific environment before restoring
this as a binding prerequisite.
Audit Workflow & Ownership Matrix¶
| Category | Audit Action | Decision Rule |
|---|---|---|
| Filesystem | Inventory web/modules/contrib |
Delete duplicates |
| Composer | Gap check against composer.json |
Add missing w/ --no-update |
| Submodules | Check for local git submodules | Convert to Composer |
| Provider | Audit enabled AI providers | Prefer Anthropic/OpenAI |
| VDB | Verify Vector Database connections | Postgres/pgvector default |
| Governance | Review usage limits/costs | Mandatory for production |
Final Output: Audit Receipt¶
The following JSON format must be generated upon completion of any playbook execution:
{"document": "DRUPAL_AI_CONTRIB_OWNERSHIP_AUDIT_RECEIPT_V2","timestamp": "2026-06-04","auditor": "Thomas Scola","constraints_checked": true,"no_new_modules": true,"no_enabling_actions": true}
Playbook Execution Rules (HARD CONSTRAINTS)¶
- Forbidden: Enabling modules during audit, running database updates, full composer update (without explicit approval), config export bulk cleanup, creating new modules/dashboards/provider layers, or using Orchestration/MCP to replace local engines/AG-UI.
- Allowed: Filesystem inventory,
composer show/why,composer require --no-update, read-onlydrush status(if bootstrap works), and local module grep.
Order of operations before building anything: see §6 — one ladder, not two.
6. Assembly order — the build sequence¶
Custom code is an operational liability, not neutral work product. When building any new capability, walk this eleven-layer ladder in order. Stop at the first layer that solves the problem.
- Core — does Drupal core (Entity API, Field API, Cache, Config, etc.) already do this?
- Stable Contrib — does an existing stable contrib module do this? Install and configure it.
- Recipe / config action — can this be expressed purely as configuration, or applied via a Recipe?
- Canvas / SDC — can this be built as a Single Directory Component or visual Canvas Code Component?
- ECA / FlowDrop — can this be wired with event-condition-action models or a FlowDrop node processor?
- Tool API (
#[Tool]) — CANONICAL CAPABILITY CONTRACT. Define the capability once as a typed#[Tool]plugin. Why here? Because Tool API defines capabilities; AI Agents, MCP, ECA, and Orchestration consume them. - Drupal AI / AI Agents — does an AI Agent or LLM prompt orchestrator need to invoke the capability? Wire the
#[Tool]plugin intodrupal/ai_agents. - Orchestration — does an external workflow platform (n8n, Make, Zapier, Activepieces) need access? Expose via
drupal/orchestration. - MCP Adapter — does an external AI agent or client need protocol access? Expose via
drupal/mcp_server_tool_bridge. MCP is a protocol adapter, not the capability authority. - Bluefly / Gas City Adapter — does the Gas City factory loop need to trigger or observe it? Expose via the Bluefly Gas City execution bridge (
blu-cli/ orders / events). - Custom PHP — last resort, only after a proven gap at every layer above.
Reaching step 11 requires, before it ships — not after — an audit receipt proving all 10 upper layers were evaluated and demonstrated an unresolvable capability gap.
CUSTOM_CODE_REQUIRED=<yes/no>
PROVEN_GAP=<what specifically steps 1-10 cannot do, with evidence, not assertion>
ADR=<link to the Architectural Decision Record>
TESTS=<test coverage present: yes/no + link>
MAINTAINER=<name or role — never "the team">
DELETION_TRIGGER=<specific future condition that makes this custom code removable>
7. Tool API: The Canonical Capability Contract¶
The Drupal ecosystem establishes the universal architectural boundary: Tool API (drupal/tool) is the capability authority. Protocols and orchestrators (MCP Server, mcp_server_tool_bridge, AI Agents, ECA, Drush, and Gas City adapters) are consumers/adapters.
7.1. The New Default Rule¶
When implementing any executable Drupal capability:
FIRST ask whether it should be a #[Tool] plugin.
Do NOT start with:
- Custom Controller
- Custom REST endpoint
- Custom MCP server capability
- Bespoke AI Agent action
- Shell/Drush-only script
unless Tool API demonstrably cannot express the requirement.
7.2. The Tool API Contract¶
Every reusable capability declares:
#[Tool(
id: 'my_module_operation',
label: new TranslatableMarkup('Human readable label'),
description: new TranslatableMarkup('Clear description of intent, mutation semantics, and draft behavior.'),
operation: ToolOperation::Write, // or ToolOperation::Read
destructive: FALSE, // Set TRUE only if prompt confirmation is required
input_definitions: [ ... ], // Typed JSON-Schema compliant input definitions
output_definitions: [ ... ], // Declared typed return outputs
)]
checkAccess() must delegate to native Drupal authority:
- \Drupal\Core\Entity\EntityInterface::access()
- Granular permissions
- Content moderation / editorial workflows
- Field-level validation constraints
Never bypass Drupal access checks inside tools.
7.3. Protocols Are Adapters, Not Capability Owners¶
- MCP Server (
drupal/mcp_server): Thin protocol runtime (PHP SDK bridge). drupal/mcp_server_tool_bridge: Adapts Tool API plugins into MCP tools.- ECA (
drupal/eca): Invokes Tool API plugins via ECA tool actions. - AI Agents (
drupal/ai_agents): Invokes Tool API plugins during multi-step reasoning loops. - Drush CLI:
drush tool:run <id> --input='...'anddrush tool:info <id> --format=json. - Gas City Adapter: External execution bridge. Rule: Do not put domain capability logic inside protocol adapters. Define the capability once; call it from anywhere.
7.4. Bluefly Gas City Execution Seam¶
The canonical execution loop flows cleanly through Tool API:
Drupal / ECA / UI
→ Tool API capability
→ orchestration / adapter
→ blu-cli
→ Gas City Order (`gc order run`)
→ Formula / Bead
→ agent execution
→ GitLab delivery
→ native Gas City Event
→ Tool API / orchestration receipt
→ ContextControl / Drupal projection
7.5. Draft-First Mutation Policy (Canvas & Content Operations)¶
When agents or tools mutate entities or layouts:
- Use drupal/canvas_tools before writing custom Canvas mutation code.
- Draft-First Rule: AI and agent mutations must write to draft, autosave, workspace, or unpublished revisions.
- Atomic Publish: "Published" state is realized explicitly (e.g. canvas_publish_auto_saves).
- Never bypass editorial workflow with direct production saves unless that behavior is explicitly required and governed.
7.6. ContextControl Governed Tool Surface¶
ContextControl operations must be evaluated and implemented as Tool API plugins rather than custom controller actions:
- context_resolve
- context_version_get
- context_approve
- context_retire
- context_attach_to_run
- context_record_evidence
- factory_run_receipt
- factory_run_replay
7.7. Module Audit Matrix for Existing Bluefly Code¶
For every module (blu_fleet, code_executor, ottermon, source_connect, ai_agents_ossa, ContextControl):
IS_THIS_A_CAPABILITY=YES|NO
TOOL_API_PLUGIN_EXISTS=YES|NO
CUSTOM_CONTROLLER_EXISTS=YES|NO
CUSTOM_MCP_IMPLEMENTATION_EXISTS=YES|NO
ECA_ACTION_DUPLICATES_TOOL=YES|NO
7.8. Version & Ecosystem Reality¶
- Tool API Target: Compatible 1.0.x beta line (
1.0.0-beta8as of September 2026). Do not lock documentation to older development checkpoints. - Tool Belt: Contrib-first reference, but pre-stable (
1.0.0-alpha5). Do not assume API immutability. - Introspection Gap (#3582943): Upstream
tool:infoflattens inputs to data types and does not yet surface enum values orcheckRequirements()failures. Do not invent a parallel schema system; contribute upstream where generic.
8. Anti-patterns — recognize on sight¶
| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
| Custom entity type for "plans"/"tasks" | ai_context_item bundles exist for exactly this |
Create a bundle + fields |
| Custom event subscriber for content workflow | ECA owns workflow logic | Create an ECA model |
| Custom REST endpoint for tool access | MCP owns the protocol | mcp_server + Tool API |
| Custom agent framework / ReAct loop | ai_agents owns execution |
@AiAgent plugin or OSSA manifest import |
| Direct OpenAI/Anthropic SDK calls | AI Core owns provider abstraction | The injected ai.provider service |
| Custom module that only ships config | Recipes exist | Write a recipe |
| New top-level module for two plugins | Submodules exist | Add to the parent module |
| Custom workflow engine | FlowDrop + ECA exist | FlowDrop for pipelines, ECA for events |
| "Smart" registry with business logic | Registries are metadata only | Move logic to tools/agents/ECA |
| Hand-rolled HTTP client for an external API | api_normalization imports OpenAPI specs and generates Tool plugins |
Import the OpenAPI spec |
| Runtime PHP file generation | Plugin discovery + derivers exist | Use derivers |
Custom route + Controller + libraries.yml mount for an embedded admin app |
Canvas owns embedded apps: a *.canvas_extension.yml with type: page gets /canvas/app/{id}, a side-menu link, deeper hash routing and permission gating |
Declare a Canvas extension; talk to the editor with @drupal-canvas/extensions |
Hand-written canvas-mount.js / bespoke build for Canvas code components |
Upstream ships the whole toolchain from git.drupalcode.org/project/canvas |
@drupal-canvas/create, @drupal-canvas/cli, @drupal-canvas/vite-plugin, @drupal-canvas/workbench, @drupal-canvas/eslint-config |
| Custom Controller rendering a marketing or demo page | Canvas composes pages; entities own the content | Canvas composition + SDC + entity data |
9. Quick decision tree¶
| Need to... | Do this |
|---|---|
| Call an LLM? | drupal/ai provider service. Never a direct SDK call. |
| An agent to do multi-step work? | drupal/ai_agents. Check OSSA templates first. |
| A reusable executable capability? | #[Tool] plugin (drupal/tool). Canonical capability authority. |
| Common Drupal operations as tools? | Enable tool_belt submodules. No custom CRUD plugins. |
| Agentic Canvas building/mutation? | drupal/canvas_tools. Writes to auto-save drafts; explicit publish. |
| Expose Drupal to external AI? | mcp_server + mcp_server_tool_bridge to expose Tool API plugins. |
| Drupal to call external tools? | mcp_client. |
| Event-driven automation inside Drupal? | An ECA model invoking Tool API plugins. Not a custom event subscriber. |
| Field-level AI actions or buttons on edit forms? | drupal/field_widget_actions + drupal/eca_field_widget_actions. |
| AI-generated Schema.org structured data / JSON-LD? | drupal/ai_schemadotorg_jsonld. |
| Expose machine-readable site index for AI agents? | drupal/llms_txt and drupal/agents_md. |
| A complex pipeline with approval gates? | A FlowDrop pipeline with pause nodes. |
| An external orchestrator (Activepieces/n8n) to drive Drupal? | Configure drupal/orchestration. Not a local engine. |
| To store structured context for AI? | An ai_context_item bundle + fields. Not a custom entity type. |
| To package and distribute config? | A Recipe. Not a module. |
| Embed a web app inside the Canvas editor or as a Canvas page? | A *.canvas_extension.yml in your module. Not a custom route + Controller. |
| Read the Canvas preview or the selected component from that app? | @drupal-canvas/extensions: getPreviewHtml(), subscribeToPreviewHtml(), getSelectedComponentUuid(), subscribeToSelectedComponentUuid(). |
| Scaffold, lint, preview or ship a Canvas code component? | The upstream @drupal-canvas/* npm toolchain. Not a bespoke mount script. |
| Full indexed module catalog with Bluefly approvals? | See Engineering-Standard/catalog/drupal-ai-ecosystem.md. |
| None of the above? | Stop. Re-read sections 2-8. The answer is almost certainly above; otherwise a custom submodule with full justification in the MR description. |
10. Bluefly platform stack (binding for Bluefly-hosted Drupal sites)¶
| Concern | Choice |
|---|---|
| Drupal version | CMS 2.0 — Drupal 11.x via drupal/cms meta-package |
| Hosting | Oracle VM behind cloudflared tunnel; bluefly.io/www.bluefly.io as P1 routes |
| PHP runtime | FrankenPHP classic mode (validated ~2.8x throughput over nginx-fpm) |
| Cache layers | BigPipe + Cloudflare edge + Drupal internal page cache + Redis render cache |
| Database | PostgreSQL on Oracle Cloud — not MySQL, matches the LiteLLM proxy setup |
| AI transport | drupal/ai (1.3.0+) only — no custom Guzzle clients |
| Page composition | drupal/canvas (SDC + React) + bluefly2 theme (Radix sub-theme + @bluefly/studio-ui tokens) |
| Local workflows | drupal/eca for events; drupal/flowdrop for pipelines only where a pipeline consumer exists — never installed speculatively |
| External orchestration | drupal/orchestration — exclusively for Activepieces/n8n/Zapier bridging, installed only once such a bridge exists |
| Policy enforcement | cedar_policy as a thin PDP client (Tool plugin + ECA condition) to the compliance engine; no inline policy evaluation in Drupal |
| Agent discovery | duadp (one drupal.org project; duadp_client is the same module and is being merged), /.well-known/duadp.json via duadp_discovery, GAID resolution |
| Agent-readable content | drupal/markdownify + llms_txt; no Bluefly Markdown or agent-content projection code |
| AI search | drupal/ai_search + Qdrant |
| LLM proxy | LiteLLM proxy on Oracle Cloud (Docker Compose + PostgreSQL) |
The table names owners, not an install list. A module in this table is installed on a site when that site has a consumer for it, and is retired when it no longer does (section 13).
Hard order of operations:
1. bluefly.io/www.bluefly.io must be in the Cloudflare Tunnel registry (domains.yaml) as P1 routes before exposing any new module or endpoint.
2. Lock down JSON:API to service accounts per OWNERSHIP.md before enabling DUADP federation.
3. Apply recipe_secure_drupal first — security baseline before any other configuration.
4. Resolve the ai_provider_litellm deletion decision before relying on it as the primary AI gateway.
5. Complete api_normalization Gate 1 (PHPCS clean, no hardcoded URLs, no TODO/FIXME, drupal-check clean) before installing on bluefly.io.
Composer dual-source guard: several Bluefly modules are published on both drupal.org and GitLab. Composer resolves by version constraint — "*" lets GitLab dev-main win; "^1.0" can let drupal.org win silently. Every Bluefly-published module needs an explicit installer-paths mapping by package name; audit composer.json before any new install.
Module discovery is governed by the .info.yml basename, not repo path or Composer package name — all three are separate namespaces. GitLab repo path, Composer name, and the Drupal module machine name (the .info.yml file's basename) can diverge independently; only the .info.yml basename determines what Drupal actually discovers, and the directory a Composer install lands the module in must match that basename or discovery silently breaks. Verify against the actual .info.yml file in the repo tree before any composer-identity fix — never against the repo path or an MR description's claim. Two near-misses, 2026-09-02: a repo path mcp_gateway whose real module is mcp_registry.info.yml (matches neither the repo path nor the composer name under discussion); a repo path source_connect whose real module is source_connector.info.yml (the repo path itself was already wrong, not only the composer name).
GitLab registry structure & package naming. blueflyio/agent-platform/drupal is the canonical GitLab group for all Bluefly-owned Drupal modules, themes, and recipes, organized by intended ownership/distribution — pick the subgroup by where the code should live, not by convenience:
| Subgroup | Contents | Package prefix |
|---|---|---|
contrib-ready/ |
Public Bluefly modules intended for Drupal.org; build to community conventions, extend contrib before adding custom implementation | drupal/NAME |
private/ |
Internal Bluefly modules; remain Bluefly-owned, still contrib-first, minimize custom implementation | bluefly/NAME |
themes/ |
Standardized Drupal themes across Bluefly sites and agent-facing experiences | not specified |
recipes/ |
Composable recipes assembling modules, configuration, permissions, and site capabilities with minimal or no custom code | not specified — real repos currently split (recipe_amcs uses blueflyio/NAME; recipe_blucity uses bluefly/NAME, an intentional 2026-08-31 rename) — needs an explicit decision, do not assume either is canonical |
Artifact hierarchy: Site Template (the Product) > Domain Recipe (the Capability) > Foundation Recipe (the Platform Base). A site template composes recipes and owns theme/layout/navigation/product-level installer config only. A domain recipe owns a capability layer (AI, ECA, Tool API, search) plus its own content types/workflow/roles. A foundation recipe owns generic platform-base primitives only (entities, content moderation, workflows, secrets) that domain recipes extend — it must never grow AI/ECA/Tool logic; that belongs one layer up, in the domain recipe.
10. Render pipeline rules (critical for agent-generated code)¶
Render errors are invisible in development (no caching) but break in production under caching.
- Return render arrays, not HTML strings. Page controllers must return render arrays, never
new Response($html)or raw HTML. AResponseobject loses all cacheability metadata. UseResponseonly for JSON endpoints, file downloads, or intentional caching bypass (CacheableJsonResponsefor JSON). - Cache metadata is mandatory. Every render array that varies by user/permission/route/query must declare cache contexts; every array depending on an entity/config must declare cache tags. Max-age uses minimum-wins bubbling — one child at
max-age: 0makes the whole page uncacheable. Use#accesswith anAccessResultobject, not a plain boolean, so cacheability propagates correctly. - Lazy builders for personalized content. Never put personalized content (username, cart count, session data) directly in a render array — use
#lazy_builder+#create_placeholder: TRUEso BigPipe streams the page and fills placeholders async. Lazy builder arguments must be scalar only (string/int/float/bool/null) — passing an entity object instead of an ID is the most common agent mistake. - Render element types:
#theme(Twig template, standard choice),#type(render element plugin),#markup(filtered HTML viaXss::filterAdmin()— never wrap user input inMarkup::create()to suppress escaping, it's an XSS hole),#plain_text(escaped text — use for user-generated content, never#markup).
11. Drupal.org contribution workflow¶
Drupal hosts source at git.drupalcode.org. Use the glab CLI for all operations — never curl, never WebFetch a GitLab URL (pages are JS-rendered). Confirm glab --version and glab auth status --hostname git.drupalcode.org before any operation.
- Issue-fork model: Drupal does not use personal forks. Each issue gets a dedicated fork at
git.drupalcode.org/issue/<project>-<issue-id>, provisioned via the drupal.org UI or a/do:forkcomment — never by pushing directly or via the API. - Two-hostname rule: HTTP operations use
git.drupalcode.org; SSHgit pushusesgit.drupal.org. Not interchangeable — a write sent togit.drupal.orgviaglab apiis silently downgraded to a GET (HTTP 200 instead of 201, nothing created). Always pass--repoand--hostnameexplicitly, never rely on cwd. - Token scopes: start read-only (
read_api,read_repository); only addapi/write_repositorywhen pushing branches or opening MRs. A GitLab PAT is not scoped to one project — write scopes reach every repo reachable. Confirm the target repo/branch with the operator before any write, and never push to a protected branch without explicit approval.
12. Drupal as a Gas City Rig¶
A Drupal site repository is an ordinary Gas City Rig — a full Git working tree registered with the one City (see Gas City Adoption). Drupal does not get a parallel orchestration model, a separate scheduling mechanism, or its own release-authority concept; Gas City owns the work graph and Beads owns execution history for Drupal work exactly as it does for every other Rig.
DDEV is an execution surface only. DDEV provisions the local/CI
environment a Drupal Rig runs in. It owns none of: durable work state,
scheduling, release policy, or deployment authority. See
DDEV Standard §2 ("shift that execution authority to Gas
City... do not solve it by writing a custom DDEV CLI wrapper"). A DDEV
.ddev/ directory is Rig-local execution configuration, not a second work
ledger.
Drupal ownership ladder (binding)¶
Extends the assembly order in §6 into the full hierarchy that governs every Drupal capability decision, in order:
- Drupal Core
- Contrib module
- Configuration (config entities, content entities/fields, Views, permissions, Layout Builder)
- Recipe
- Canvas / SDC (component-level composition)
- ECA / FlowDrop (event-driven workflow, pipelines)
- Drupal AI (
drupal/ai,drupal/ai_agents, provider/tool/context plugins — §§2–8 above) - Tool API / MCP (typed executable capability, protocol-level access)
- Existing Bluefly extension (an already-owned Bluefly module or pack that already covers adjacent ground)
- Custom code — only after every rung above is proven insufficient for a specific, documented gap, and only with: an ADR recording the gap and the rejected alternatives, tests proving the behavior, a named maintainer, and an explicit deletion trigger (the condition under which this custom code is retired back down the ladder).
Skipping a rung requires stating, in the MR description, which specific rung was evaluated and why it does not cover the requirement — the same discipline §6's assembly order already requires for steps 1–9 before step 10 there.
Module = capability, recipe = composition (binding)¶
A module ships the capability. A recipe ships the composition — which models run, on what schedule, writing into which entity type. This is not a style preference; a module physically cannot ship config whose module dependencies it does not itself declare:
ModuleInstaller.php:209 $config_installer->checkConfigurationToInstall('module', $module_list)
ConfigInstaller.php:581 throw UnmetDependenciesException::create($name, ...)
Any file in config/install/ declaring dependencies.module: [x] where x is
absent from the module's .info.yml makes drush en <module> fatal on every
site that does not already have x. A recipe has no such constraint, because it
installs before it configures:
RecipeRunner::processRecipe()
processInstall(...) <- modules installed first
processConfiguration(...) <- config applied after
The losing side is required, not dropped. When config moves to a recipe, the
recipe adds the module to its install: list — the capability is a dependency,
not a discarded half. Measured case: an ECA model using
http_client_manager_command:<service>:<op> is inert without the module whose
*.http_services_api.yml generates that derivative.
A recipe's install: list must match what its config actually depends on.
Read every dependencies.module across the recipe's config/install/ and
reconcile. Entries nothing depends on are unvalidated accretion and will block
drush recipe with "is not a known module or theme" the first time someone
applies it on a clean site. Do not satisfy such an entry by adding a Composer
dependency — that adds ownership to justify a line nobody needed.
Canvas extensions and the upstream npm toolchain¶
Canvas ships its JavaScript surface as published npm packages from the same
repository as the module (git.drupalcode.org/project/canvas). They are
upstream, not third-party glue, and they sit above custom code on the
ownership ladder.
| Package | Owns |
|---|---|
@drupal-canvas/extensions |
The extension API: preview HTML, selected component UUID, and their subscriptions |
@drupal-canvas/create |
Scaffolding a code-component codebase |
@drupal-canvas/cli |
Managing and shipping code components |
@drupal-canvas/vite-plugin |
Local development of code components |
@drupal-canvas/workbench |
Local preview/development app for code components |
@drupal-canvas/eslint-config |
Validating code components |
@drupal-canvas/headless* |
Draft preview for headless front ends (Next, Nuxt, Astro, TanStack Start) |
An extension is declared in Drupal, not wired by hand:
my_module_extension:
name: Example Extension
description: What it does.
url: index.html # local file in the module, or a remote URL
icon: icon.svg
type: page # canvas (default) | page | code-editor
api_version: 1.0
permissions:
- access content
type: page publishes the app at /canvas/app/{extension_id} with a side
menu link, forwards deeper paths to the iframe as a hash route, and gates on
the listed permissions. That is the sanctioned way to put a Bluefly app
beside Canvas.
OBSERVED 2026-09-13 in the estate:
bluefly.ioalready depends on@drupal-canvas/extensions,cli,create,eslint-configanddrupal-canvas, and imports none of them. The upstream answer is installed and unused.- No Bluefly module declares a
*.canvas_extension.yml. The only ones on disk are contrib Canvas's own test fixtures. agentic_canvas_blocksinstead hand-rolls the surface: custom routes/brandand/acquia-demowithBrandControllerandAcquiaDemoController, plusstudio_ui_mount,command_palette,ai_assistant,agent_catalogandcanvas-componentslibrary mounts. These are candidates for deletion in favour of Canvas extensions and Canvas-composed pages, not for extension.drupal/canvasis constrained three different ways across consumers:^1.0@dev(bluefly.io, resolves 1.11.0),^1.4, and exact1.9.0(contextcontrol-ai). The first two are fine — Bluefly tracks the latest, and dev where that is what ships. The exact1.9.0is the defect: it freezes that consumer two minor versions behind the extension API it needs. Do not pin versions.
web/ is read-only — the source law¶
web/ is a runtime and install projection surface, not an authoring
surface — Composer-installed packages plus DDEV, generated and runtime output.
It is never an authoring location, for contrib or for our own code.
WEB_FOLDER_SOURCE_AUTHORITY=NO
WEB_FOLDER_READ_ONLY=YES
WEB_FOLDER_MUTATION_ALLOWED=NO
No agent edits, creates, deletes, patches, moves, renames, or commits anything
under web/ — core, modules/contrib, modules/custom, themes,
profiles, libraries. Changes made there are overwritten by Composer,
disconnected from the producer repository, absent after a clean install, and
they contaminate config-export analysis while looking like a fix.
Allowed under web/: grep, cat/head/sed for reading, read-only
static analysis, Composer package and version discovery, Drush runtime
verification, class and plugin discovery, diffing against upstream.
Not allowed: editor writes, sed -i, perl -pi, patch, cp into
web/, mv, deletion, git add web/, generated code, hand-made symlinks.
modules/custom is not an exception. web/modules/custom/kb_cache may be
read to understand installed behaviour; the source of truth is the
bluefly/kb_cache producer repository, and every change goes
Bead → producer worktree → tests → MR to release → publish → Composer.
A contrib defect is evidence, not a work item in place. PSR-4 warnings under
web/modules/contrib/api_normalization belong to that package's producer.
Report the finding in this shape and never as "edit web/modules/…":
INSTALLED_PATH=
PACKAGE=
SOURCE_OWNER=
CANONICAL_REPO=
DEFECT=
IMPLEMENTATION_LOCATION=
Site configuration is not an exception to this law — it is a different
source-authority surface. When the site genuinely owns the setting, inspect
the contrib implementation under web/ read-only, then change and export
config/sync/. web/ stays read-only throughout. That is how contrib
behaviour gets corrected without touching contrib source.
SITE_CONFIG_AUTHORITY = config/sync
PACKAGE_SOURCE_AUTHORITY = producer repo
DEPENDENCY_AUTHORITY = composer.json + composer.lock
WEB_REMAINS_READ_ONLY = always
If web/ is already dirty, do not tidy it blindly. A local modification
under web/ is evidence, not source authority. Classify it first:
COMPOSER_GENERATED LOCAL_MANUAL_EDIT SYMLINK_PROJECTION
DDEV_GENERATED RUNTIME_GENERATED ACCIDENTAL_CONTAMINATION OTHER
Then preserve any unique local work, identify the producer/source authority, and restore the installed tree through the governed Composer/package path. A local modification may contain unshipped producer work — that must be proven before it is treated as such:
LOCAL_EDIT_IN_WEB = EVIDENCE
PRODUCER_WORK = NOT_PROVEN UNTIL CLASSIFIED
Until unique-work risk is resolved, no rm, no trash, no
git checkout web/, no git clean web/, and no blind Composer reinstall.
Generated trees are never source¶
web/modules/, web/themes/, and any other Composer-managed or
build-generated Drupal tree are generated runtime artifacts, not source.
They are never the target of a hand-edit, never committed as authoritative
source (only composer.json/composer.lock are), and never diffed as if they
were first-party code. A change that appears only inside a generated tree has
not actually been made — find the owning composer.json constraint, producer
repository, or recipe instead. See web/ is read-only — the source law
above for the binding rule and its reporting contract.
13. Module evaluation is not module installation¶
Given a list of candidate modules, evaluate before enabling — never drush en/composer require straight down a list.
Per candidate:
PROJECT=
WHAT_PROBLEM_DOES_IT_SOLVE=
DO_WE_HAVE_THAT_PROBLEM=
CURRENT_SITE_CAPABILITY=
UPSTREAM_ALTERNATIVE=
MODULE_MATURITY=
SECURITY_COVERAGE=
DEPENDENCIES=
CONFIG_EXPORTABLE=
RECIPE_FRIENDLY=
MAINTENANCE_COST=
LAST_VERIFIED=
CURRENT_BLUEFLY_OWNER=
BLUEFLY_OVERLAP=
WHAT_IT_REPLACES=
NET_OWNERSHIP_EFFECT=<SN|N|0|P>
DISPOSITION=<ADOPT|ADOPT_WHEN_NEEDED|EVALUATE|DO_NOT_USE|REPLACE_BLUEFLY|KEEP_BLUEFLY|CONTRIBUTE_UPSTREAM>
Existing on drupal.org is not install-worthy by itself. For any alpha/beta/sandbox/non-security-covered project, SECURITY_COVERAGE/MODULE_MATURITY must carry an explicit answer, not a blank — silently promoting an experimental project to production is the failure this guards against.
The same discipline applies in reverse. An installed or enabled module with no consumer (no View, field, link, pipeline, model, plugin or dependent module uses it) is not kept because it is already present; it is retired with the same record, and the retirement must prove consumers, replacement and clean configuration import. One owner per concern: two cache stacks, two mail paths, several analytics trackers, or several modules for one field type are a defect, not flexibility. The procedure is Playbooks/drupal/contrib-module-adoption.md; the persistent record is Engineering-Standard/reference/drupal/module-ownership-matrix.md.
A Drupal AI extension that hard-requires a specific provider module violates the provider abstraction and is DO_NOT_USE regardless of its other merits; the same capability is obtained through provider-agnostic modules or Drupal AI automators.
14. Upstream documentation discovery (per module, before integrating)¶
Before trusting memory for a module's API or behavior, establish:
PROJECT_PAGE=
DOCUMENTATION_URL=
SOURCE_REPOSITORY=
INSTALLED_VERSION=
CURRENT_STABLE_VERSION=
SECURITY_COVERAGE=
MAINTENANCE_STATUS=
UPSTREAM_REQUIREMENTS=
KNOWN_CURRENT_LIMITATIONS=
If upstream docs exist for a module in active use, they are the bible for that module's behavior — current docs/source beat memory. This is the Drupal-specific instantiation of Upstream Capability Discovery, the platform-wide law; do not restate UCD's own algorithm or tables here.
15. Canvas authentication is not a manual OAuth task¶
Don't create an OAuth client until the current Canvas auth path is inspected: installed Canvas version → canvas_oauth README → Simple OAuth requirements → determine intended flow (interactive user auth, service-to-service, pre-issued token, external provider) → prefer the Canvas-supported path. Canvas currently supports browser OAuth Authorization Code + PKCE with consumer/client discovery.
Do not reflexively run drush oauth:client:create just because an OAuth client is needed — first determine whether Canvas OAuth provisions/discovers the consumer itself. simple_oauth (capability) and canvas_oauth (Canvas-specific integration/scopes/discovery) are not interchangeable; inspect current docs before enabling either.
Least privilege: never create a broad all-powerful OAuth consumer for convenience. Determine minimum required scopes from current upstream scope definitions — never invent scope names. Never put client secrets in git, config export, README examples, or committed .env files.
16. DDEV execution context — host vs. container¶
Inside ddev ssh, commands execute inside the container directly — use drush/composer/php directly, not ddev drush/ddev composer (those are host-context commands; doubling them up errors or silently re-wraps). Before giving any command, establish:
HOST_CONTEXT=<HOST|DDEV_CONTAINER>
PROJECT_ROOT=
COMPOSER_ROOT=
DRUPAL_ROOT=
Same DDEV-is-execution-surface-only framing as §12 — this is the command-level corollary. Before inventing a sync/migration script, check whether the official Canvas CLI already supports it (login/logout, component pull/push, sync, asset libraries, pages, content templates, regions, validation, media reconciliation) — same Rule J discipline as the rest of this doctrine.
17. Success law — mechanism succeeding is not outcome succeeding¶
composer require succeeded ≠ module correctly integrated. drush en succeeded ≠ feature correctly configured. OAuth token minted ≠ required API capability works. Canvas component uploaded ≠ rendered component works. Config import succeeded ≠ site behavior correct. Recipe applied ≠ product acceptance passed.
Always verify resulting Drupal behavior against the intended invariant:
AUTH_DISCOVERY=<PASS|FAIL>
LOGIN=<PASS|FAIL>
TOKEN=<PASS|FAIL>
EXPECTED_SCOPE=<PASS|FAIL>
EXPECTED_API_READ=<PASS|FAIL>
EXPECTED_API_WRITE=<PASS|FAIL>
UNAUTHORIZED_OPERATION=<DENIED|ALLOWED>
Same discipline as Investigation Methodology's evidence-before-claims rule, instantiated for Drupal.
18. Site context stays thin¶
A specific Drupal site's own context (its README/agent-context file) records only: purpose, product/Recipe composition, current Drupal/CMS/Canvas versions, enabled capability set, site-specific content model, site-specific config, package ownership, deployment target, current known gaps. Never copy an upstream module's manual into a site's context — reference upstream docs instead. Site context answers "what is unique here," not "how does Drupal work" — that answer lives in this file, and applies to every Bluefly Drupal site, product, Recipe, Site Template, consumer, module, theme, and Drupal-specialist agent.
19. ECA operational recovery¶
ECA fails quietly. A broken model can lock the admin UI that would let you fix it, and a correct model can import cleanly and then never run, with nothing written to any log. These four are the recovery paths. Their value is knowing they exist during an incident, not finding them afterwards.
The model has locked the admin UI. Disabling the offending model normally needs the UI, and Drush cannot help because it bootstraps the site — and the site is what is broken. Put the kill switch in settings.php instead; it disables ECA entirely, and users holding administer eca get an on-screen notice so the outage is not silent. Remove the line once the model is fixed, disabled, or deleted.
$settings['eca_disable'] = TRUE;
The model references a plugin that no longer exists and therefore cannot be saved. Core validates the original config entity as well as the updated one, so removing the dead reference does not help — the model is unsaveable in both directions. Append the query argument to the model's edit URL once, make the correction, save. It is not needed again.
/admin/config/workflow/eca/<model_id>/edit?eca_validation=off
Debug mode was left on. The Test button in the modeler enables debug mode temporarily; closing the tab before the test completes leaves it on. It normalizes and stores token data on every ECA run, which is a real cost on any site with significant entity or JSON:API traffic. ECA 3.1+ times test-triggered debug out after 5 minutes by default, but manually enabled debug mode has no timeout. Check with state:get, clear with:
drush state:set _eca_internal_debug_mode 0
The model imported cleanly and does nothing. ECA does not listen to every Drupal event — it keeps a cached list of the events used by enabled, non-template models. When that list is stale the model exists, validates, looks correct in the modeler, and never fires, and there is no error anywhere to say so. This is the most common false report of a broken ECA installation. The command is cheap and idempotent, so run it after every config import rather than working out whether this particular import needed it:
drush eca:subscriber:rebuild
Same §17 discipline: drush config:import succeeded ≠ the model runs. Verify the model actually fires before reporting the deployment complete.