Drupal seo foundation
Goal
Deliver a complete on-page SEO foundation for a Drupal site — per-bundle metatag defaults (title, description, og:, twitter:, canonical), per-bundle Schema.org JSON-LD, an XML sitemap tuned per content priority, pathauto URL patterns for nodes and taxonomy terms, redirect-on-alias-change behavior, google_tag installed with its config excluded from sync, robots.txt managed as config, and 403/404/user/taxonomy_term defaults — composed from the target site's own field inventory, deterministically and idempotently, with the operator's policy supplied once via a typed input contract.
The recipe runs fully unattended. Decisions are encoded in the input contract; when the contract doesn't cover a situation, the recipe halts with a typed reason rather than guessing.
Opinion
Adapt to the target's actual fields, never bolt on. Token chains are composed by walking a priority list against the audited per-bundle field set; first present field wins; if none, the recipe omits the override and lets the parent default fall through. The recipe does not assume a reference site's fields exist; it does not reference fields that the bundle doesn't carry. This stance is invariant — it is expressed by the Input-contract priority_chains walk and the State-awareness contract, not by a defaulting fallback.
Per-bundle defaults, not one-size-fits-all. Each bundle gets its own metatag.metatag_defaults.<entity>__<bundle>.yml declaring the chains relevant to its field set plus the Schema.org type that fits its semantic role. Source: play drupal/best-practices/camoa/metatag-per-bundle.
Schema.org type per semantic role, not auto-derived. Article for content pieces, Product for products, Service for solutions, WebPage for pages and landings, LocalBusiness/Organization on home. The mapping is declared by the operator in the input contract; the recipe does not infer it. Source: guide drupal/seo-geo/schema-types-reference.
No hardcoded JSON-LD without explicit policy. Tags like schema_article_publisher, schema_article_author, schema_product_brand carry hardcoded JSON blobs. The recipe never authors them unless the input contract names each blob and supplies (or accepts) its content. Source: guide drupal/seo-geo/schema-metatag-setup.
Sitemap priority and changefreq per editorial role. Uniform 0.9 / daily across every bundle is a smell — it signals poor quality to search engines. Priority and changefreq are derived from the semantic role declared in the input. Source: guide drupal/seo-geo/xml-sitemap.
Pathauto: one pattern per bundle, taxonomy terms are first-class. Every content type reachable on the public site gets a deterministic pattern. taxonomy_term is enabled in pathauto.settings.yml and individual vocabularies get their own patterns when the input contract names them. Source: guide drupal/seo-geo/pathauto-patterns.
robots.txt is content, not scaffold. Use the robotstxt module so robots.txt is editable via config without redeploying core scaffold. The static robots.txt shipped by Drupal core scaffold is removed via the composer drupal-scaffold.file-mapping exclusion. Source: guide drupal/seo-geo/robots-txt.
google_tag is installed, never config-managed. A GA4 / GTM container is environment-specific, so its config must not live in tracked config sync. When google_tag is in scope the recipe installs the module and adds google_tag to $settings['config_exclude_modules'] in settings.php; it authors no container config. The container is set per environment by the operator, outside config management — the recipe never fabricates or exports a GA4 / GTM ID.
The recipe never makes a judgment call. Every situation is either covered by the input contract (proceed) or it isn't (halt with a typed reason). It doesn't infer defaults; it doesn't have a "smart" fallback. This stance is invariant — it is expressed by the escalation policy in the Input contract and the adversarial Verifier, not by a play.
The chain
The layers are configured in this order, and the order is load-bearing:
- Modules present and enabled.
composer require+drush enfor any missing module the input names. - Global metatag defaults. Sitewide patterns that everything inherits. Source: guide
drupal/seo-geo/metatag-architecture(cascading inheritance). - Per-entity defaults.
node,taxonomy_term,user— the entity-level defaults. Source: guidedrupal/seo-geo/core-meta-tags. - Per-bundle defaults.
node__<bundle>files declaring the chains and Schema.org tags specific to each bundle's field set + semantic role. - Page-context defaults.
403,404,front(if not present). - Schema.org / JSON-LD per bundle. Schema_metatag tags inside the per-bundle defaults from step 4. Source: guide
drupal/seo-geo/schema-metatag-setup. - Metatag entity-type groups. Emit
metatag.settings.ymlentity_type_groupspermetatag_groups_by_bundle(or the default derived fromsemantic_role_by_bundle) so only the relevant tag groups (basic, open_graph, schema_article, schema_person, …) surface on each bundle's edit form. Sequenced after the per-bundle defaults exist. Source: guidedrupal/seo-geo/metatag-architecture. - Pathauto patterns. Per-bundle node patterns + per-vocabulary taxonomy patterns. Source: guide
drupal/seo-geo/pathauto-patterns. - Sitemap settings. Per-bundle priority + changefreq (empty
changefreqallowed = no crawl-frequency hint) + image inclusion. Source: guidedrupal/seo-geo/xml-sitemap. - Hreflang sitemap type + index. When
sitemap_type: default_hreflang, also writesimple_sitemap.type.default_hreflang.ymlandsimple_sitemap.sitemap.index.yml(referencing both the default and the hreflang variant). Skipped whensitemap_type: default. Source: guidedrupal/seo-geo/metatag-multilingual. - robots.txt content.
robotstxt.settings.yml+ remove the staticrobots.txtvia composer scaffold exclusion. Source: guidedrupal/seo-geo/robots-txt. - google_tag install + config exclusion. Install the module and add
google_tagto$settings['config_exclude_modules']insettings.phpso its environment-specific container config stays out of config sync. The recipe authors nogoogle_tagconfig; the container is configured per environment by the operator. - Breadcrumb structured data. Verify
easy_breadcrumbhasadd_structured_data_json_ld: true. Source: guidedrupal/seo-geo/breadcrumbs-structured-data. - Breadcrumb customization. When declared, write
easy_breadcrumb.settings.ymlalternative_title_field(custom display-label field) andcustom_paths(path-to-label overrides). Source: guidedrupal/seo-geo/breadcrumbs-structured-data.
The bundle defaults cannot exist until the per-entity defaults exist; the entity-type groups reference the per-bundle defaults; pathauto patterns cannot generate aliases until the patterns are imported; the sitemap cannot reference URLs until aliases exist, and the hreflang index cannot reference a sitemap type that hasn't been written — hence the order.
Preconditions
- Drupal 11.3+ (required by
drupal_cms_seo_basicanddrupal_cms_seo_tools, which this recipe leverages). - Composer-managed install (the recipe writes to
composer.jsonand runscomposer require). - A writable config sync directory the recipe can author into, and a writable
settings.php(the recipe appendsgoogle_tagto$settings['config_exclude_modules']whengoogle_tagis in scope). - A working
drushfrom the project root (or via ddev/docker wrapper). - A typed input contract supplied by the caller (see below).
Input contract
Generic schema, source-agnostic, supplied by the caller. No field in this contract has a runtime default; missing fields fail Phase 0 validation.
mode: dry-run | apply
project_root: string # absolute path to the Drupal site
config_sync_dir: string # relative to project_root
layers_in_scope: # opt-in per layer; absent = false
metatag: true
schema_org: true
sitemap: true
pathauto: true
redirect: true # verify auto-redirect-on-alias-change behavior
robotstxt: true
google_tag: false # install module + exclude its config (settings.php)
non_content_defaults: false # 403/404/taxonomy_term/user metatag defaults
semantic_role_by_bundle: # operator-chosen; no auto-derivation
# role enum: content_article | product | service | web_page | landing_page |
# person | organization | none
# person → author/profile-style bundles (Schema.org Person)
# organization → tenant/agency/brand bundles (Schema.org Organization)
<bundle>: <role>
og_type_by_bundle: # og:type per bundle; bundles omitted inherit
# the node-level default. Source: guide drupal/seo-geo/open-graph
<bundle>: <og_type> # e.g. article | website | product
metatag_groups_by_bundle: # metatag.settings.yml entity_type_groups —
# which tag groups surface on each bundle's edit form. DEFAULT IS DERIVED from
# semantic_role_by_bundle (content_article → basic + open_graph + schema_article;
# product → … + schema_product; person → … + schema_person; etc.); declare a
# bundle here only to OVERRIDE the derived set.
<bundle>: [<group>] # e.g. [basic, open_graph, schema_article]
date_fields_by_bundle: # drives article_published_time /
# article_modified_time tokens. Bundles omitted get no date meta. Each bundle's
# actual date fields differ. Source: guide drupal/seo-geo/open-graph
<bundle>:
published: <field_machine_name> # e.g. field_publication_date | created
modified: <field_machine_name> # e.g. field_updated_date | changed
alternate_links: [] # metatag_custom_tags rel="alternate" links
# (RSS, AMP, …). Empty = none. Requires the metatag_custom_tags module when used.
# - {rel: alternate, type: <mime_type>, href: <url>}
priority_chains: # walked top-down against audited fields;
og_image: # first present field wins; if none, omit
- <field_machine_name>
meta_title:
- <field_machine_name>
- node:title # always-present terminator
meta_description:
- <field_machine_name>
reference_sources: [string] # absolute paths to reference projects;
# Phase 2 reads ONLY these
reference_selections: [string] # explicit menu items pre-approved;
# unselected items are dropped silently
home_node: # single source of truth; recipe does not infer
nid: integer
bundle: string
og_image_strategy: omit | static_url
og_image_static_url: string|null
og_type: string|null # og:type on the front-page default; often
# differs from the node-level default; null = inherit
sitemap_type: default # default | default_hreflang
# default_hreflang also emits simple_sitemap.type.default_hreflang.yml +
# simple_sitemap.sitemap.index.yml. Source: guide drupal/seo-geo/metatag-multilingual
sitemap_priority_by_role: # role → (priority, changefreq)
# changefreq enum: always | hourly | daily | weekly | monthly | yearly | never | ""
# "" = emit no changefreq hint (a valid, intentional "don't signal crawlers" stance)
content_article: {priority: <0.0-1.0>, changefreq: <enum|"">}
product: {priority: <0.0-1.0>, changefreq: <enum|"">}
service: {priority: <0.0-1.0>, changefreq: <enum|"">}
web_page: {priority: <0.0-1.0>, changefreq: <enum|"">}
landing_page: {priority: <0.0-1.0>, changefreq: <enum|"">}
person: {priority: <0.0-1.0>, changefreq: <enum|"">}
organization: {priority: <0.0-1.0>, changefreq: <enum|"">}
pathauto_patterns_by_bundle: # bundles omitted get no pattern
<bundle>: <pattern_string>
pathauto_patterns_by_vocabulary: # vocabularies omitted get no pattern
<vocabulary>: <pattern_string>
excluded_vocabularies: [] # vocabularies that exist as taxonomies but are
# NOT part of the public SEO surface: no pathauto pattern, no sitemap setting, no
# metatag default is authored for them. Declared opt-out so the operator need not
# name every vocabulary just to silently exclude it.
# - <vocabulary>
redirect: # verified when redirect in layers_in_scope
auto_redirect: bool # expected true
default_status_code: integer # expected 301
suppress_404: bool # redirect_404.suppress_404; expected true
robotstxt_content: string # full robots.txt body
robotstxt_remove_static_scaffold: bool # remove web/robots.txt via composer scaffold
# google_tag has no contract fields — when in scope the recipe installs the module
# and excludes its config via settings.php (config_exclude_modules). The GA4 / GTM
# container is environment-specific and set by the operator per environment.
breadcrumb: # easy_breadcrumb.settings.yml customization
alternative_title_field: string|null # custom field for the breadcrumb display label
# (e.g. field_breadcrumb_title); null = default
custom_paths: {} # path → label overrides, operator-facing as a map;
# the recipe serializes it to easy_breadcrumb's native `PATH::LABEL` newline-delimited
# string form. Empty = none.
# <path>: <label>
escalation_policy: # per ambiguity class; default = halt
no_image_field_for_bundle: halt | omit_og_image | use_site_default
no_description_field_for_bundle: halt | use_fallback | use_static
url_convention_change_on_live_aliases: halt | apply_with_redirects | skip
hardcoded_schema_org_blob: halt | apply | skip
conflict_with_existing_config: halt | overwrite | skip
unknown_bundle_in_groups_map: halt | apply | drop # bundle in metatag_groups_by_bundle doesn't exist
unknown_vocabulary_in_excluded_list: halt | apply | drop # vocab in excluded_vocabularies doesn't exist
date_field_missing_on_bundle: halt | omit_date_meta | use_node_created_changed # date_fields_by_bundle names an absent field
new_entity_field_required: halt # always halts; data-model changes out of scope
content_seed_required: halt # always halts; content authoring out of scope
Sequence
If mode: dry-run, perform all reads and derivations but emit a preview instead of writing.
-
Validate input contract. Halt with
contract_erroron missing/inconsistent fields, on bundles that don't exist, on roles that aren't in the enum, on priority chains that name no terminator. -
Audit target project state. Inventory composer.json modules,
core.extension.yml, per-bundle field lists, existing sync configs matching the in-scope layers, taxonomies, home node. Read-only. Emit a structuredaudit.json. See guidedrupal/seo-geo/overviewfor what's worth inventorying. -
Reference scan (advisory menu). Read only
reference_sources; produce a labelled menu of reference patterns. Filter toreference_selections; drop everything else. The recipe never carries unselected reference patterns into the plan. See guidedrupal/seo-geo/seo-recipe-baselinefor the Drupal CMS recipe shape if it's a selected source. -
Compose the plan. For each in-scope layer, walk the contract's rules over the audit:
- For each bundle in
semantic_role_by_bundle: compose its per-bundle metatag default by walkingpriority_chainsagainst the bundle's audited fields. Cite the rule and the field for every produced line. Addog_type_by_bundleanddate_fields_by_bundleoverrides where declared; halt perdate_field_missing_on_bundleif a named date field is absent. - For
metatag_groups_by_bundle(or the role-derived default): composemetatag.settings.ymlentity_type_groups; halt perunknown_bundle_in_groups_mapon an unknown bundle. - For each role: derive Schema.org type and sitemap priority/changefreq (empty
changefreqallowed) from the input mapping. Whensitemap_type: default_hreflang, also plansimple_sitemap.type.default_hreflang.yml+simple_sitemap.sitemap.index.yml. - For each bundle in
pathauto_patterns_by_bundle: produce the pattern file. - For each vocabulary in
pathauto_patterns_by_vocabulary: produce the pattern file. Vocabularies inexcluded_vocabulariesget no pathauto/sitemap/metatag surface; halt perunknown_vocabulary_in_excluded_liston an unknown vocabulary. - For
alternate_links(if any): planmetatag_custom_tagsrel="alternate"entries (requires themetatag_custom_tagsmodule). - For
breadcrumb(if declared): planeasy_breadcrumb.settings.ymlalternative_title_field+custom_paths. - For
google_tag(if in scope): plan the module install and thesettings.phpconfig_exclude_modulesentry. No container config is produced. -
For robotstxt, redirect verification, 403/404, taxonomy_term, user, home: produce per the input. Emit a structured
plan.json. -
Resolve escalations. Walk every ambiguity and apply the escalation_policy. If any policy is
halt, emit a structuredescalation.jsonand exit non-zero. The operator updates the contract and re-runs. -
Apply. Walk the plan:
composer require <missing modules>+drush en <missing modules>(includesgoogle_tagwhen in scope).- For each emitted config file: absent → write; present + matching → no-op + log; present + differing → halt with
conflict(already escalated in step 5; reaching this means a TOCTOU change). - For
google_tagin scope: addgoogle_tagto$settings['config_exclude_modules']insettings.php. The module is installed (above); no container config is written or exported. drush cim.drush pathauto:aliases-generate createper added pattern.drush simple-sitemap:generate.drush cr.-
For
robotstxt_remove_static_scaffold: true: updatecomposer.jsonextra.drupal-scaffold.file-mappingto exclude[web-root]/robots.txt; delete the file. -
Verify. Run the verifier (next section). Non-zero exit on any failure.
-
Emit summary. Change log: files written, modules installed, aliases regenerated, verifier results.
Data flow
input: contract (operator-supplied, validated up front)
reads project state:
composer.json + core.extension.yml
field.field.<entity>.<bundle>.<field>.yml (per bundle field inventory)
taxonomy.vocabulary.*.yml
existing metatag.metatag_defaults.* / simple_sitemap.* / pathauto.* /
redirect.* / robotstxt.*
settings.php (config_exclude_modules — for the google_tag exclusion)
system.site.yml (home node)
applies opinion:
play drupal/best-practices/camoa/metatag-per-bundle (per-bundle defaults)
inline invariant stances: adapt-to-project-fields · schema-type-per-role ·
no-hardcoded-json-ld-without-policy · sitemap-priority-per-role ·
one-pathauto-pattern-per-bundle · robotstxt-as-content ·
google-tag-install-only · halt-on-ambiguity ·
headless-via-input-contract · verifier-runs-adversarially
references atomic detail (guides):
drupal/seo-geo/{ overview, seo-recipe-baseline, metatag-architecture,
core-meta-tags, open-graph, twitter-cards, canonical-urls,
metatag-multilingual, structured-data-decision, schema-metatag-setup,
schema-types-reference, pathauto-patterns, redirect-management,
xml-sitemap, robots-txt, breadcrumbs-structured-data, testing-validation }
emits (in chain order):
composer.json updates (modules + scaffold exclusion)
metatag.metatag_defaults.global.yml (update)
metatag.metatag_defaults.node.yml (update; chains pruned to audit)
metatag.metatag_defaults.taxonomy_term.yml (create, if non_content_defaults)
metatag.metatag_defaults.user.yml (create, if non_content_defaults)
metatag.metatag_defaults.403.yml (create, if non_content_defaults)
metatag.metatag_defaults.404.yml (create, if non_content_defaults)
metatag.metatag_defaults.node__<bundle>.yml (per bundle in semantic_role_by_bundle)
metatag.settings.yml (entity_type_groups per metatag_groups_by_bundle / derived)
pathauto.settings.yml (taxonomy_term enabled if needed)
pathauto.pattern.<bundle>.yml (per bundle in input)
pathauto.pattern.taxonomy_<vocabulary>.yml (per vocab in input)
simple_sitemap.bundle_settings.default.node.<bundle>.yml (per role mapping; changefreq may be '')
simple_sitemap.bundle_settings.default.taxonomy_term.<vocab>.yml (per input; excluded_vocabularies omitted)
simple_sitemap.type.default_hreflang.yml (if sitemap_type: default_hreflang)
simple_sitemap.sitemap.index.yml (if sitemap_type: default_hreflang)
robotstxt.settings.yml (from input)
easy_breadcrumb.settings.yml (alternative_title_field + custom_paths, if declared)
settings.php (config_exclude_modules += google_tag, if in scope)
core.extension.yml (newly-enabled modules)
State-awareness contract
For every emitted config object: absent → create; present and matching the derived spec → skip with no-op; present and differing → conflict, do not overwrite unless escalation_policy.conflict_with_existing_config: overwrite. A field listed in priority_chains that does not exist on any in-scope bundle → log warning, drop from chain. A bundle in semantic_role_by_bundle whose role implies fields it lacks (e.g. content_article without field_seo_title) → halt with contract_error so the operator either adjusts the role or the chain.
Idempotent: running the recipe twice on identical input and identical project state produces no changes on the second run, including no alias regeneration if no new pattern was added.
Verifier
After apply, the recipe runs each check and emits PASS / FAIL. Failures exit non-zero with actual-vs-expected.
-
metatag-resolves-per-bundle — For each bundle with ≥1 published node: fetch a sample page; every metatag tag in the bundle's emitted default is present in
<head>and its token chain resolved to non-empty content. -
json-ld-per-bundle-role — For each bundle in
schema_orgscope:<head>contains at least one<script type="application/ld+json">; it parses; one of its@graphentries (or the root) carries@typematching the configured Schema.org type for the bundle's role. -
sitemap-covers-indexed-bundles —
/sitemap.xmlreturns 200; contains URLs from every indexed bundle; for each role mapping, at least one URL has the configuredpriorityandchangefreq. -
robotstxt-served-by-module —
/robots.txtcontent matchesrobotstxt_contentfrom input; the staticweb/robots.txtis absent (composer scaffold exclusion applied). -
pathauto-pattern-produces-matching-alias — For each pathauto pattern in input, the most recently created entity of that bundle/vocabulary has an alias matching the pattern.
-
pathauto-context-fix-clean — Run
drush pathauto:aliases-generate update canonical_entities:node; assert noContextExceptionwarnings in output. (This is thecontext_mapping.node: nodefix that emerged from the cotea session.) -
redirect-on-alias-change — When
pathautoregenerates an alias for a node whose alias changed,redirectmodule creates a 301 from the old alias to the new. Verify: assertredirect.settings.auto_redirect: true,default_status_code: 301,redirect_404.suppress_404: true; fetch the old alias of a known-renamed node, assert301→ new alias. -
google-tag-installed-and-config-excluded — If
google_tagis in scope: assert thegoogle_tagmodule is enabled and thatgoogle_tagis listed in$settings['config_exclude_modules'](settings.php), so its environment-specific container config is not under config management. The recipe authors no container; per-environment container configuration is the operator's, out of scope. -
idempotency — Immediately re-run
applywith the same contract and project state; assertdrush cimreports 0 changes and no aliases are regenerated. -
metatag-groups-resolve-per-bundle — For each bundle in
metatag_groups_by_bundle: the bundle'sentity_type_groupsentry inmetatag.settings.ymlmatches the declared (or derived) set. Fail if a configured group isn't installed (e.g.schema_persongroup declared but theschema_personmodule is not enabled). -
sitemap-hreflang-when-declared — If
sitemap_type: default_hreflang, assertsimple_sitemap.type.default_hreflang.ymlexists andsimple_sitemap.sitemap.index.ymlreferences both the default and the hreflang variant. PASS whensitemap_type: default. -
og-type-per-bundle — For each bundle in
og_type_by_bundle: the resolved per-bundle metatag default contains the declaredog_type. Fetch a sample page from each bundle;<meta property="og:type">matches. Source: guidedrupal/seo-geo/open-graph. -
date-meta-resolves-when-declared — For each bundle in
date_fields_by_bundle: a sample page has non-empty<meta property="article:published_time">and<meta property="article:modified_time">resolving from the declared fields. Source: guidedrupal/seo-geo/open-graph. -
excluded-vocabularies-absent-from-surface — For each vocabulary in
excluded_vocabularies: no pathauto pattern, no sitemap setting, and no metatag default exists for it. Idempotent: re-running the recipe does not create them.
The verifier is recipe-runnable, not operator-driven. Failures exit non-zero.
References
Guides
| Guide | Status | Used for |
|---|---|---|
drupal/seo-geo/overview |
✅ exists | Module landscape; entry point |
drupal/seo-geo/seo-recipe-baseline |
✅ exists | Drupal CMS recipe selection in Phase 3 |
drupal/seo-geo/metatag-architecture |
✅ exists | Cascade order (global → entity → bundle) the chain follows |
drupal/seo-geo/core-meta-tags |
✅ exists | Foundation tags written in steps 2–3 |
drupal/seo-geo/open-graph |
✅ exists | og:* tags in per-bundle defaults; og:type-per-bundle (og_type_by_bundle); article:published_time/modified_time (date_fields_by_bundle) |
drupal/seo-geo/twitter-cards |
✅ exists | twitter:* tags |
drupal/seo-geo/canonical-urls |
✅ exists | Canonical URL configuration |
drupal/seo-geo/metatag-multilingual |
✅ exists | hreflang for multilingual sites (when in scope) |
drupal/seo-geo/structured-data-decision |
✅ exists | Schema_metatag vs Schema.org Blueprints choice |
drupal/seo-geo/schema-metatag-setup |
✅ exists | Per-bundle JSON-LD via schema_metatag |
drupal/seo-geo/schema-types-reference |
✅ exists | Bundle role → Schema.org type mapping reference |
drupal/seo-geo/pathauto-patterns |
✅ exists | Pattern syntax + token reference |
drupal/seo-geo/redirect-management |
✅ exists | Redirect module behavior on alias changes |
drupal/seo-geo/xml-sitemap |
✅ exists | simple_sitemap config + per-bundle tuning |
drupal/seo-geo/robots-txt |
✅ exists | robotstxt module vs core scaffold |
drupal/seo-geo/breadcrumbs-structured-data |
✅ exists | BreadcrumbList JSON-LD via easy_breadcrumb |
drupal/seo-geo/testing-validation |
✅ exists | Verifier reference: structured data testing |
Plays
| Play | Status | Notes |
|---|---|---|
drupal/best-practices/camoa/metatag-per-bundle |
✅ exists | The per-bundle defaults principle |
The remaining SEO stances (adapt-to-project-fields, schema-type-per-role, no-hardcoded-json-ld, sitemap-priority-per-role, one-pathauto-pattern-per-bundle, robotstxt-as-content, google-tag-install-only) are expressed inline in the Opinion section and cite the relevant drupal/seo-geo/* guide for their mechanics. The cross-cutting invariants (headless-via-input-contract, halt-on-ambiguity, verifier-runs-adversarially) are expressed structurally by the Input contract, escalation policy, and Verifier sections rather than by a play citation.
Drupal recipes invoked
The recipe optionally invokes drupal_cms_seo_basic, drupal_cms_seo_tools, or both (controlled via input). When invoked, the agentic recipe still owns the per-bundle adaptation and verifier; the Drupal core recipes only install the module set + carry their own opinionated config that the agentic recipe then overlays.