Problem/Motivation

When a configuration import deletes a configuration entity, ConfigImporter removes it through a low-level Config object rather than through the entity. The config-entity deletion lifecycle is skipped: ConfigEntityBase::preDelete()/postDelete(), each dependent entity's ::onDependencyRemoval(), and delete hooks never run during import.

For config-to-config dependencies this is largely covered by a separate validate-time stand-in: ConfigImportSubscriber validates the change set at ConfigEvents::IMPORT_VALIDATE and blocks an import that would delete configuration another retained config entity depends on. But it can only block — it cannot reproduce ::onDependencyRemoval()'s self-healing (rewriting a dependent to a safe state and keeping it), and it is blind to anything outside the config graph.

The gap is for dependents and side effects outside the config graph that ::onDependencyRemoval() exists to protect:

  • Content entities that reference configuration. A content-to-config reference is not a config dependency, so ConfigImportSubscriber cannot see it and does not block. The config entity is deleted at the storage level, ::onDependencyRemoval() never runs, and content is left referencing something that no longer exists.
  • Non-config state managed via these hooks: state, generated files or assets, external systems.

Concrete downstream impact: the Canvas module tracks each component with a Component config entity whose ::onDependencyRemoval() switches the component to a safe fallback source when its backing code component is removed, so content still renders. Deleting the code component through the entity API triggers that self-heal and the component is kept. A drush config:import that removes the code component and its Component entity skips the lifecycle: both are deleted at the storage level, the fallback never applies, and every page that used the component white-screens (WSOD), recoverable only by editing the database. See #3573022: [upstream] Data loss: `drush config:import` deletes config (e.g. code component + component config entities) and bypasses config system data integrity checks.

Root cause: ConfigImporter::importConfig() handles a delete operation with a raw Config object and calls Config::delete(), which fires only the ConfigCrudEvent::DELETE event; the config entity is never instantiated, so ConfigEntityBase::preDelete() — which calls ConfigManager::getConfigEntitiesToChangeOnDependencyRemoval() and each dependent's ::onDependencyRemoval() — never runs. Core already runs that machinery for extension-triggered removals via ConfigManager::uninstall(); the plain config-entity delete path in ConfigImporter does not.

Steps to reproduce

The data-loss reproduction needs a content-to-config dependent, which no core config entity has; see #3573022: [upstream] Data loss: `drush config:import` deletes config (e.g. code component + component config entities) and bypasses config system data integrity checks for the end-to-end WSOD.

Core-only, the mechanism is verifiable with a Content Moderation workflow that config-depends on a node type (ContentModeration::onDependencyRemoval() removes the bundle when the node type is deleted):

  • Deleting the node type via the entity API self-heals the workflow: the bundle is removed and the workflow is retained.
  • Deleting only the node type via config import is blocked by ConfigImportSubscriber because the workflow still depends on it — no lifecycle, but no silent damage (asymmetry).
  • Deleting the node type and the workflow together imports with no lifecycle; harmless only because the workflow is being deleted anyway.

These confirm the bypass but produce no harm on their own: for config-only dependents core either blocks or the skip is moot. Harm requires a dependent outside the config graph.

Proposed resolution

Route config-entity deletions in ConfigImporter through the entity and dependency-removal machinery so preDelete() and ::onDependencyRemoval() run, mirroring what ConfigManager::uninstall() already does for extension-triggered removals. Simple (non-entity) configuration keeps using Config::delete(). Import ordering and secondary write/delete reconciliation are preserved by the importer's existing per-operation re-check.

This does not make core aware of content-to-config references (and not to be confused with config-to-content references, tracked in #2248369: [meta] Deploying configuration that depends on content); it makes a config entity's own ::onDependencyRemoval() effective during import, so a Canvas-style entity can protect its content, and entity-API deletion and config import behave consistently.

Remaining tasks

  • Add a kernel test that isolates the defect using a config entity whose ::onDependencyRemoval() has an effect outside its own configuration (for example a state write), asserting it fires during ConfigImporter::import() deletions as it does via entity delete.
  • Assess the entity-API-versus-import asymmetry described in STR: UI self-heals, config import rejects.
  • Assess backward compatibility: imports that previously deleted entities via the low-level path will now trigger the deletion lifecycle and its side effects. This is one of those bugs that people might be building on top. Can this happen in a patch or minor version? e.g. how this interacts with existing ConfigEvents::IMPORT_VALIDATE guards (including Canvas's)?
  • Fix.

User interface changes

None.

API changes

No public signatures change, but it's a pretty important behavioral change people might be relying on: deleting a config entity via ConfigImporter now runs ConfigEntityBase::preDelete()/postDelete() and dependents' ::onDependencyRemoval(), matching EntityInterface::delete().

Data model changes

None.

LLM disclosure

Drafted with Opus 4.8, tons of edits by this human afterward, typos my own.

Comments

penyaskito created an issue. See original summary.

penyaskito’s picture

I recognize this might look entirely LLM generated, but sadly isn't. Editing this HTML made me suffer.

wim leers’s picture

Priority: Normal » Major
Issue tags: +data loss

Root cause: ConfigImporter::importConfig() handles a delete operation with a raw Config object and calls Config::delete(), which fires only the ConfigCrudEvent::DELETE event; the config entity is never instantiated, so ConfigEntityBase::preDelete() […] never runs.

This seems at least a major bug?

wim leers’s picture

vidit.anjaria’s picture