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/*→mainmain→release/v0.1.x- path repository / local copy /
web/modulescopy 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.xor 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_componentsdrupal-masterdrupal-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_blucitypublishesbluefly/recipe_blucity,blueflyio/recipe_blucityandblueflyio/recipe_drupaltown.site_template_amcsconsumes the third name, so the Site Template's dependency does not name the project it comes from.recipe_amcspublishes bothbluefly/recipe_amcsandblueflyio/recipe_amcs.amcs-demo/composer.jsondeclaresname: blueflyio/site_template_amcs, the package name already owned by thesite_template_amcsproject. It reaches the registry asblueflyio/amcs-demo. This is the same name-collision that the false-passing publish step hides.recipe_agentdashdeclaresblueflyio/recipe-agentdash— hyphen where the estate uses underscores, and theblueflyio/vendor where Recipes usebluefly/.
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.