# Translation data plane — design

**Goal:** Pin the contract between the `international-press-zone` WordPress plugin and the
Cloudflare worker for everything the auth/purchase spec does not cover: translation, estimation,
word exceptions, and site registration.

Companion to `2026-08-13-ecosystem-auth-design.md`, which owns connect/social/Stripe. That spec
pins `/api/connect/*`, `/api/packages`, `/api/webhooks/stripe` and `/api/plugin/*`. It does **not**
pin the translation data plane, and that omission is why the plugin still calls a `/v1/*` surface
no worker serves.

## 1. Current state

The plugin has 27 call sites, all against `/v1/*`, inherited from the retired Node backend:

| Plugin surface | Call sites |
|---|---|
| `/v1/translate`, `/v1/translate/bulk` | `TranslationService.php:25-26,359`, `TranslationAPI.php:26-27`, `BulkActions.php:447` |
| `/v1/jobs`, `/v1/jobs/{id}`, `/v1/jobs/{id}/cancel`, `/v1/jobs/bulk-strings` | `JobSender.php:21`, `TranslateJobsController.php:339,705,916`, `TranslateController.php:789,1070,1342`, `StringTranslateController.php:797`, `TranslationService.php:554` |
| `/v1/estimate` | `CharacterEstimator.php:245` |
| `/v1/exceptions`, `/v1/exceptions/sync` | `ExceptionSync.php:50,94` |
| `/v1/sites` | `SiteRegistrar.php:19` |
| `/v1/auth/register`, `/v1/auth/login`, `/v1/onboarding/*`, `/v1/subscriptions/*` | `OnboardingApi.php:179,208,264,344,434,529,617` |
| `/v1/international{endpoint}` | `LicenseClient.php:251` |

Four gaps beyond the path prefix:

1. **The async job model has no counterpart yet.** The worker has no CF Queue and no cron trigger,
   and `apps/api/src/queue-regression.test.ts` keeps both out. It does not forbid Durable Objects
   or alarms, which is how the job plane is rebuilt (D2).
2. **Password auth.** `/v1/auth/register` and `/v1/auth/login` are retired by the auth spec
   (social identity only).
3. **License key as bearer token** at `CharacterEstimator.php:256`, `BulkActions.php:462`,
   `SiteRegistrar.php:67,130`, plus `LicenseClient.php` entire. License keys are retired.
4. **No `Idempotency-Key` header anywhere in the plugin.** `apps/api/src/routes/plugin.ts:176`
   returns `400 IDEMPOTENCY_KEY_REQUIRED` without it, so every metered call fails regardless of path.

Worker side: `/api/plugin/international` (metered) and `/api/estimate` are mounted in `app.ts`.
`createSitesRoute` exists in `routes/sites.ts` but is **not mounted**. There is no exceptions route
at all — `translationExceptions` exists in `schema.ts:218` with no HTTP surface.

## 2. Decisions

**D1 — The plugin is the side that changes.** `/api/*` is the approved target in
`2026-08-13-ecosystem-auth-design.md` §4–§6, and §5.1–§5.4 already mandate replacing
`includes/Licensing/`, both licensing controllers, and the onboarding UI. Serving `/v1/*` from the
worker would mean rebuilding password auth and license-key auth — the two things the auth spec
retires. No `/v1/*` alias is added.

**D2 — The async job plane is rebuilt on a Postgres-backed queue drained by a Durable Object
alarm**, using `@platform-modules/jobs`. No CF Queue, no cron trigger, so
`queue-regression.test.ts` passes unedited and is not touched.

Primitives, already built and tested in `~/Projects/platform/packages/jobs`:

| Concern | Platform export |
|---|---|
| Queue table, claim, retry-with-backoff, completion | `jobsTable`, `claim`, `requeueAfterFailure`, `markJobCompleted` (`db-poll.ts`) |
| Alarm-driven drain loop, self-rearming | `JobRunnerDO`, `REARM_DELAY_MS` (`do-runner.ts`) |
| Typed dispatch, idempotency, terminal-failure handling | `createJobRegistry`, `IdempotencyStore`, `TerminalJobError` (`index.ts`) |

Shape: `POST /api/plugin/international/jobs` claims the idempotency key, debits, and inserts one
`jobs` row per post-language pair, then pokes the runner Durable Object. The DO's `alarm()` claims
due rows, runs the translation, writes the result, and re-arms while work remains. The plugin polls
`GET /api/jobs/{jobId}`.

- **Polling, not callbacks.** The worker never calls into WordPress, so no publicly reachable WP
  URL is needed and the dev site works unchanged. The plugin's job-callback receiver is removed;
  its `process_failed_job` logic moves to the poll handler.
- Retry and backoff belong to `requeueAfterFailure`; a terminal failure marks the row failed and is
  reported through the poll response, never retried.
- Billing is charged once at submit, under the idempotency key, so a resubmitted batch does not
  double-charge.

**Naming constraint:** `queue-regression.test.ts:26` asserts `index.ts` matches neither `queue` nor
`cron`, case-insensitively. The Durable Object export and its binding are named `JOB_RUNNER` /
`JobRunnerDO` — never "queue".

**D3 — The plugin key is `international`.** The plugin's own slug, its onboarding calls, and the
production `plugins` row all use `international`. `app.ts` mounting it as `translate` is the
outlier and is corrected. This key is the entitlement key and the wallet period key, so it must be
settled before any metered call is made in production.

**D4 — One credential: the per-site API key** issued by the connect handshake, sent as
`Authorization: Bearer <api key>`. No license key, no password grant, no user-visible key.

**D5 — Every metered request carries an `Idempotency-Key`** derived deterministically from the work
unit so a retry reuses it: `ipz-<site_id>-<post_id>-<target_lang>-<content_hash>`, ≤255 chars.
Changing the content changes the hash, so an edited post is a new billable unit.

## 3. Endpoint map

| Plugin call today | Target | Auth | Metered |
|---|---|---|---|
| `POST /v1/translate` | `POST /api/plugin/international` | API key | yes |
| `POST /v1/translate/bulk` | `POST /api/plugin/international/jobs` | API key | yes |
| `POST /v1/jobs` | `POST /api/plugin/international/jobs` | API key | yes |
| `GET /v1/jobs/{id}` | `GET /api/jobs/{jobId}` | API key | no |
| `POST /v1/jobs/{id}/cancel` | `POST /api/jobs/{jobId}/cancel` | API key | no |
| `POST /v1/jobs/bulk-strings` | `POST /api/plugin/international/jobs` with `kind: 'strings'` | API key | yes |
| plugin `/callback` receiver | dropped — the plugin polls (D2) | — | — |
| `POST /v1/estimate` | `POST /api/estimate` | API key | no |
| `POST /v1/sites` | `POST /api/sites/register` | API key | no |
| — | `PATCH /api/sites/{siteId}` | API key | no |
| `POST /v1/exceptions/sync`, `GET /v1/exceptions` | `POST /api/exceptions/sync`, `GET /api/exceptions` | API key | no |
| `/v1/auth/*`, `/v1/onboarding/*`, `/v1/subscriptions/*` | `/api/connect/*`, `/api/packages` per the auth spec | per auth spec | no |
| `/v1/international{endpoint}` (`LicenseClient`) | dropped with `includes/Licensing/` | — | — |

`createSitesRoute` and a new exceptions route must be mounted in `apps/api/src/app.ts`; neither is
reachable today. The jobs routes and the `JOB_RUNNER` Durable Object binding are new.

## 3a. Async job contract

`POST /api/plugin/international/jobs` — metered, `Idempotency-Key` required. Body:

```
{ kind: 'posts' | 'strings', sourceLang, items: [{ ref, targetLang, content, title?, excerpt? }] }
```

`ref` is the plugin's own identifier for the unit (post id, string key) and is echoed back
untouched. Response `202`:

```
{ data: { jobId, itemCount, charactersCharged } }
```

`GET /api/jobs/{jobId}` — unmetered. Response:

```
{ data: { jobId, status: 'pending'|'running'|'completed'|'failed'|'cancelled',
          total, completed, failed,
          items: [{ ref, targetLang, status, translatedTitle?, translatedExcerpt?,
                    translatedContent?, error? }] } }
```

Items appear as they finish, so the plugin renders progress from the same response it polls for
results. `POST /api/jobs/{jobId}/cancel` marks unclaimed rows cancelled; already-running items
finish and are billed. Cancellation does not refund work already executed.

Both job routes are scoped to the caller's account; a `jobId` belonging to another account returns
`404`, never `403`.

## 4. Metered translate contract

`POST /api/plugin/international`, headers `Authorization: Bearer <api key>` and `Idempotency-Key`.

Request body — already parsed by `routes/translate.ts`:

```
{ sourceLang, targetLang, content, title?, excerpt?, tone?, format?: 'html'|'text', preserveTags?: string[] }
```

Response `200`:

```
{ data: { translatedTitle?, translatedExcerpt?, translatedContent, charactersUsed } }
```

Response `202 { data: { status: 'pending' } }` means a prior attempt with this key debited but its
outcome is unknown. The plugin retries the same key; it must not treat `202` as a failure or as a
new charge.

**Billing unit:** exact characters,
`mb_strlen(title) + mb_strlen(strip_tags(excerpt)) + mb_strlen(strip_tags(content))`, counted as
Unicode code points on both sides. Never the model's token count. The credit debit commits before
the model call, inside the same transaction that claims the idempotency key.

## 5. Estimate, sites, exceptions

**`POST /api/estimate`** — unmetered, API key required. Body
`{ content, source_lang, target_langs[] }`; response
`{ data: { estimates: { <lang>: { estimated_characters, confidence } }, total_characters } }`.
`target_langs` is de-duplicated server-side so a repeated language does not multiply the total.

**`POST /api/sites/register`** — idempotent on normalized `display_url`
(`lower(regexp_replace(display_url, '/+$', ''))`). Registering beyond the subscription's seat count
fails with `409 SEAT_LIMIT_REACHED` and names the limit and current count; this is the control that
stops one subscription serving unlimited sites. `PATCH /api/sites/{siteId}` updates the display URL
and last-seen timestamp. A disconnect frees the seat (auth spec §4.4).

**`POST /api/exceptions/sync`** — the plugin pushes its local exception list; the worker replaces
the account's set for that plugin and returns the stored set. **`GET /api/exceptions`** returns it,
paginated with `limit` (default 100, max 500). Exceptions are applied per field during translation,
after the model returns, so a term survives regardless of how the model rendered it. Backed by
`translationExceptions` (`schema.ts:218`).

## 6. Errors

The metered plane returns, per the auth spec §4.6: `401 INVALID_CREDENTIAL`,
`402 PLUGIN_NOT_ENTITLED`, `402 INSUFFICIENT_CREDITS`, `429` with `Retry-After`,
`400 IDEMPOTENCY_KEY_REQUIRED`, `400 IDEMPOTENCY_KEY_TOO_LONG`, `409 IDEMPOTENCY_KEY_REUSED`,
`413 REQUEST_TOO_LARGE` (1 MiB).

Plugin behavior: `401` wipes the local credential and drops to disconnected (auth spec §5.1);
`402 INSUFFICIENT_CREDITS` halts the batch and surfaces the top-up path — it never silently skips
posts; `429` honors `Retry-After` and resumes the same batch; `409` is a plugin bug (two different
payloads under one key) and is logged as such, not retried.

No API key, license key, or bearer value is ever rendered in admin UI or written to a log.

## 7. Out of scope

Connect handshake, social identity, Stripe checkout and webhooks — owned by
`2026-08-13-ecosystem-auth-design.md`. CF Queues, cron triggers, and any callback into WordPress.
`queue-regression.test.ts` is not to be edited — the job plane is built to pass it as written. The
`api.press.zone` DNS cutover — the owner performs it.

## 8. Testing

Per `GOLIVE.md`, every criterion is satisfied only by a passing automated test against a live
deployment, never by code inspection.

- Worker: a request without `Idempotency-Key` returns `400 IDEMPOTENCY_KEY_REQUIRED`; the same key
  replayed with the same body returns the stored result and debits once; the same key with a
  different body returns `409`; an over-budget account returns `402 INSUFFICIENT_CREDITS`.
- Seat limit: registering one site past the subscription's seat count returns `409
  SEAT_LIMIT_REACHED`; disconnecting frees the seat and the next registration succeeds.
- Exceptions: a synced term round-trips through a real translation with the term intact.
- Jobs: a submitted batch of N items reaches `completed` with N results; a handler failure retries
  via `requeueAfterFailure` and a `TerminalJobError` lands as `failed` with an error on that item
  only; a cancel leaves unclaimed items `cancelled`; a `jobId` from another account returns `404`.
- End-to-end from WordPress at `localhost:8080` against the production worker: a bulk run of N
  posts completes with N debits, and re-running the identical batch debits zero.
- `queue-regression.test.ts` still passes, unedited.

## Architecture decisions

- **Rejected: serve `/v1/*` from the worker.** It reverses the approved auth spec and would require
  rebuilding password auth and license-key auth, both retired.
- **Rejected: dropping the async plane for synchronous per-pair calls.** It would have pushed retry,
  resume and progress into the plugin and made a large bulk run depend on the browser staying open.
  The DO-alarm + DB-queue primitives already exist in `@platform-modules/jobs`, so the durable
  option is also the cheaper one.
- **Rejected: `ctx.waitUntil` for the job body.** It is not durable — an eviction loses the work
  after the debit has committed. The `jobs` table survives eviction; the alarm re-arms and resumes.
- **Rejected: a callback into WordPress.** Delivery would need its own retry and a publicly
  reachable dev URL. Polling reuses the response the plugin already needs for its progress UI.
