Skip to content

Drupal Factory Convergence — Operating Contract

This document is the durable operating contract for Drupal delivery.

It is not one work item. Do not hand this charter to one agent as a single execution unit. Execute the Bead graph below. Measure deleted duplicate implementation, not repositories touched.

Related (do not fold into this contract): contrib-first Drupal AI practice lives in Engineering-Standard/standards/drupal/drupal-standard.md. Repo-shape and duplicate-CI removal live in Engineering-Standard/standards/core/repository-governance-workflow.md. Custom CI detection already exists as gitlab_components component custom-ci-governance (GOV-CUSTOM-GITLAB-CI-001).


Binding rules

feature/* → release/v0.1.x → main

GitLab = source/package/CI authority
gitlab_components = shared CI authority
DDEV = disposable Drupal runtime
upstream first
no duplicate project-specific CI
real package install is required
Playwright/screenshot proof for rendered behavior
bluefly.io + contextcontrol.ai = real consumer acceptance

Job: consolidate existing drupal-master and drupal-playwright behavior into the shared factory. Do not create another CI system.

Forbidden:

  • feature/* → main
  • main → release/v0.1.x
  • path repository / local copy / web/modules copy as the consumption path
  • hand-edited vendor code
  • unpublished branch dependency as the consumer install source
  • fleet-wide migration before the vertical slice is proven
  • grandfathering existing custom CI

Target branch for factory and producer work: release/v0.1.x.


Consumable release state (locked meaning)

“Package on every release-branch merge” means the exact tested release state becomes consumable, not that every merge commit must publish a permanent versioned package.

drupal-master already publishes the Composer branch alias dev-release/v0.1.x on release/* success. Numbered -dev tags are not valid Composer versions. Do not add a unique registry version per commit.

Enough:

  • branch/ref-derived Composer consumption (dev-release/v0.1.x or equivalent), or
  • a properly stamped prerelease artifact

Not enough:

  • a unique registry version per merge commit (registry/versioning mess)
  • a consumer that installs a different SHA than the one that passed CI

Required of every proven producer and consumer:

EXACT_TESTED_RELEASE_STATE=CONSUMABLE
SAME_TESTED_ARTIFACT=YES

Promotion release/v0.1.x → main remains a separate lane. It must promote the already-tested artifact. Do not rebuild materially different content for main.


Bead graph (execution units)

Parent epic: DRUPAL_FACTORY_CONVERGENCE

EPIC: DRUPAL_FACTORY_CONVERGENCE
│
├── 1. AUDIT_GITLAB_COMPONENTS
│     prove drupal-master / drupal-playwright current behavior
│     identify duplicated/dead components
│
├── 2. CONVERGE_SHARED_FACTORY
│     DDEV
│     package publication (exact release state consumable)
│     Drupal install
│     Playwright
│     screenshot artifacts
│
├── 3. PROVE_ONE_PRODUCER
│     use ottermon if ready
│     feature → release
│     package → clean DDEV install → Drupal test → browser proof
│
├── 4. PROVE_BLUEFLY_IO_CONSUMER
│     consume exact release artifact
│
├── 5. PROVE_CONTEXTCONTROL_AI_CONSUMER
│     consume exact release artifact
│
├── 6. ENFORCE_NO_CUSTOM_CI
│     detect duplicated local CI
│     require exception record
│
├── 7. FLEET_MIGRATION
│     only after vertical slice is proven
│
└── 8. RELEASE_TO_MAIN_PROMOTION_PROOF
      prove stable promotion uses the already-tested artifact

Do not start bead 7 before beads 1–5 are green. Bead 6 may start once the vertical slice (1–5) is proven. Bead 8 stays a separate promotion lane after release state is fully proven.


P0 — Shared factory first (beads 1–2)

Audit the existing canonical implementations:

  • blueflyio/gitlab_components
  • drupal-master
  • drupal-playwright (current behavior, including if the named component is absent or retired)

Do not create replacements.

Return:

DRUPAL_MASTER_CURRENT=
DRUPAL_PLAYWRIGHT_CURRENT=
DUPLICATE_COMPONENTS=
PACKAGE_PATH=
DDEV_PATH=
PLAYWRIGHT_PATH=
SCREENSHOT_PATH=
GAPS=

Then fix the existing shared components on release/v0.1.x.

Playwright/screenshot proof belongs in the shared factory (DDEV + drupal-master composition), not a second product-local browser stack.


P1 — Prove one producer (bead 3)

Use one real Drupal package only. Preferred: drupal/ottermon.

Do not move to another producer until this works.

Acceptance:

feature branch
MR → release/v0.1.x
CI PASS
development artifact/package available
  (exact release state consumable; not a unique version per commit)
clean DDEV site created
exact release artifact installed
Drupal boots
module enables
tests pass
Playwright passes where applicable
screenshot artifact exists

P2 — Prove real consumers (beads 4–5)

First bluefly.io, then contextcontrol.ai.

Each must consume the exact release artifact.

Forbidden consumer paths: path repository, local copy, web/modules copy, hand-edited vendor code, unpublished branch dependency.

Required per consumer:

PACKAGE_CONSUMPTION=PASS
DDEV=PASS
DRUPAL_INSTALL=PASS
PLAYWRIGHT=PASS
SCREENSHOT=PASS
EXACT_TESTED_RELEASE_STATE=CONSUMABLE

P3 — Governance (bead 6)

Only after the vertical slice is green.

Enforce: project CI = shared component composition.

Local CI only with:

CI_EXCEPTION_ID
OWNER
REASON
WHY_SHARED_COMPONENT_CANNOT_HANDLE_IT
EXPIRY_OR_REVIEW_DATE

Existing custom CI is not grandfathered.

Prefer extending custom-ci-governance over a new detector.


P4 — Fleet migration (bead 7)

Now audit remaining Drupal-family repositories.

Classify:

SHARED_COMPONENT_ONLY
SHARED_PLUS_JUSTIFIED_LOCAL
DUPLICATED_SHARED_LOGIC
FULL_CUSTOM
BROKEN

Default action for duplicated logic: MOVE_TO_SHARED.

Do not touch the fleet before P0–P2 are proven.


P5 — Promotion proof (bead 8)

After release state is fully proven:

release/v0.1.x → main
SAME_TESTED_ARTIFACT=YES

This remains the separate promotion lane.


Success metric

Do not optimize for repositories touched.

CUSTOM_CODE_DELETED=
DUPLICATE_CI_REMOVED=
SHARED_COMPONENTS_REUSED=
UPSTREAM_COMPONENTS_REUSED=
PRODUCER_PACKAGE_PROVEN=
REAL_CONSUMERS_PROVEN=

Objective: less owned implementation and one reliable delivery path.


Composition authority — the Recipe layer owns package sets

Bluefly already has the Drupal composition system. It is the Recipe layer. Do not build a second one beside it.

MODULE / CONTRIB
  -> CAPABILITY RECIPE        owns its own direct dependencies
  -> PLATFORM / PRODUCT RECIPE  composes capability Recipes
  -> SITE TEMPLATE            composes the product Recipe

Binding, until an operator authorises the artifact by name in advance:

NEW_GITLAB_PROJECT=FORBIDDEN
NEW_PACKAGE=FORBIDDEN
NEW_META_PACKAGE=FORBIDDEN
NEW_BOM_PROJECT=FORBIDDEN
NEW_REGISTRY_NAMESPACE=FORBIDDEN

"Cleaner to centralise the pins in one place" is not an architectural gap. A metapackage that exists only to group packages the Recipe layer already composes is duplicate authority, and it is forbidden. The question is never "which new project should own this set" — it is "which existing Recipe already composes this capability". If none does, prove the gap against existing modules, Recipes, Site Templates, packages, GitLab projects, the package registry and Drupal.org before proposing anything new.

Consequence for dependency pinning: one canonical capability owner per package, composition upward. Do not repeat the same module list across five Recipes.


Publishing acceptance — green is not published

package:dev in the shared release-flow component posts the Composer dev ref to the Package Registry API with curl, does not propagate the HTTP status, prints its own success line, and exits 0. A failed registration therefore produces a green pipeline that published nothing.

OBSERVED 2026-09-14, job log:

{"message":"Validation failed: Name is already taken by another project"}
curl: (22) The requested URL returned error: 400
Composer dev ref registered: dev-release/v0.1.x
Job succeeded

Acceptance for any producer claim is therefore:

EXPECTED_PACKAGE_VERSION_VISIBLE_IN_REGISTRY=YES
PUBLISHED_SOURCE_REFERENCE == RELEASE_BRANCH_HEAD

not:

PIPELINE_GREEN=YES

Verify with the group p2 metadata (/api/v4/group/87749026/-/packages/composer/p2/<vendor>/<name>.json) and compare its source.reference to the branch head. Fix belongs once in gitlab_components, never per consumer project.


Recipe layer audit — 2026-09-13

RETRIEVED from the GitLab API and the group Composer registry (group 87749026) on 2026-09-13. Every Recipe project below had a green release/v0.1.x pipeline at the time of audit.

HEAD is the project's current branch tip. Published is the ref the registry actually serves. Status is derived from the two: equal is PASS, Published behind HEAD is STALE, absent is NOT_PUBLISHED. Neither column is an "expected" value — both are observed, and the comparison is positional so every row reads the same way.

Recipe project Declared package HEAD Published Registry state
recipe_agent_platform bluefly/recipe_agent_platform 2845c3b6 2845c3b6 PASS
recipe_amcs blueflyio/recipe_amcs 02eda81b 02eda81b PASS, duplicate identity
recipe_blucity bluefly/recipe_blucity 74ddddd2 74ddddd2 PASS, triple identity
recipe_secure_drupal bluefly/recipe_secure_drupal 294e1876 294e1876 PASS
recipe_ottermon_baseline bluefly/recipe_ottermon_baseline f5bbee49 f5bbee49 PASS
recipe_ai_marketplace bluefly/recipe_ai_marketplace e8d30477 e8d30477 PASS, but unresolvable requires
recipe_contractplane_intent drupal/recipe_contractplane_intent ffc71b7c ffc71b7c PASS, wrong vendor
recipe_contextual_memory bluefly/recipe_contextual_memory 19046219 27201fc6 STALE
recipe_digital_service_baseline bluefly/recipe_digital_service_baseline not recorded not in registry NOT_PUBLISHED
recipe_agentdash blueflyio/recipe-agentdash not recorded not in registry NOT_PUBLISHED

Three of ten Recipes are green and wrong. That is the false pass above, already in the estate and not caused by any one project.

Package identity defects

One project must publish exactly one package name.

  • recipe_blucity publishes bluefly/recipe_blucity, blueflyio/recipe_blucity and blueflyio/recipe_drupaltown. site_template_amcs consumes the third name, so the Site Template's dependency does not name the project it comes from.
  • recipe_amcs publishes both bluefly/recipe_amcs and blueflyio/recipe_amcs.
  • amcs-demo/composer.json declares name: blueflyio/site_template_amcs, the package name already owned by the site_template_amcs project. It reaches the registry as blueflyio/amcs-demo. This is the same name-collision that the false-passing publish step hides.
  • recipe_agentdash declares blueflyio/recipe-agentdash — hyphen where the estate uses underscores, and the blueflyio/ vendor where Recipes use bluefly/.

Composition is flat, not layered

Only recipe_amcs -> bluefly/recipe_blucity and the Site Templates compose another Recipe. Every other Recipe carries its own flat module list, so drupal/ai, drupal/ai_agents, drupal/eca, drupal/key, drupal/token and drupal/search_api are re-pinned independently, at differing constraints, across five or more Recipes. Converge these onto capability Recipes before touching consumer sites.

Unresolvable requirements

recipe_ai_marketplace cannot install as published. Seven of its requirements do not resolve:

bluefly/ai_agents_ossa         dev-release/v0.1.x  registry has dev-main only; class 1 must be drupal/
bluefly/ai_provider_apple      dev-release/v0.1.x  not in registry; class 1 must be drupal/
bluefly/ai_agents_claude       dev-release/v0.1.x  not in registry
bluefly/ai_agents_cursor       dev-release/v0.1.x  not in registry
bluefly/ai_agents_crewai       dev-release/v0.1.x  not in registry
bluefly/ai_agents_huggingface  dev-release/v0.1.x  not in registry
bluefly/ai_provider_langchain  dev-release/v0.1.x  not in registry

recipe_contextual_memory requires drupal/kb_cache: *. That package does not exist, so this is not a vendor-naming preference — it is an UNRESOLVABLE_REQUIREMENT, the same class already flagged against recipe_ai_marketplace.

The correction belongs entirely on the Recipe side. Do not touch kb_cache. The package is already published correctly: kb_cache's own composer.json on release/v0.1.x declares "name": "bluefly/kb_cache", and the project carries tags v0.1.1, v0.1.2, v0.2.0.

PACKAGE_CHANGE_REQUIRED=NO          TARGET_PACKAGE=bluefly/kb_cache
RECIPE_REQUIREMENT_CHANGE_REQUIRED=YES
    drupal/kb_cache: *   ->   bluefly/kb_cache: ^0.2.0

^0.2.0 rather than * because a real tag exists to constrain against; under 0.x semver it admits 0.2.x and nothing wider.

The same Recipe pins four further requirements at * — drupal/ai_agents_ossa, drupal/api_normalization, drupal/duadp and drupal/source_connector. A wildcard is not a reproducible constraint. Those four are recorded here as observed; each needs the same treatment as kb_cache, which is to say check the package's real name and tags first, because a wildcard can be hiding an unresolvable requirement rather than a loose one. That is exactly what drupal/kb_cache: * was doing: the constraint never failed loudly because the package name was never resolvable in the first place.

Producer package defects

Fix at the producing project. Never work around these in a Recipe, a consumer site, or a grouping package.

layout_system_converter   no Composer package published under any vendor
simple_age_gate           bluefly/ vendor correct, dev-main only, channel never published
skills_browser            publishes drupal/skills_browser; class 2 must be bluefly/
playbook_engine           publishes drupal/playbook_engine; class 2 must be bluefly/

24 of the 28 class-2 packages do publish dev-release/v0.1.x correctly and have a resolvable channel constraint.

Order of work

1  shared release-flow false pass        gitlab_components, once
2  package identity: one project, one name
3  four producer package defects
4  republish stale / never-published Recipes, verify in registry
5  Recipe composition DRY: capability owner per package
6  product Recipes compose capability Recipes
7  Site Templates compose product Recipes
8  clean-install proof, then WITNESS

Consumer sites are last. A consumer cannot be fixed while its producers publish nothing, publish stale, or publish under a name no one requires.