Skip to content

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:

  1. Modules present and enabled. composer require + drush en for any missing module the input names.
  2. Global metatag defaults. Sitewide patterns that everything inherits. Source: guide drupal/seo-geo/metatag-architecture (cascading inheritance).
  3. Per-entity defaults. node, taxonomy_term, user — the entity-level defaults. Source: guide drupal/seo-geo/core-meta-tags.
  4. Per-bundle defaults. node__<bundle> files declaring the chains and Schema.org tags specific to each bundle's field set + semantic role.
  5. Page-context defaults. 403, 404, front (if not present).
  6. 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.
  7. Metatag entity-type groups. Emit metatag.settings.yml entity_type_groups per metatag_groups_by_bundle (or the default derived from semantic_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: guide drupal/seo-geo/metatag-architecture.
  8. Pathauto patterns. Per-bundle node patterns + per-vocabulary taxonomy patterns. Source: guide drupal/seo-geo/pathauto-patterns.
  9. Sitemap settings. Per-bundle priority + changefreq (empty changefreq allowed = no crawl-frequency hint) + image inclusion. Source: guide drupal/seo-geo/xml-sitemap.
  10. Hreflang sitemap type + index. When sitemap_type: default_hreflang, also write simple_sitemap.type.default_hreflang.yml and simple_sitemap.sitemap.index.yml (referencing both the default and the hreflang variant). Skipped when sitemap_type: default. Source: guide drupal/seo-geo/metatag-multilingual.
  11. robots.txt content. robotstxt.settings.yml + remove the static robots.txt via composer scaffold exclusion. Source: guide drupal/seo-geo/robots-txt.
  12. google_tag install + config exclusion. Install the module and add google_tag to $settings['config_exclude_modules'] in settings.php so its environment-specific container config stays out of config sync. The recipe authors no google_tag config; the container is configured per environment by the operator.
  13. Breadcrumb structured data. Verify easy_breadcrumb has add_structured_data_json_ld: true. Source: guide drupal/seo-geo/breadcrumbs-structured-data.
  14. Breadcrumb customization. When declared, write easy_breadcrumb.settings.yml alternative_title_field (custom display-label field) and custom_paths (path-to-label overrides). Source: guide drupal/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_basic and drupal_cms_seo_tools, which this recipe leverages).
  • Composer-managed install (the recipe writes to composer.json and runs composer require).
  • A writable config sync directory the recipe can author into, and a writable settings.php (the recipe appends google_tag to $settings['config_exclude_modules'] when google_tag is in scope).
  • A working drush from 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.

  1. Validate input contract. Halt with contract_error on missing/inconsistent fields, on bundles that don't exist, on roles that aren't in the enum, on priority chains that name no terminator.

  2. 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 structured audit.json. See guide drupal/seo-geo/overview for what's worth inventorying.

  3. Reference scan (advisory menu). Read only reference_sources; produce a labelled menu of reference patterns. Filter to reference_selections; drop everything else. The recipe never carries unselected reference patterns into the plan. See guide drupal/seo-geo/seo-recipe-baseline for the Drupal CMS recipe shape if it's a selected source.

  4. Compose the plan. For each in-scope layer, walk the contract's rules over the audit:

  5. For each bundle in semantic_role_by_bundle: compose its per-bundle metatag default by walking priority_chains against the bundle's audited fields. Cite the rule and the field for every produced line. Add og_type_by_bundle and date_fields_by_bundle overrides where declared; halt per date_field_missing_on_bundle if a named date field is absent.
  6. For metatag_groups_by_bundle (or the role-derived default): compose metatag.settings.yml entity_type_groups; halt per unknown_bundle_in_groups_map on an unknown bundle.
  7. For each role: derive Schema.org type and sitemap priority/changefreq (empty changefreq allowed) from the input mapping. When sitemap_type: default_hreflang, also plan simple_sitemap.type.default_hreflang.yml + simple_sitemap.sitemap.index.yml.
  8. For each bundle in pathauto_patterns_by_bundle: produce the pattern file.
  9. For each vocabulary in pathauto_patterns_by_vocabulary: produce the pattern file. Vocabularies in excluded_vocabularies get no pathauto/sitemap/metatag surface; halt per unknown_vocabulary_in_excluded_list on an unknown vocabulary.
  10. For alternate_links (if any): plan metatag_custom_tags rel="alternate" entries (requires the metatag_custom_tags module).
  11. For breadcrumb (if declared): plan easy_breadcrumb.settings.yml alternative_title_field + custom_paths.
  12. For google_tag (if in scope): plan the module install and the settings.php config_exclude_modules entry. No container config is produced.
  13. For robotstxt, redirect verification, 403/404, taxonomy_term, user, home: produce per the input. Emit a structured plan.json.

  14. Resolve escalations. Walk every ambiguity and apply the escalation_policy. If any policy is halt, emit a structured escalation.json and exit non-zero. The operator updates the contract and re-runs.

  15. Apply. Walk the plan:

  16. composer require <missing modules> + drush en <missing modules> (includes google_tag when in scope).
  17. 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).
  18. For google_tag in scope: add google_tag to $settings['config_exclude_modules'] in settings.php. The module is installed (above); no container config is written or exported.
  19. drush cim.
  20. drush pathauto:aliases-generate create per added pattern.
  21. drush simple-sitemap:generate.
  22. drush cr.
  23. For robotstxt_remove_static_scaffold: true: update composer.json extra.drupal-scaffold.file-mapping to exclude [web-root]/robots.txt; delete the file.

  24. Verify. Run the verifier (next section). Non-zero exit on any failure.

  25. 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.

  1. 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.

  2. json-ld-per-bundle-role — For each bundle in schema_org scope: <head> contains at least one <script type="application/ld+json">; it parses; one of its @graph entries (or the root) carries @type matching the configured Schema.org type for the bundle's role.

  3. sitemap-covers-indexed-bundles/sitemap.xml returns 200; contains URLs from every indexed bundle; for each role mapping, at least one URL has the configured priority and changefreq.

  4. robotstxt-served-by-module/robots.txt content matches robotstxt_content from input; the static web/robots.txt is absent (composer scaffold exclusion applied).

  5. 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.

  6. pathauto-context-fix-clean — Run drush pathauto:aliases-generate update canonical_entities:node; assert no ContextException warnings in output. (This is the context_mapping.node: node fix that emerged from the cotea session.)

  7. redirect-on-alias-change — When pathauto regenerates an alias for a node whose alias changed, redirect module creates a 301 from the old alias to the new. Verify: assert redirect.settings.auto_redirect: true, default_status_code: 301, redirect_404.suppress_404: true; fetch the old alias of a known-renamed node, assert 301 → new alias.

  8. google-tag-installed-and-config-excluded — If google_tag is in scope: assert the google_tag module is enabled and that google_tag is 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.

  9. idempotency — Immediately re-run apply with the same contract and project state; assert drush cim reports 0 changes and no aliases are regenerated.

  10. metatag-groups-resolve-per-bundle — For each bundle in metatag_groups_by_bundle: the bundle's entity_type_groups entry in metatag.settings.yml matches the declared (or derived) set. Fail if a configured group isn't installed (e.g. schema_person group declared but the schema_person module is not enabled).

  11. sitemap-hreflang-when-declared — If sitemap_type: default_hreflang, assert simple_sitemap.type.default_hreflang.yml exists and simple_sitemap.sitemap.index.yml references both the default and the hreflang variant. PASS when sitemap_type: default.

  12. og-type-per-bundle — For each bundle in og_type_by_bundle: the resolved per-bundle metatag default contains the declared og_type. Fetch a sample page from each bundle; <meta property="og:type"> matches. Source: guide drupal/seo-geo/open-graph.

  13. 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: guide drupal/seo-geo/open-graph.

  14. 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.