# Language Disable & Delete — request

**Goal:** Deleting an `international-press-zone` language that owns translations must stop hard-blocking and instead offer two explicit dispositions — *disable* (keep every translation row untouched behind a flag, restorable) or *purge* (permanent, flag-gated, confirmation-typed deletion of the language's translated posts and plugin rows).

**Design spec (authoritative, read it first):** `plugins/international-press-zone/docs/specs/2026-08-21-language-disable-delete-design.md`. Where this request and the spec disagree, the spec wins; where the spec is silent, this request is the contract.

**Base:** `origin/master` @ `f01fc5586`. Every line number below is from that tree — re-verify before editing, do not trust a stale offset.

**Plugin root for all relative paths below:** `plugins/international-press-zone/`.

---

## Context

`LanguageManager::deleteLanguage()` (`includes/Core/LanguageManager.php:488`) throws `InvalidArgumentException` — "Cannot delete language with %d existing translations. Delete translations first." (line 522) — whenever the language owns a row in `ipz_translations`. There is no disable path and no supervised delete path. The admin (`admin/src/pages/languages.js:775`) wraps this in a browser `confirm()` (line 779).

The change is additive: a new `is_disabled` column orthogonal to the existing `is_active` toggle, holding the invariant `is_disabled = 1 => is_active = 0`. That invariant is what makes every existing `WHERE is_active = 1` read path correct with no edit; only the read paths that do **not** filter on `is_active` need work.

`is_disabled` is **not** `is_active`. Inactive = temporarily toggled off, still in the main list. Disabled = deleted from the site with content kept, out of the main list, undone only by **Restore**. The UI must never blur the two.

---

## Files

**Create**

- `includes/Migrations/Migration20260821AddLanguageDisabledState.php` — adds the two columns + index; `rollback()` drops them.

**Modify**

- `includes/Core/Database.php` — `ipz_languages` DDL (fresh-install source of truth) gains the same two columns + index.
- `includes/Core/FeatureFlags.php` — one new entry in the `DEFINITIONS` const.
- `includes/Core/LanguageManager.php` — new seam (`deleteLanguage()` at 488, `getAllLanguages()` at 665).
- `includes/API/LanguagesRestController.php` — disposition handling on both delete impls (`:532` by code, `:579` by id), four new routes, create-path collision guard.
- `includes/API/TranslationsController.php:1473` — unfiltered `SELECT * FROM {languages} ORDER BY is_active DESC, ...` gains `WHERE is_disabled = 0`.
- Front-end suppression touch points: `includes/Frontend/RewriteRuleManager.php`, `includes/Frontend/URLManager.php`, `includes/Frontend/LocalizedURLResolver.php`, `includes/Frontend/LanguageRequestContext.php`, `includes/Frontend/LanguageSwitcher.php`, `includes/Frontend/LocaleContextCoordinator.php`, `includes/Compatibility/YoastSEOIntegration.php`.
- `admin/src/pages/languages.js` — modal flow + Disabled section.
- The existing languages-page SCSS partial under `admin/src/styles/` — disabled row-state modifier and modal/section styling.
- `tests/e2e/journeys/journey-support.js` — `deleteLanguage()` teardown helper (line 227) and the `createLanguage()` fixture that feeds it (line 201).
- `tests/e2e/journeys/UJ-005-delete-language.spec.js` + its `.spec.manifest.json` — rewritten.
- Call sites of the teardown helper in: UJ-001, UJ-002, UJ-003, UJ-004, UJ-011, UJ-012, UJ-013, UJ-015, UJ-016, UJ-019, UJ-020, UJ-021.

---

## Contract

### Schema (unflagged — dormant schema preparation)

`ipz_languages` gains:

| 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)`.

`up()` guards the add with an `information_schema` existence check so it is idempotent; `rollback()` drops both columns and the index. Follow the dated class convention of the existing `Migration20260804MigrateDebugMode` in the auto-discovered `InternationalPressZone\Migrations` namespace.

This is the **expand** step of expand–migrate–contract and the **only** step: additive, safe defaults, no backfill, nothing narrowed or dropped. **Do not author a contract migration** and do not report its absence as a gap. `ipz_translations`, `ipz_string_translations` and `wp_posts` are not altered.

### Feature flag

Primitive: `InternationalPressZone\Core\FeatureFlags` (`includes/Core/FeatureFlags.php`), read via `FeatureFlags::isEnabled( string $key ): bool`, backed by the `ipz_feature_flags` option. It already fails closed. **Do not create a second flag mechanism, constant, env check, or direct `get_option()` call.**

New key `ipz_language_purge`, added to `DEFINITIONS` in the same shape as the existing two entries:

```php
'ipz_language_purge' => array(
	'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.',
),
```

**Flagged (OFF by default):** the `purge` disposition, `GET /languages/purge-jobs/{id}`, and the `[Delete all]` modal choice — nothing else.

**Not flagged:** the migration and both columns, disable/restore, `deletion-impact`, `GET /languages/disabled`, the Disabled section + Restore, and every read-path exclusion.

**OFF semantics — zero effect, not hidden UI:** `register_rest_route` is not called for the purge-jobs route at all; the delete routes reject `disposition: "purge"` with `403 purge_disabled` **before any count or write**; no purge job is ever enqueued or scheduled; `[Delete all]` is absent from the DOM. **CSS hiding is forbidden.** PHP is the authority — the SPA reads `purge_allowed` off the server payload and MUST NOT gate on any client-side constant.

### `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 row and no 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, exactly as today
  - otherwise -> throws LanguageHasTranslationsException carrying the impact counts

getDeletionImpact(string $code): array   // shape below
getDisabledLanguages(): Language[]       // the only intentional is_disabled = 1 reader

purgeLanguage(int $id): array
  - {mode:'inline', purged:int, total:int}   when total <= 25
  - {mode:'job',    job_id:int, total:int}   when total > 25
```

`deleteLanguage()` keeps refusing — as a **typed** `LanguageHasTranslationsException`, not a generic `InvalidArgumentException`. Nothing may destroy content as a side effect of the word "delete"; destruction happens only through `purgeLanguage()`.

### Read-path audit — the correctness core

Every language read must end up in exactly one bucket; **leave none unclassified**.

**Bucket A — already filters `is_active = 1` on the languages table; correct for free, do not edit:** `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`, `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`. **Contract change: `getAllLanguages()` never returns a disabled language at either argument value.** Consumers to re-check under the new contract: `API/ExceptionsController.php:377`, `API/TranslationsController.php:914`, `Admin/LanguagesController.php:80`, `Translation/Settings.php:496`, `Translation/TranslationBridge.php:119`.
- `API/TranslationsController.php:1473` — no `WHERE` clause 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()`.

**Out of scope:** `is_active` on `SiteContent/class-sitecontentrepository.php` and `Team/TimeTracker.php` — different tables, not language reads.

**Cache keys.** `generateKey('languages', $activeOnly ? 'active' : 'all')` has only two variants today. "all" now excludes disabled, so the disabled read needs a third key (`'disabled'`), and disable/restore must invalidate **all three**.

### Front-end suppression

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
```

**`Frontend/ContentFilter.php` needs no new query filter — do not add one.** Verified on master: `filterPosts()` (line 100) appends a *positive* `_ipz_language = <current code>` meta clause via `getLanguageMetaClause()` (line 355), so front-end queries are already scoped to exactly one language. A disabled language can never be the default (the invariant) and can never become the request language once `LanguageRequestContext` refuses it. The request-language guard is the gate, and it alone.

**Explicitly rejected — do not implement:** a `post__not_in` ID blacklist. Hundreds of IDs on every front-end query is a real regression and buys nothing. If a residual leak is found, close it with `posts_join`/`posts_where` against `ipz_translations`, never an ID list.

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

### REST contract

Both delete impls — `DELETE /languages/{code}` (`:532`) and `DELETE /languages/{id}` (`:579`) — gain **the same** disposition handling. The by-id route is unused by the admin JS and is the easy miss; leaving it on the old path keeps half the API hard-blocking.

Request body on either delete route:

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

- zero translations → deletes outright regardless of `disposition` (today's behavior)
- absent `disposition` + language has translations → `409 language_has_translations`, body carries the impact counts so the 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 full name, not the two-letter code — two characters is too weak a gate for irreversible post deletion. On success: `total <= 25` → `200 { "success": true, "purged": <int> }`; otherwise `202 { "success": true, "job_id": <int>, "total": <int> }`.

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 returning disabled rows and the sole data source for the admin's Disabled section — the main list route filters them out, so without this route the section has nothing to render. Back it with `getDisabledLanguages()` and join the per-language `content_translations` count in with **one grouped query**, not N per-row counts.

The headline "XXX translations" is `content_translations` (content only); string translations and queued jobs render as secondary lines.

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 — never a raw duplicate-key failure.

### Purge

Both branches are mandatory; they run the **identical** batch body and the **identical** guards — only the driver differs (a direct loop vs. the async processor). **Implement the batch body once and call it from both.** Count first, then branch: `total <= 25` runs inline inside the request (no job row, no polling — this is what lets E2E fixture teardown finish in one call); `total > 25` enqueues a job on the existing `includes/Performance/AsyncJobProcessor.php` / `ipz_translation_jobs` infrastructure.

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` the route refuses with `409 language_has_source_content` and reports `purge_allowed: false`. Sibling rows sharing a `translation_group_id` in other languages are never touched — only rows whose `language_code` matches.

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

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

`deleteLanguage()` (line 775) stops using `confirm()` (line 779):

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 (`Spanish`, case-sensitive) before the button enables; states plainly that translated posts are permanently deleted and unrecoverable. Rendered disabled with an explanatory note when `purge_allowed` is false; **absent from the DOM entirely** when the flag is OFF.
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`: collapsible, rendered only when non-empty, headed `Disabled (N)`; each row shows flag, name, translation count and a **Restore** button → `POST /languages/{code}/restore` → `Toast.success` → row returns to the main list as active. Disabled rows leave the main list entirely and expose **Restore, never a toggle switch** — that is what keeps them distinct from Inactive.

Reuse the existing `Modal`, `Toast`, `Button` components and the existing `ipz-inactive` row-state pattern; disabled rows get their own modifier class.

**Styling: SCSS only, in the existing page partial. No `wp_add_inline_style`, no `<style>` block, no `style=` attribute — monorepo hard rule, no exceptions.**

### 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()`, `esc_url()`); every input sanitized (`sanitize_text_field(wp_unslash(...))`, `sanitize_key`, `absint`).

### Error table

| 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 |
| 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) |

---

## Out of scope

- Any change to `ipz_translations`, `ipz_string_translations`, or `wp_posts` schema.
- Any contract/destructive migration (see Schema).
- A `post__not_in` front-end exclusion, or any new filter in `Frontend/ContentFilter.php`.
- Replacing `is_active` with a `status` ENUM, or any rewrite of the existing Active/Inactive toggle, its routes, or the public `isActive` REST field.
- A separate `ipz_disabled_languages` table, or a repository/adapter abstraction over the disabled read.
- Rewriting `post_status` to suppress disabled content.
- `is_active` handling in `SiteContent/class-sitecontentrepository.php` and `Team/TimeTracker.php`.
- Refactoring, reformatting, or "cleaning" any file this work does not functionally change.

---

## Acceptance

**Build (required before any browser gate):**

```
cd plugins/international-press-zone/admin && npm run build
```
Expected: exit 0, bundles emitted to `admin/dist/`.

**PHP unit/integration** — the suite must demonstrate, each as its own assertion:

- disable sets `is_disabled = 1` **and** `is_active = 0` and mutates **zero** rows in `ipz_translations` / `ipz_string_translations` / `wp_posts`
- restore returns the exact prior translation set (count and ids identical to pre-disable)
- `getAllLanguages(true)` and `getAllLanguages(false)` both exclude a disabled language
- purge never deletes a `translation_status = 'original'` row and never deletes a sibling-language row sharing a `translation_group_id`
- `400 confirmation_mismatch` on a wrong-case or wrong-string confirmation
- `409 language_has_source_content` when `source_posts > 0`
- `403 purge_disabled` when `ipz_language_purge` is OFF, returned before any row is counted or deleted
- `409 language_disabled` when creating a language whose code or locale belongs to a disabled row

**E2E (`tests/e2e/journeys/`) — remote gate only.**

`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. Update its `.spec.manifest.json` sibling alongside it.

**The shared teardown helper changes and 13 journeys inherit it.** `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 via `owned.add(() => deleteLanguage(...))`. Under the new contract a journey that translated into its fixture language gets `409` and leaves durable state behind. New helper contract:

- sends `{ disposition: "purge", confirmation: <language name> }`, so it needs the fixture's **`name`**, not just its `code` — widen the signature to take the created entity (or add `name` as a parameter) and update **every** call site
- accepted statuses are `[200, 404, 403, 409]`; on `403` or `409` it retries **once** with `{ disposition: "disable" }` and asserts that succeeds
- `403 purge_disabled` is the *expected* response on any host where the flag is OFF — i.e. every host until rollout — so the disable fallback is the normal teardown path, not an edge case. This also makes teardown correct regardless of whether a fixture language can ever own an `original` row.

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/string journeys (011/012/013/015/016/019/020/021) actually create translations and are the ones that would fail today's helper.

**New E2E coverage:**

- disable → language absent from switcher, hreflang and sitemap; a translated URL returns 404; the `wp_posts` row is unchanged with `post_status = publish`; restore brings back every translation
- purge (flag ON in the spec's own setup, prior option value restored in teardown) → confirmation gating, job progress to a terminal state, originals untouched
- 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. **Never** run `npx playwright test` on the workstation; never gate locally; never contact `dev1.danzigeronline.com`, `dev3.press.zone`, or any remote/client WordPress. Local runtime verification is canonical `devzone-wordpress` only.

**Rollout state at merge:** `ipz_language_purge` is OFF. Disable/restore ships active; the destructive half stays dark until a complete install/runtime/browser journey passes on the remote gate, then staff-first activation with the kill switch preserved.
