# Spec — Batched content translation, WordPress plugin side

Status: wave 3 (plugin dispatcher + REST route) is **implemented** on
`feat/plugin-content-batching`: `TranslationJobDispatcher::dispatch_content_batch()`,
`submit_content_batch_chunk()`, `get_bulk_content_limits()`; `TranslateJobsController`'s
renamed `hasBulkJobItems()`/`syncBulkJobItems()`/`recomputeBulkParentStatus()` plus new
`completeBulkContentJob()` and the `bulk_content_translation.completed` webhook branch;
`GET /translate-jobs/{id}` now returns `is_bulk`/`items`/`items_summary`; and
`TranslateController`'s `POST /translate-async-batch` route with
`checkAsyncBatchPermission()`. Wave 4 (frontend, `content-translate.js`) is separate work.
Backend half is **already landed** on `origin/master` at `d062b4ca2`. Every contract
below was read out of that landed code, not from a design proposal.

**Confirmed per §5b**: every identifier the frontend polls is a **local WordPress job row
id**, never the backend UUID — the `GET /jobs/{id}` route's `id` param regex is `[\d]+` and
cannot match a UUID.

**SUPERSEDED — `batch_id` (singular) no longer exists.** A single call can produce more
than one batch-parent row: `$batchable` is keyed by SOURCE LANGUAGE, so a 20-pair request
spanning two source languages yields two parents, and pairs carrying ACF/SEO extras leave
the batch path entirely and keep their own single job row. One scalar cannot address them.
The response now carries `batch_ids` (every parent row created) and stamps each
`submitted[]` entry with its own `job_id` poll target. See §4b.

---

## 1. Problem

`Generate All` submits **one HTTP request per (post, language) pair**. A 188-post sweep
across the configured languages is ~376 sequential `wp_safe_remote_post` calls at roughly
6.7s of wall clock each. Five backend components were individually measured and all are
fast (ingress 0.28s median, submit handler 27ms, Redis 0.41ms, pool healthy, both auth
branches single-query). When every component is fast but the aggregate is slow, the cost
is the **number of round trips**. This change reduces 376 requests to ~19.

Note: the per-request latency split between WordPress bootstrap and backend time is still
unattributed (`timing_ms` instrumentation is deployed but not yet read on a real sweep).
Batching is justified regardless of where that time lives, and is **not** gated on it.

## 2. The unit of batching is the PAIR

An item is one `(post, targetLang)` pair. **Not a post.** A 10-post chunk at 12 languages
is 120 items, six times the cap. Chunk over the flattened pair list.

`ref` format: `content:{postId}:{lang}` — this is the correlation key end to end.

## 3. Landed backend contract (verbatim — do not re-derive)

### Request

`POST {api_base}/v1/jobs/bulk-content`

Headers: `Authorization: Bearer <license key>`, `x-plugin: international`, `Content-Type: application/json`.

International Press Zone authenticates on the **license** branch. `authenticateApiKey`
detects a license key and delegates to `authenticateLicense` (`middleware/auth.ts:152-155`);
an `sk_live_`/`sk_test_` key with `x-plugin: international` is rejected `LICENSE_REQUIRED`.
Send the license key exactly as the existing single-content path does.

```jsonc
{
  "sourceLang": "en",
  "items": [                       // min 1, max 20  (BULK_CONTENT_MAX_ITEMS)
    { "ref": "content:41:es", "targetLang": "es",
      "title": "...", "excerpt": "...", "content": "..." }
  ],
  "callbackUrl": "https://site/wp-json/.../webhook",
  "callbackSecret": "<>=16 chars>",
  "tone": "neutral",               // optional
  "clientJobId": "wp_bulk_41"      // optional
}
```

Per item at least one of `title`/`excerpt`/`content` must be present, and each item is
capped at `config.maxAsyncChars` using the same sanitized counting as the single-content
path. A rejection names the offending `ref`.

### Synchronous response — `202`

```json
{ "success": true, "job_id": "<uuid>", "status": "queued",
  "total_items": 20, "timestamp": "..." }
```

Other statuses: `402 INSUFFICIENT_CREDITS` (message carries required vs available),
`400` validation (names the `ref`), `401 LICENSE_REQUIRED`, `500 INTERNAL_ERROR`.

**Check `202` specifically.** There is a pre-existing bug elsewhere in the plugin that
tests `=== 202`; here that is correct, but treat any 2xx defensively.

### Webhook — one per batch, on completion

```json
{
  "event": "bulk_content_translation.completed",
  "job_id": "<uuid>",
  "clientJobId": "wp_bulk_41",
  "results": [
    { "ref": "content:41:es", "status": "completed",
      "translatedTitle": "...", "translatedExcerpt": "...", "translatedContent": "..." },
    { "ref": "content:41:fr", "status": "failed", "error": "Gemini timed out" }
  ],
  "total_characters_used": 123,
  "failed_count": 1,
  "timestamp": "..."
}
```

Signed with `callbackSecret` by the same mechanism as the existing webhooks — reuse the
existing verification helper, do not write a new one.

**Per-item failures are isolated**: one failed item never fails its batch. Only successful
items are billed, so a retry of a failed item does not double-charge.

### Negotiating the cap

`GET /health` now exposes:
```json
"capabilities": { "bulkContentTranslation": { "maxItems": 20,
                  "maxCharsPerItem": <config.maxAsyncChars>, "version": 1 } }
```
Cache under a **separate** transient key `ipz_bulk_content_limits`. **Cache the fallback
outcome too** (5-min TTL) — otherwise every batch flush adds an uncached `/health` call,
which is the exact latent bug already present in the string path's two copies.

**Do not touch the string path's copies** (`AutoTranslationCoordinator`,
`StringTranslateController`). They are live and working; fixing their caching gap here
converts a scoped change into a regression surface. Leave it as a separate noted item.

## 4. Plugin flow

Prerequisite (wave 1, landing separately): `dispatch_content()`'s ~350-line per-language
preflight is extracted into
`prepare_content_job(int $post_id, string $target_language, int $initiator_id, array $selection): array|WP_Error`.
Wave 3 builds on that method and MUST NOT re-extract it.

1. `dispatch_content_batch(array $pairs)`:
   - call `prepare_content_job()` per pair, **preserving today's order**;
   - a `WP_Error` marks that pair failed and is skipped — it must not abort the batch;
   - pack surviving pairs into chunks of `maxItems` (greedy; an item exceeding
     `maxCharsPerItem` is rejected at preflight, not silently split);
   - one `wp_safe_remote_post` per chunk. **Never** `Requests::request_multiple()` — it
     bypasses `wp_safe_remote_post`'s SSRF protection and the `http_request_args` filters.
     Do not hand-rebuild that boundary either.
   - persist one `presszone_international_job_items` row per pair, keyed by `ref`.
     **No schema change** — `Core/Database.php:577-607` already carries `ref`,
     `target_lang`, `status`, `translated_content`, fingerprints and `target_identity`.
2. `reconcile_bulk_content_job()` consumes the webhook, fans `results[]` out by `ref`,
   writes each translation through the existing per-pair save path, and recomputes the
   parent status.
3. Rename the bulk-strings helpers to serve both paths:
   `hasBulkStringJobItems()`→`hasBulkJobItems()`, `syncBulkStringJobItems()`→`syncBulkJobItems()`,
   `recomputeBulkStringParentStatus()`→`recomputeBulkParentStatus()`; add `completeBulkContentJob()`.
4. New REST route `/translate-async-batch` on `TranslateController` with
   `checkAsyncBatchPermission()`. The permission callback MUST check capability
   **per post**, exactly as `translateBulk()` does.

## 4b. PINNED WordPress route contract (frontend codes against this verbatim)

`POST /wp-json/presszone-international/v1/translate-async-batch`
Headers: `X-WP-Nonce` (standard REST nonce).

Request:
```jsonc
{ "pairs": [ { "post_id": 41, "target_lang": "es" } ],   // max 20 per call
  "selection": { /* existing selection object, unchanged */ } }
```

Response `200`:
```jsonc
{
  "success": true,
  "batch_ids": [ 512, 513 ],         // every local batch-parent job row id; [] when nothing was batched
  "submitted": [ { "post_id": 41, "target_lang": "es", "ref": "content:41:es",
                   "job_id": 512 } ],   // the row to poll: batch parent, or the pair's own
                                        // job row when it took the single path
  "skipped":   [ { "post_id": 42, "target_lang": "es", "reason": "already_exists" } ],
  "protected": [ { "post_id": 43, "target_lang": "es", "message": "..." } ],
  "queued":    [ { "post_id": 44, "target_lang": "es", "job_id": 91 } ],
  "failed":    [ { "post_id": 45, "target_lang": "es", "message": "..." } ]
}
```

Every submitted pair MUST appear in exactly one of the five arrays, and the five arrays
together MUST account for every pair in the request. The frontend advances its progress
counter by `skipped + protected + queued + failed` immediately, and by each `submitted`
pair as the webhook reconciles it.

`job_id` is authoritative per pair — the frontend polls the DISTINCT set of `job_id`s in
`submitted[]`, which is exactly `batch_ids` plus every single-path row. `batch_ids` alone
is not sufficient: single-path pairs never appear in it.

Two requested `post_id`s in the same translation group resolve to one canonical post and
therefore one `ref`. The second occurrence is reported in `skipped` with
`reason: "duplicate_pair"`; a duplicate `ref` inside one batch is unresolvable on the way
back and is never submitted.

`4xx` only for a malformed request or a failed permission check. A per-pair problem is
never an HTTP error — it lands in `failed`.

## 4c. Mapping `prepare_content_job()` outcomes (landed at `aac514d2b`)

The refactor returns a richer contract than originally specced. Map it exactly:

| `prepare_content_job()` result | goes to |
|---|---|
| `WP_Error` | `failed` |
| `['outcome' => 'skip_existing', 'reason', 'translation_id']` | `skipped` |
| `['outcome' => 'protected', 'error', 'translation_id']` | `protected` |
| `['outcome' => 'already_queued', 'active_job']` | `queued` |
| `['outcome' => 'submit', 'local_job_id', 'context']` | batched into the outbound request; `context` builds the item payload |

Only `submit` outcomes are packed into chunks and sent to the backend. Note the durable
local job row is **already inserted** and all locks are **already released** by the time
`submit` is returned — do not re-acquire or re-insert.

## 5. Frontend

`admin/src/pages/content-translate.js` `executeGenerateAll()` (`:1377-1467`) builds the
flattened pair list and posts chunks. **Progress is counted per pair from the webhook's
`results[]`**, not per post.

A 20-item batch takes roughly 64s before its single webhook fires, so the progress bar
advances in steps of up to 20 pairs. If that granularity is unacceptable, lower `maxItems`
— call count degrades linearly and even 30 requests is a >12x reduction from 376.

## 5b. MANDATORY — poll the BATCH, never the posts

The first frontend implementation (landed `36345e778`) reconciles by calling
`GET /translations/content/{post_id}` for **every pending post every 5 seconds**. For 376
pairs across 188 posts that is ~188 requests per tick — thousands over a sweep. It costs
far more traffic than batching saves and **negates the entire change**. This must be
replaced, not tuned.

**Correct shape:** poll once per batch, not once per post.

`GET /wp-json/presszone-international/v1/jobs/{job_id}` returns, for a bulk parent:
```jsonc
{ "is_bulk": true,
  "items": [ { "ref": "content:41:es", "post_id": 41, "target_lang": "es",
               "status": "pending|processing|completed|failed", "error": null } ],
  "items_summary": { "total": 20, "completed": 18, "failed": 1, "pending": 1 } }
```
Non-bulk job responses are unchanged.

Request count per poll tick becomes **one per in-flight batch** (≤19 for a full sweep, and
falling as batches complete) instead of one per pending post. Resolve pairs by `ref`;
`updateRowProgress()` still drives the row badge, fed from the item statuses.

**Permission contract for a batch parent.** The parent row carries `post_id = null`, so
the generic `edit_post` fall-through in `checkJobPermission()` evaluates
`edit_post, 0` and denies EVERY user, administrators included — the route was unreachable
as first landed. `canAccessBulkContentJob()` now resolves the batch's item rows to their
child jobs' `post_id`s and requires `edit_post` on **every one of them** (fail-closed when
no item rows resolve). The conjunction is load-bearing: the response body exposes every
sibling `post_id` through `items[]`, so an any-post test would turn a dead route into an
information-disclosure one.

**Batch children are never polled directly.** Each child job row shares the parent's
`api_job_id`, but the bulk poll body keys its results by `ref`, never by the child's local
row id. `syncProcessingJobs()`'s missed-webhook fallback would therefore find no matching
item, fall back to the BATCH's status, and finalize the child with an empty translation
the moment the batch completes — destroying the real webhook result whenever the poll wins
the race. The fallback query excludes any job sharing a `content_batch` parent's
`api_job_id`; children are reconciled through the parent only.

**Acceptance:** a full 376-pair sweep must issue on the order of tens of requests total,
not thousands. Count them; do not assume.

## 6. Out of scope (deliberate)

- **Gemini-side batching.** The worker makes one `translateStructured()` call per item,
  preserving today's exact translation behavior. A batched Gemini prompt has its own
  output-cap truncation failure mode and is separate, separately-validated work.
- The string path's fallback-caching gap (§3).
- Attributing the residual per-request latency (`timing_ms` will settle it).

## 7. Verification

No UI change is done until a Playwright run on the k3s lane shows literal DOM evidence.
Lane: mirror `measure-perf-820823-bda33efcb79b`, dispatched from a fresh `origin/master`
worktree's `tests/e2e`. Verified 88/88 green on debian1 on 2026-08-23 with this argv —
Playwright lives at `/opt/ipz-e2e/node_modules`, not under `/workspace`, and `NODE_PATH`
is required because Node resolves from the requiring file's directory:

```
env NODE_PATH=/opt/ipz-e2e/node_modules /opt/ipz-e2e/node_modules/.bin/playwright \
  test --config=playwright.config.js <specs> --project=chromium --project=firefox
```

`pnpm exec`, `npx`, `npx --no-install` and `./node_modules/.bin/playwright` all fail.

Required evidence: the pair count submitted equals the pair count reconciled; the number of
outbound HTTP requests for a full sweep is ~pairs/20, not ~pairs; a deliberately failed
item leaves its siblings translated.
