# Language Disable & Delete — Design

- **Slug:** `language-disable-delete`
- **Date:** 2026-08-21
- **Plugin:** `international-press-zone`
- **Base:** `origin/master` @ `f01fc5586` (all line references below are from this tree)
- **Status:** design, ready for plan

## 1. Problem

`LanguageManager::deleteLanguage()` (`includes/Core/LanguageManager.php:488`) hard-blocks whenever
the language owns any row in `ipz_translations`, raising `InvalidArgumentException` with "Cannot
delete language with %d existing translations. Delete translations first." (line 522). There is no
path forward: an operator who wants a language gone must first destroy every translation by hand,
and there is no way to take a language off the site while keeping its content recoverable.

## 2. Desired behaviour

Deleting a language that owns translations opens a two-branch decision instead of an error:

- **Keep translations disabled** — the language row survives with a disabled flag. Nothing renders
  it, nothing queries it, no row in `ipz_translations` / `ipz_string_translations` / `wp_posts` is
  touched. It moves to a **Disabled** section in the admin with a **Restore** action that brings
  every translation back by flipping one flag.
- **Delete all** — permanent, unrecoverable removal of the language's plugin rows *and* the
  translated WordPress posts.

"Disabled" is a label on the language, not an operation on content. No copying, exporting, moving,
or status-rewriting of translation rows or posts ever happens.

A language with **zero** translations still deletes outright, unchanged.

### 2.1 Disabled vs. Inactive — keep these distinguishable

The plugin already ships an Active/Inactive toggle (`activate` / `deactivate` routes, `is_active`).
"Disabled" is a *different* state and the UI must never let the two blur:

| | Inactive (existing) | Disabled (new) |
|---|---|---|
| how it is reached | the row's toggle switch | choosing "Keep translations disabled" while deleting |
| where the row lives | main list, toggle off | own **Disabled (N)** section, out of the main list |
| how it is undone | flip the toggle | **Restore** button |
| meaning | temporarily off, still managed | deleted from the site, content kept |

## 3. Architecture

### 3.1 State model

Disabled is a **third state**, distinct from `is_active`, so a deliberately deactivated language is
never confused with a deleted one.

Invariant, owned by `LanguageManager` and by nothing else:

```
is_disabled = 1  =>  is_active = 0
```

Because disabling forces `is_active = 0`, every existing `WHERE is_active = 1` read path is
*already correct without modification*. The audit surface is therefore limited to the read paths
that do **not** filter on `is_active` (§3.4) — that is where "nothing will query it" is won or lost.

`restoreLanguage()` sets `is_disabled = 0, is_active = 1`: the stated model is "re-enable the
language → the translations are restored", so restore returns the language to live service in one
action rather than leaving a second toggle to find.

### 3.2 Schema

`ipz_languages` gains two columns:

| Column | Type | Default | Notes |
|---|---|---|---|
| `is_disabled` | `TINYINT(1)` | `0` | `NOT NULL` |
| `disabled_at` | `DATETIME` | `NULL` | set on disable, cleared on restore |

plus `KEY idx_disabled (is_disabled)`.

Delivered in **both** places or fresh installs diverge from upgrades:

- `includes/Core/Database.php` — the languages-table DDL (fresh-install source of truth).
- `includes/Migrations/Migration20260821AddLanguageDisabledState.php` — new class in the existing
  auto-discovered namespace `InternationalPressZone\Migrations`, following the dated convention of
  `Migration20260804MigrateDebugMode`. `up()` adds the columns + index guarded by an
  information_schema existence check (idempotent); `rollback()` drops them.

`ipz_translations`, `ipz_string_translations` and `wp_posts` are **not** altered.

**This is the expand step of expand–migrate–contract, and it is the only step.** Both columns are
additive with safe defaults, no data backfill is required, and no existing column or table is
narrowed, renamed, or dropped. There is therefore **no contract migration in this work** — do not
author one and do not treat its absence as an omission; `rollback()` dropping the two columns is the
entire reverse path. Per `CLAUDE.md` line 32, dormant schema preparation is explicitly never flagged,
so the migration and the two columns ship **unflagged** even while the purge route (§6) is flag-gated
OFF.

### 3.3 `LanguageManager` seam

```
disableLanguage(int $id): void
  - refuses when the language is default (existing default guard) or already disabled
  - sets is_disabled = 1, is_active = 0, disabled_at = now, updated_at = now
  - clears language cache; fires do_action('ipz_language_disabled', $code)
  - touches no translation or post row

restoreLanguage(int $id): void
  - refuses when the language is not disabled
  - sets is_disabled = 0, is_active = 1, disabled_at = NULL, updated_at = now
  - clears language cache; fires do_action('ipz_language_restored', $code)

deleteLanguage(int $id): bool            // existing signature preserved
  - default-language guard unchanged
  - zero translation rows -> hard delete, as today
  - otherwise -> throws LanguageHasTranslationsException carrying the impact counts,
    so callers must route through disable or purge explicitly

getDeletionImpact(string $code): array   // shape in §3.6
purgeLanguage(int $id): array            // §3.7; returns
                                         //   {mode:'inline', purged:int, total:int}  when total <= one batch
                                         //   {mode:'job', job_id:int, total:int}     otherwise
getDisabledLanguages(): Language[]
```

`deleteLanguage()` keeping its refusal — as a typed exception rather than a generic
`InvalidArgumentException` — is deliberate: nothing may destroy content as a side effect of the word
"delete". Destruction happens only through `purgeLanguage()`.

### 3.4 Read-path audit — the correctness core

Every language read falls in exactly one bucket. The plan must classify each site below and leave
none unclassified. **Scope note:** `is_active` also appears on unrelated tables
(`SiteContent/class-sitecontentrepository.php`, `Team/TimeTracker.php`) — those are not language
reads and are out of scope.

**Bucket A — already filters `is_active = 1` on the languages table; correct for free.**
`Workflow/SkillMatcher.php:503`, `Performance/AsyncJobProcessor.php:429`,
`Performance/ObjectCache.php:600`, `Frontend/LocaleContextCoordinator.php:202`,
`Core/StringScanner.php:474`, `Core/CacheManager.php:751`, `Services/AnalyticsService.php:223`,
`Core/CacheWarmer.php:174` and `:278`, and `LanguageManager::getAllLanguages(true)`.

**Bucket B — unfiltered or deliberately inactive-inclusive; MUST gain `is_disabled = 0`.**
These are the leaks:

- `LanguageManager::getAllLanguages()` (line 665) at `$activeOnly = false` — returns inactive
  languages by design. Consumers: `API/ExceptionsController.php:377`,
  `API/TranslationsController.php:914`, `Admin/LanguagesController.php:80`,
  `Translation/Settings.php:496`, `Translation/TranslationBridge.php:119`.
  **Contract change:** `getAllLanguages()` never returns disabled languages at either argument.
- `API/TranslationsController.php:1473` — `SELECT * FROM {languages} ORDER BY is_active DESC, ...`,
  no `WHERE` at all; feeds the per-post translation matrix.
- The languages REST list endpoint and its field maps in `API/LanguagesRestController.php`.

**Bucket C — the only intentional `is_disabled = 1` consumer:** `getDisabledLanguages()`, backing
the admin Disabled section.

**Cache keys.** `generateKey('languages', $activeOnly ? 'active' : 'all')` distinguishes only two
variants. Since "all" now excludes disabled, the disabled read needs its own key (`'disabled'`), and
disable/restore must invalidate all three.

### 3.5 Front-end suppression contract

Posts are never mutated; `post_status` stays `publish`. Suppression is a **request-level gate**, so
restore is one flag flip and lossless. Observable contract for a disabled language `es`:

```
GET /es/mi-articulo        -> 404
language switcher          -> es absent
hreflang output            -> es absent
XML sitemap                -> es absent
front-end WP_Query         -> es-language elements excluded
wp_posts                   -> UNCHANGED (post_status = publish)
Restore                    -> one UPDATE; all N translations live again
```

Touch points: `Frontend/RewriteRuleManager.php` (do not register disabled-language rules),
`Frontend/URLManager.php` and `Frontend/LocalizedURLResolver.php` (no disabled URL generation;
resolve -> 404), `Frontend/LanguageRequestContext.php` (a disabled code must never resolve to a
request language), `Frontend/LanguageSwitcher.php`, `Frontend/LocaleContextCoordinator.php`,
`Compatibility/YoastSEOIntegration.php` (hreflang + sitemap).

**`Frontend/ContentFilter.php` needs no new query filter — do not add one.** Verified on master:
`filterPosts()` (line 100) resolves the *current* request language and appends a positive
`_ipz_language = <current code>` meta clause via `getLanguageMetaClause()` (line 355); the default
language additionally ORs in `NOT EXISTS`. Front-end queries are therefore already scoped to exactly
one language. Because a disabled language can never be the default (§3.1 invariant) and can never
become the effective request language once `LanguageRequestContext` refuses it, no front-end query
ever asks for disabled-language elements. The gate is the request-language guard, and that alone.

Explicitly rejected: excluding disabled content with a `post__not_in` ID blacklist. Hundreds of IDs
on every front-end query is a real performance regression and buys nothing the language scoping does
not already give. If a residual leak is found, close it with a `posts_join` / `posts_where` clause
against `ipz_translations` — never an ID list.

Admin and REST management contexts still see disabled rows, so the Disabled section can render.

### 3.6 REST contract

Existing routes in `includes/API/LanguagesRestController.php`:

- `DELETE /languages/{code}` (impl. line 532) and `DELETE /languages/{id}` (impl. line 579) —
  **both** gain the same disposition handling. The second is unused by the admin JS and is the easy
  miss; leaving it on the old path would keep half the API hard-blocking.

Request body on either delete route:

```json
{ "disposition": "disable" | "purge", "confirmation": "<language name>" }
```

- absent `disposition` + language has translations -> `409 language_has_translations`, body carries
  the impact counts so a client can prompt
- `disposition: "disable"` -> `200 { "success": true, "disabled": true, "message": "..." }`
- `disposition: "purge"` -> requires `confirmation` to equal the language's `name` exactly
  (case-sensitive, e.g. `Spanish`), else `400 confirmation_mismatch`. The name is used rather than
  the two-letter code because typing two characters is too weak a gate for an irreversible post
  deletion. On success:
  - impacted element count <= one batch (25) -> the purge runs inline and returns
    `200 { "success": true, "purged": <int> }`
  - otherwise -> `202 { "success": true, "job_id": <int>, "total": <int> }` and the client polls
    the progress route. The inline branch exists so small deletions — including E2E fixture
    teardown (§5) — complete within the request and need no polling.
- zero translations -> deletes outright regardless of `disposition`

New routes:

```
GET  /languages/{code}/deletion-impact -> 200
     { "code":"es", "name":"Spanish",
       "content_translations": 412,   // ipz_translations, translation_status != 'original'
       "source_posts": 0,             // ipz_translations, translation_status = 'original'
       "string_translations": 88,     // ipz_string_translations
       "queued_jobs": 3,              // ipz_translation_jobs
       "purge_allowed": true }        // false when source_posts > 0 OR ipz_language_purge is OFF

GET  /languages/disabled              -> 200
     { "success": true, "data": [ { "code":"es", "name":"Spanish", "nativeName":"Español",
                                    "flagCode":"ES", "disabledAt":"2026-08-21 10:00:00",
                                    "content_translations": 412 } ] }

POST /languages/{code}/restore        -> 200 { "success": true }
GET  /languages/purge-jobs/{id}       -> 200 { "status":"running|complete|failed",
                                               "processed":150, "total":412, "errors":[] }
```

`GET /languages/disabled` is the **only** read path that returns disabled rows and is the sole data
source for the admin's `Disabled (N)` section (§3.8); the main list route filters them out per §3.4,
so without this route the section has nothing to render. It is backed by `getDisabledLanguages()`
and carries the per-language `content_translations` count the section displays — that count lives in
`ipz_translations`, not on the language row, so the controller joins it in with one grouped query
rather than N per-row counts.

"XXX translations" in the prompt is `content_translations` — content only; string translations and
queued jobs appear as secondary lines so the headline number matches what an operator thinks of as
a translation.

All routes: existing `X-WP-Nonce` verification and capability check, `sanitize_key()` on the code,
`absint()` on ids, prepared statements throughout.

**Add-language collision.** `idx_code` / `idx_locale` are UNIQUE and the disabled row keeps
occupying them. The create path must detect a disabled row for the submitted code/locale and return
`409 language_disabled` with a message pointing at Restore, instead of surfacing a raw duplicate-key
failure.

### 3.7 Purge job

`purgeLanguage()` has two branches and **both must be built** — §3.6's REST contract depends on it.
It first counts the impacted elements using the predicate below, then:

- **total ≤ one batch (25)** — the purge runs inline, synchronously, inside the request, returning
  `{mode:'inline', purged:<int>, total:<int>}`. No job row is created and the client never polls.
  This branch is what lets E2E fixture teardown (§5) finish in a single call.
- **total > 25** — deleting hundreds-to-thousands of posts cannot fit one request, so the work is
  enqueued as a job processed in batches by the existing async infrastructure
  (`includes/Performance/AsyncJobProcessor.php`, `ipz_translation_jobs`), returning
  `{mode:'job', job_id:<int>, total:<int>}`.

Both branches run the identical batch body and the identical guards; only the driver differs (a
direct loop versus the async processor). Implement the batch body once and call it from both.

Per batch (25 elements):

1. select `ipz_translations` rows for the language where `translation_status != 'original'`
2. `wp_delete_post($element_id, true)` for each
3. delete those `ipz_translations` rows
4. record progress

Once content batches drain (inline loop or last async batch — identical tail in both): delete `ipz_string_translations` rows for the language, cancel queued
`ipz_translation_jobs` targeting it, delete the `ipz_languages` row, clear caches.

**Non-negotiable guard — `translation_status = 'original'`.** `Core/ContentManager.php:133` writes
`$sourceElementId ? 'translated' : 'original'` and `:271` writes `'original'`, so a non-default
language *can* own originals: those rows describe source content, not translations. A blanket delete
over `WHERE language_code = 'es'` would permanently destroy original posts. Therefore:

- the purge predicate always carries `AND translation_status != 'original'`, and
- when `source_posts > 0`, `purge_allowed` is `false` and the purge route refuses with
  `409 language_has_source_content`, directing the operator to disable instead.

`purge_allowed` carries **both** gates: it is `false` when `source_posts > 0` **or** when the
`ipz_language_purge` flag is OFF (§6). It is the single field the SPA reads to decide whether the
destructive choice exists.

Sibling rows sharing a `translation_group_id` in other languages are never touched — only rows whose
`language_code` matches.

### 3.8 Admin UI — `admin/src/pages/languages.js`

`deleteLanguage()` (line 775) stops using `confirm()` (line 779). New flow:

1. `GET /languages/{code}/deletion-impact`
2. zero translations -> keep today's simple confirm-and-delete
3. otherwise open a `Modal`:
   - title: `Deleting a language with 412 translations`
   - secondary lines for string translations and queued jobs
   - **[Keep translations disabled]** — primary; explains the language is removed from the site but
     its translations are kept and can be restored
   - **[Delete all]** — destructive; reveals a text input requiring the language's full name
     (e.g. `Spanish`, case-sensitive) to be typed
     before the button enables; states plainly that translated posts are permanently deleted and
     unrecoverable. Disabled with an explanatory note when `purge_allowed` is false.
4. disable -> `Toast.success`, row moves to the Disabled section
5. purge -> progress bar polling `GET /languages/purge-jobs/{id}` until terminal state

New **Disabled** section below the main list, fed by `GET /languages/disabled` (§3.6) — the main
list route no longer returns these rows. Collapsible, rendered only when non-empty, headed
`Disabled (N)`; each row shows flag, name, translation count and a **Restore** button. Restore ->
`POST /languages/{code}/restore` -> `Toast.success` -> row returns to the main list as active.

Per §2.1 the section must read as a distinct concept from the Active/Inactive toggle: disabled rows
leave the main list entirely and expose Restore, never a toggle switch.

Styling: SCSS only, in the existing page partial. **No inline CSS, no `<style>`, no `style=`
attributes** — monorepo hard rule. Reuse the existing `Modal`, `Toast`, `Button` components and the
existing `ipz-inactive` row-state pattern; disabled rows get their own modifier class.

### 3.9 i18n & escaping

All new strings use the `international-press-zone` text domain — PHP via `__()` / `esc_html__()`,
JS via the existing `__()` wrapper. Every echoed value escaped (`esc_html()`, `esc_attr()`); every
input sanitized (`sanitize_key`, `absint`).

## 4. Error handling

| Condition | Response |
|---|---|
| language not found | `404 language_not_found` |
| language is default | `400 cannot_delete_default` (existing) |
| has translations, no disposition | `409 language_has_translations` + impact payload |
| purge while `ipz_language_purge` is OFF | `403 purge_disabled` — checked first, before any count or write (§6) |
| purge without matching confirmation | `400 confirmation_mismatch` |
| purge when `source_posts > 0` | `409 language_has_source_content` |
| restore a language that is not disabled | `400 not_disabled` |
| create a language whose code/locale is disabled | `409 language_disabled` |
| DB write failure | `500 delete_failed` / `500 disable_failed` (existing style) |

Purge job failures are recorded per element and surfaced in the progress payload's `errors`; a
partially failed purge leaves the language row in place so the operation can be retried.

## 5. Testing

**Unit / integration (PHP).** Disable sets both flags and mutates zero translation rows; restore
returns the exact prior translation set; `getAllLanguages()` excludes disabled at both argument
values; purge never deletes an `original` row or a sibling-language row; the confirmation-mismatch
and source-content guards refuse.

**E2E (`tests/e2e/journeys/`).**

- `UJ-005-delete-language.spec.js` **breaks by design** and is rewritten. Its H1 asserts the exact
  `confirm()` string, `{ success: true, message: 'Language deleted successfully.' }` and
  `postDataJSON() === null`; all three are invalidated by the modal and the disposition body. A1
  (default-language refusal) survives with an updated payload assertion. Its
  `.spec.manifest.json` sibling updates alongside it.
- **The shared teardown helper changes, and 13 journeys inherit it.**
  `tests/e2e/journeys/journey-support.js` exports `deleteLanguage(page, nonce, code)` (line 227),
  which issues a bare `DELETE /languages/{code}` and asserts `[200, 404]`. Every journey registers
  it through `owned.add(() => deleteLanguage(...))`, so any journey that translated into its fixture
  language will now get `409 language_has_translations`, fail the assertion, and leave durable state
  behind. The helper must send `{ disposition: "purge", confirmation: <language name> }` — real
  teardown wants the rows gone — and therefore needs the fixture's `name`, not just its `code`;
  widen its signature to take the created entity (or add the name as a parameter) and update every
  call site. Accepted statuses stay `[200, 404]` because fixture counts are far below the inline
  batch threshold (§3.6), so teardown never has to poll a job. Purge teardown also cannot trip the
  `409 language_has_source_content` guard: verified on master, the save-content-translation route
  (`API/TranslationsController.php:1756`) writes the `original` row against the language selected by
  `WHERE is_default = 1` and writes the target-language row as `'translated'` (`:1810`); the
  `ContentManager.php:133` fallback that would stamp `'original'` onto a non-default language is
  reached only through `addToTranslationGroup()`, whose sole callers are `linkTranslations()`
  (`ContentManager.php:249`/`:288`), which always ensures a source row exists first. No REST surface
  can therefore give a journey's fixture language a `translation_status = 'original'` row, and
  `source_posts` for a fixture language is always `0`. That analysis covers the write paths that
  were read on master; `linkTranslations()` is itself REST-exposed, so **do not let 13 journeys rest
  on it.** The helper's accepted-status set therefore also includes `409` and `403`, and on either it
  retries once with `{ disposition: "disable" }` and asserts that succeeds. Teardown is then correct
  whether or not the no-source-rows claim holds, **and correct in both flag states** — `403
  purge_disabled` is the expected response on any host where `ipz_language_purge` is OFF (§6), which
  is every host until rollout, so the disable fallback is the normal teardown path, not an edge case.
  The common case degrades to a disabled fixture language rather than a failed assertion and durable
  leftover state.
  Affected: `journey-support.js` plus UJ-001, UJ-002, UJ-003, UJ-004, UJ-005, UJ-011, UJ-012,
  UJ-013, UJ-015, UJ-016, UJ-019, UJ-020, UJ-021 — the content and string journeys (011/012/013/
  015/016/019/020/021) are the ones that actually create translations and would fail today's helper.
- New coverage: disable -> language absent from switcher/hreflang/sitemap, translated URL 404s,
  `wp_posts` row unchanged, restore returns every translation; purge -> confirmation gating, job
  progress to completion, originals untouched. The disable/restore coverage runs unflagged. The
  purge coverage must flip `ipz_language_purge` on in its own setup and restore the prior option
  value in teardown, plus one dedicated OFF-state assertion: `[Delete all]` absent from the modal
  DOM (not merely hidden), the purge disposition answering `403 purge_disabled`, and the
  purge-jobs route unregistered.

**Gates are remote-only.** Every `international-press-zone` gate runs through `/ipz-e2e` on a
registry-selected debian host. Nothing here is gated locally.

## 6. Rollout

Additive schema, no backfill: existing rows default to `is_disabled = 0` and behave exactly as
today. The migration is reversible; with the columns dropped the plugin returns to the current
hard-block behaviour. Two-way door throughout, except the purge branch, which is irreversible by
construction and therefore gated behind an explicit typed confirmation.

**The purge branch ships behind the project's canonical feature flag, default OFF.** A route that
permanently `wp_delete_post`s hundreds of posts is risky user-facing behaviour under `CLAUDE.md`
line 32.

- **Primitive:** `InternationalPressZone\Core\FeatureFlags` (`includes/Core/FeatureFlags.php`) — the
  single canonical server-side primitive, backed by the `ipz_feature_flags` option and read through
  `FeatureFlags::isEnabled( string $key ): bool`. It already fails closed: an unknown key, a missing
  option, or a non-array option all return `false`, and only the literal `true` enables. **Do not
  invent a second flag mechanism, a constant, an environment check, or a direct `get_option()` call.**
- **Key:** `ipz_language_purge`, added to the `DEFINITIONS` const alongside the existing
  `ipz_localized_routing` / `ipz_site_content` entries, with the same five required fields —
  owner `InternationalPressZone`; purpose "Control destructive language-purge rollout."; rollout
  state `inactive`; kill switch "Set `ipz_language_purge` to false in `ipz_feature_flags`."; removal
  task "Remove after purge rollout is complete."
- **Scope — flagged:** only the destructive path. `disposition: "purge"`, the
  `GET /languages/purge-jobs/{id}` route, and the `[Delete all]` choice in the confirmation modal.
- **Scope — NOT flagged:** the migration and both columns (dormant schema preparation, §3.2), the
  disable/restore seam and routes, `GET /languages/deletion-impact`, `GET /languages/disabled`, the
  Disabled section with its Restore button, and every read-path exclusion in §3.4/§3.5. The
  non-destructive half of this feature is the safe default and ships active.
- **OFF semantics — zero effect, not hidden UI.** With the flag OFF, `register_rest_route` is not
  called for the purge-job route at all; the delete route rejects `disposition: "purge"` with
  `403 purge_disabled` before any count or write; the purge job is never enqueued or scheduled; and
  the modal does not render the `[Delete all]` action. **CSS hiding is forbidden** — the button must
  be absent from the DOM, not merely invisible.
- **Authority:** PHP decides. The admin SPA learns the state only from server-returned data (the
  deletion-impact payload's `purge_allowed` already carries it — with the flag OFF it is `false`
  regardless of counts) and MUST NOT gate on any client-side constant.
- **Activation:** staff-only first, after a complete install/runtime/browser journey passes on the
  remote gate; kill switch preserved throughout; flag removed once rollout completes.

## 7. Architecture Decisions

**Accepted collapses**

- *No `DisableService` / `LanguageStateMachine` module.* Disable and restore are two flag writes plus
  cache invalidation. Deleting such a module scatters nothing to callers — it would be a decorative
  seam over `LanguageManager`, which already owns language state. Folded in.
- *No repository/adapter abstraction for disabled reads.* Exactly one plausible implementation
  (`wpdb` against `ipz_languages`). Single-adapter test -> collapse; YAGNI.
- *No separate `ipz_disabled_languages` table.* Duplicates the schema, needs copy-back on restore,
  and leaves translations pointing at a language code with no row. The flag is simpler and lossless.

**Rejected candidates**

- *Replacing `is_active` with `status ENUM('active','inactive','disabled')`* — rejected. It makes the
  illegal state unrepresentable, but rewrites ~20 read/write sites and the public `isActive` REST
  field: a one-way contract break for a modelling nicety. The `is_disabled => is_active = 0`
  invariant, enforced in the two methods that own it, closes the same hole additively.
- *Synchronous purge* — rejected. Exceeds the request budget at realistic translation counts.
- *Suppressing disabled content by rewriting `post_status`* — rejected. It mutates `wp_posts`, making
  restore lossy (original statuses must be remembered) and colliding with anything else that manages
  post status. The request-level gate keeps restore a single flag flip.

**Depth assessment.** Three modules carry the design: `LanguageManager` (deep — callers cannot tell
disabled state is two columns and an invariant), the front-end gate (deep — callers see 404s and
absent switcher entries, never the mechanism), the purge job (medium — hides batching and the
`original`-row guard behind a job id and a progress payload). No all-shallow decomposition.
