Let the model express the recipe metadata it currently cannot
## Problem/Motivation
#3588509 established that an exported recipe cannot express everything a shipped recipe needs, and fixed it by **preserving** the hand-written parts across a re-export (`e41391e`). That works, and it stopped the bleeding. But it leaves the recipe as a *partly hand-maintained* artifact: the model is no longer the whole truth about the recipe, so a maintainer improving a model in the UI and re-exporting still cannot produce a complete release without knowing which lines to re-add by hand. The one thing preservation cannot cover at all is the `config/` directory, which is deleted and regenerated wholesale — #3588516.
This issue proposes the other half: let the model **express** those parts, so preservation becomes a fallback for already-published recipes rather than the mechanism.
A survey of the `drupal-eca-recipe` library (53 recipes) shows exactly four kinds of hand-edit, and every one of them is metadata or a pointer — not content:
| Hand-made piece | Where it lives now | Recipes affected |
|---|---|---|
| `recipes:` — included core recipes | `recipe.yml` | 8: `eca_lib_0001`, `0003`, `0004`, `0009`, `0020`, `0028`, `0031`, `0038`, `0042`, `0043` |
| A one-line description, distinct from the long-form documentation | `recipe.yml` `description` + `composer.json` `description` | all documented models; `getRecipe()`, `getComposer()` and `getReadme()` are all handed `$owner->getDocumentation($entity)`, which for a documented model is several screens of Markdown |
| `config.actions` other than `user.role.*` | `recipe.yml` | `eca_lib_0038` (`setComponents` on `core.entity_form_display.node.article.default`) |
| An extra config file the model does not depend on | `config/` | `eca_lib_0041` ships `core.entity_form_display.node.article.default.yml` |
## Proposed resolution
Four new keys in the `modeler_api` third-party settings envelope, with `final` accessors on `ModelOwnerBase` alongside the existing `getDocumentation()` / `getTags()` / `getChangelog()`, and `ExportRecipe` reading them instead of relying on what the previous export left behind.
| Key | Type | Consumed by | Accessor |
|---|---|---|---|
| `summary` | `label` | `recipe.yml` `description`, `composer.json` `description` | `getSummary()` / `setSummary()` |
| `recipes` | `sequence` of `string` | `recipe.yml` `recipes:` | `getRecipes()` / `setRecipes()` |
| `config_actions` | `mapping` | merged into `recipe.yml` `config.actions`, beside the generated `user.role.*` entries | `getConfigActions()` / `setConfigActions()` |
| `export_config` | `sequence` of `string` | additional config object **names** to write into `config/` | `getExportConfig()` / `setExportConfig()` |
### The one design decision worth arguing about: names, not bodies
`export_config` holds config object *names*, not their YAML. `doExport()` already reads every entry of `$dependencies['config']` out of `$this->configStorage` and writes it to `config/`; the declared names simply join that list. One code path, no YAML duplicated into a config entity, no staleness between the declared copy and the site's actual config, and the model still owns the decision about what ships.
Storing the bodies instead would put an unbounded blob of foreign configuration inside `third_party_settings`, where nothing would keep it in sync with the objects it was copied from.
### `summary` needs a fallback
`getDocumentation()` is the long form and stays as it is. When `summary` is empty, fall back to the first paragraph — or the first sentence — of `documentation`, so every existing model keeps a sensible one-line description without being touched.
### What this does to #3588516
It makes it **moot rather than fixed**, and that is the better outcome. Wiping and regenerating `config/` is *correct* once the directory is fully derived; that issue only exists because a maintainer had to put a file there which the model had no way to express. With `export_config` there is nothing hand-added left to lose.
One part of #3588516 is still worth keeping: **warn before deleting a file that is not in the generated set**. Every already-published recipe stays in the old world until its model is updated, and the loss is silent today.
### `mergeRecipe()` gets smaller
Its preservation logic exists only because these values had nowhere else to live. Once the model expresses them, `mergeRecipe()` needs to preserve only what a *human* legitimately owns in a repository the exporter does not: nothing in `recipe.yml`, as far as this survey found. I would keep the preservation for one release as a migration aid rather than removing it in the same change.
## Schema is not optional here
`modeler_api.schema.yml` must gain all four keys **in the same change** as the code. A `third_party_settings` key with no schema entry raises `SchemaIncompleteException` from the config *save* event, which core dispatches **after** writing the data:
```php
$this->storage->write($this->name, $this->data);
// ...
$this->eventDispatcher->dispatch(new ConfigCrudEvent($this), $event_name);
```
The write has already happened, so the raw value lands on disk while `postSave()`, entity update hooks and — when this runs inside a recipe — the rest of the recipe's configuration step are all abandoned, and the command still reports success. On a development site with the current core scaffold `LenientConfigSchemaChecker` only logs, but with `ConfigSchemaChecker` it throws, and on production nothing checks at all. Shipping keys without schema would produce exactly the silent half-application this whole effort exists to remove.
The envelope already carries `annotations`, `colors` and `swimlanes`, so recipe metadata is not a new category of thing in there.
## Remaining tasks
- [ ] Add the four keys to `model_settings.third_party.modeler_api` in `config/schema/modeler_api.schema.yml`
- [ ] Add the four accessor pairs to `ModelOwnerInterface` and `ModelOwnerBase`
- [ ] Read them in `getRecipe()`, `getComposer()`, `getReadme()` and the `config/` write in `doExport()`
- [ ] `summary` falls back to the leading paragraph of `documentation`
- [ ] Warn when `doExport()` is about to delete a `config/` file that is not in the generated set
- [ ] Test coverage: export twice and assert every declared piece survives without a hand edit
- [ ] Surface the new settings in the model's edit UI, so a maintainer can set them where they set the documentation
## Related
- #3588509 — preserved these values; this expresses them
- #3588516 — becomes unnecessary for models that adopt `export_config`
- `drupal/documentation/eca#111` — the ECA Guide's "recipe as the single source of truth" issue, which this is a prerequisite for
issue
GitLab AI Context
Project: project/modeler_api
Instance: https://git.drupalcode.org
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://git.drupalcode.org/project/modeler_api/-/raw/1.1.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/modeler_api
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD