ADR 193 · Accepted
ADR-193: A draft element does not turn a connected page into a mixed one
Context
create_content_element_draft (ADR-146) writes the element in the language it is asked for and never sets a translation parent. ADR-146 recorded that as deliberate and named the cost among its consequences: the description points a model at create_translation_draft, and "a model that ignores both produces an element an editor has to clean up".
On the Netresearch demo installation a model did. It created elements in language 1 on a page that also holds connected translations in language 1, and the page module reports Inconsistent content detected in language "…" on that page.
That warning has an exact condition. It was read in TYPO3CMSBackendViewBackendLayoutContentFetcher::getTranslationData() of typo3/cms-backend 14.3.7, the version this repository resolved on the day of this record:
The default language is never judged; the method returns early for it.
For any other language it walks the tt_content rows of that language on the page. Deleted rows are out, hidden rows are in, the workspace restriction of the acting user applies, and rows for "all languages" (sys_language_uid = -1) are skipped.
A row with l18n_parent = 0 marks the language as holding standalone content, a row with l18n_parent > 0 as holding translations. When both marks are set the mode is mixed and the warning is queued.
The warning is suppressed when DrawingConfiguration::getAllowInconsistentLanguageHandling() is true, which DrawingConfiguration::create() reads from the page TSconfig mod.web_layout.allowInconsistentLanguageHandling with a plain cast to bool.
The tool only ever adds rows of the first kind. So one fact decides whether a call produces the warning: whether the page already holds a row of the second kind in that language.
Decision
create_content_element_draft refuses a call with language greater than zero when the target page already holds at least one tt_content row with that sys_language_uid and l18n_parent > 0. Deleted rows do not count, hidden rows do — as core counts them, and every draft this extension writes is hidden.
The refusal is resolved in the tool's plan(), so it is reached before anything is written, and previewCall() and the viewer gate answer with it as they do for every other refusal of this tool. It comes after the neutral Page not found or not permitted., because it names the page: a user who may not edit the page learns nothing about what is on it.
The message names the page uid and the language uid and says what to do instead: create the element in the default language (0), translate it with create_translation_draft or the translation tools of the CMS, and tell the editor that the translation is a separate step. The tool description and the language argument description say the same on the wire, so a model can choose before it calls.
What stays allowed, and why
The default language. Unchanged; it is never queried, as core never judges it.
Free mode. A page that holds no row in the language, or only standalone rows, takes another standalone one. A page that is free-mode throughout is consistent, core raises nothing on it, and ADR-146's reasoning for creating without a translation parent still holds there.
The integrator's opt-in. When the page TSconfig sets mod.web_layout.allowInconsistentLanguageHandling, the tool does not refuse. It is the switch that silences the same warning in core, read the same way (rootline-merged through BackendUtility::getPagesTSconfig(), cast to bool), so the same switch lifts the refusal. The row check itself is wider than core's, as the two sections below state. The TSconfig is only read once a connected row was found.
Workspaces
No workspace code is added. Every builtin writer refuses outside the live workspace (WritesThroughDataHandlerTrait), so the live workspace is the only one a write happens in.
The query carries the deleted restriction and no workspace restriction, which is the one place it is wider than core's: a connected translation that exists only as another workspace's draft counts. That is deliberate. The draft sits on the page and becomes a live connected translation when that workspace is published, at which point a free element created in between would mix the page.
What this does not prevent
The opposite order. A page that holds only free elements in a language becomes mixed the moment a connected translation is added to it — through create_translation_draft, through the backend's Translate wizard, or through an extension that translates automatically. This record guards one writer against one direction; it does not make mixed pages impossible, and it does not repair a page that already is one.
Nor does it judge the page as a whole. A page that is already mixed is refused like a connected one, because it holds a connected row; nothing is reported about rows the tool did not write.
Core's query is open to listeners of ModifyDatabaseQueryForContentEvent; the tool's is not. An installation whose listener hides rows from the page module can therefore be refused for a connected row its editors do not see there.
Consequences
✓ This tool can no longer be the cause of the page module's inconsistency warning on a page that translates in connected mode.
✓ The refusal names the way that works — the default language, then a translation — instead of only declining.
✕ One more query per call with a non-default language, and a TSconfig resolution when that query finds something.
✕ An editor who wants a standalone element beside connected translations, and whose integrator has not set the TSconfig switch, cannot get it from this tool. The backend still lets them create it by hand.
✕ The opposite order stays open, as stated above.
Revisit when
create_translation_draft or another writer is found to produce mixed pages from the other direction in practice, or core changes the condition ContentFetcher::getTranslationData() applies — the guard mirrors that method and has to follow it.