# Dev Mock Translation Provider Design

Audience: AI coding agents first.

## Goal

Add deterministic mock translation to production-hosted backend without letting request headers, request environment claims, or process environment choose provider. Preserve live Gemini behavior. Exercise API, Bull worker, Redis, Prisma, credits, webhooks, retry, timeout, malformed-output, and cancellation paths without Gemini traffic or sleep-based assertions.

## Security invariants

1. Server-side credential state chooses execution mode.
2. `License.execution_mode` is `live` by default. Only stored `mock` licenses select mock provider.
3. International plugin keeps current credential contract: license key plus `X-Plugin` plus activated `X-Site-URL`. No generic API key, mock header, query parameter, hostname heuristic, or `NODE_ENV` selector.
4. `authenticateLicense` loads `execution_mode`; downstream code receives trusted `req.license.executionMode` only after license, plugin, site activation, owner, account, and subscription checks pass.
5. Every `TranslationJob` snapshots `execution_mode`. Worker reads mode and scenario from Prisma by `jobId`; Bull payload never chooses provider.
6. Live credential cannot access mock control operations. Return `404 MOCK_CONTROL_NOT_FOUND`, not capability details.
7. Mock license issuance requires authenticated `AdminRole.ADMIN`, explicit `user_id`, `plugin: "international"`, and active subscription for same owner/plugin. Public checkout/onboarding always creates `live` licenses.
8. `execution_mode` is immutable after issuance. Key regeneration preserves it. Converting live↔mock requires revoke plus new credential.
9. Mock execution remains available on hosted production backend; process environment only provides defense-in-depth kill switch `MOCK_TRANSLATION_KILL_SWITCH=1`, never activation or selection.

## Credential and activation model

Use current `License` because International authentication already binds credential to plugin, activated site, owner, account, and subscription. Add:

- Prisma enum `TranslationExecutionMode { live mock }`.
- `License.execution_mode TranslationExecutionMode @default(live)`.
- `TranslationJob.execution_mode TranslationExecutionMode @default(live)`.
- Prisma enum `MockTranslationScenario { success pending_hold processing_hold rate_limited transient_failure permanent_failure timeout malformed_response }`.
- `TranslationJob.mock_scenario MockTranslationScenario?`.

Do not use `ApiKey.prefix`, `sk_test_`, client headers, `NODE_ENV`, backend hostname, source IP, or site URL patterns. Those are forgeable, ambiguous, or incompatible with International license-only authentication.

Admin license creation accepts `execution_mode`. `mock` creation applies stricter authorization and ownership checks. Existing creation callers omit field and receive database/service default `live`. List/detail responses expose mode to admins; public license validation does not expose it.

## Provider boundary

Create one narrow boundary matching three existing Gemini call shapes:

```ts
type TranslationProvider = {
  translate(content: string, sourceLang: string, targetLang: string, tone: Tone): Promise<TranslationProviderResult>;
  translateBulk(strings: BulkTranslationItem[], sourceLang: string, targetLang: string, tone: Tone): Promise<BulkTranslateResponse>;
  translateStructured(fields: StructuredTranslationInput, sourceLang: string, targetLang: string, tone: Tone): Promise<StructuredTranslationResult>;
};

type TranslationProviderErrorKind =
  | 'rate_limited'
  | 'transient'
  | 'permanent'
  | 'timeout'
  | 'malformed_response';
```

`GeminiTranslationProvider` adapts existing `geminiClient`; primary remains `gemini-3.1-flash-lite`, fallback remains `gemini-3.5-flash-lite`. No model change. Validate provider results before service accounting or persistence.

`getTranslationProvider(executionMode, context)` returns Gemini for `live`, mock for authenticated/persisted `mock`, and throws `MOCK_TRANSLATION_DISABLED` when kill switch is active. Never fall back from mock to Gemini or Gemini to mock.

## Request and job flow

### Ordinary translation endpoints

- Live license: unchanged Gemini path.
- Mock license: deterministic `success` mock path.
- Sync/bulk service calls receive trusted mode from route after authentication.
- Async submit copies trusted mode to `TranslationJob`; queue payload contains `jobId` and existing business data only.
- Worker reloads job, uses persisted mode/scenario, and rejects mismatch between DB owner and queue payload.

### Mock control API

Mount `/v1/dev/mock-translator` on all environments. Authentication and authorization make capability unavailable to live callers; no global mode switch exists.

- `POST /jobs`: authenticate license; require stored `mock`; validate normal async translation input plus `scenario`; create job with mode/scenario and enqueue atomically enough to avoid scenario-assignment race.
- `GET /jobs/:jobId`: require same license owner and mock mode; return job state, scenario, Bull attempt count, provider-call count, gate state, safe error code, and timestamps. Never return source content, translation body, callback secret, credentials, or Redis keys.
- `POST /jobs/:jobId/release`: release `pending_hold` by enqueueing exactly once or release `processing_hold` via Redis. Idempotent repeat returns current state.
- `POST /jobs/:jobId/reset`: only terminal jobs; remove that job's mock Redis keys and Bull record. Do not delete Prisma job/credit/webhook audit rows.

`pending_hold` creates persisted `pending` job without queue insertion. Release performs compare-and-set claim plus enqueue. Cancellation through existing endpoint deterministically produces `cancelled` before release; later release is a no-op/conflict and cannot enqueue cancelled job.

`processing_hold` queues normally. Mock provider records call and blocks on Redis notification until release or job cancellation/terminal state. Tests wait on control API state, never elapsed time.

## Scenario contract

| Scenario | Deterministic provider behavior | Expected job/accounting behavior |
|---|---|---|
| `success` | Stable target-language-marked output; preserve `__TAG_N__`, `__EXCPT_N__`, printf tokens, block markup, IDs, item order | `completed`; one credit deduction; mock model metadata; success webhook |
| `pending_hold` | Provider not called until release enqueues | `pending`; cancellable; zero credits before release |
| `processing_hold` | Provider call observed, waits on explicit Redis release | `processing`; no credits before release; completes once after release |
| `rate_limited` | Throw typed retryable error immediately | Three Bull attempts with zero backoff for mock jobs; one terminal `failed`; zero credits; one failure webhook |
| `transient_failure` | Throw typed retryable transport error immediately | Same retry contract as rate limit |
| `permanent_failure` | Throw typed non-retryable error | `job.discard()`; one attempt; terminal `failed`; zero credits |
| `timeout` | Throw typed retryable timeout immediately | Retry exhaustion without real timer; terminal `failed`; zero credits |
| `malformed_response` | Return schema-invalid result to validation boundary | Non-retryable `malformed_response`; no translation persistence; zero credits |

Mock provider returns fixed token counts and `model_used: "mock-translator-v1"`. Internal provider cost and customer cost follow current formulas; credit charge remains character-based. Tests use dedicated mock-license account and assert ledger delta exactly once. Live and mock jobs never deduplicate across modes; control-created scenarios never deduplicate with one another.

## Retry and terminal-state rules

Current worker marks failed and emits failure webhook inside each caught attempt. Refactor processor outcome handling:

1. Claim `pending → processing` with conditional update. Terminal job exits without provider call.
2. Retryable error: keep job `processing`, record safe last error/attempt observation, throw to Bull. Do not send failure webhook or set `completed_at` before final attempt.
3. Non-retryable error: call `job.discard()`, then finalize once.
4. Final attempt: conditional terminal write `processing → failed`; send one failure webhook.
5. Success: transaction conditionally commits `processing → completed` and credit deduction exactly once. Existing credit idempotency migration must be honored; add/verify unique deduction-per-job guard.
6. Cancellation: existing public contract remains pending-only. `pending_hold` makes race-free pending cancellation testable. Processing cancellation remains `409`.
7. Webhook failure never changes translation terminal state. Retry attempts never emit duplicate terminal webhooks.

## Cross-process coordination

Prisma is source of truth for mode, scenario, lifecycle, ownership, accounting, and terminal audit. Redis stores only ephemeral control state:

- `mock-translator:v1:job:<uuid>:gate`
- `mock-translator:v1:job:<uuid>:calls`
- `mock-translator:v1:job:<uuid>:attempts`

Use validated UUID, atomic Lua/MULTI operations where increment+publish must be indivisible, and bounded 24-hour TTL refreshed only while non-terminal. Release uses Redis pub/sub plus polling-safe key state so missed publish cannot deadlock worker. Worker shutdown aborts waits. No in-memory state coordinates API and worker.

## Test isolation

- Provision dedicated user, active International subscription, site activation, credits, and `mock` license through test fixture/admin setup.
- Generate unique site origin, `clientJobId`, source text, and callback receiver per test.
- Scope every control lookup by authenticated `license.id`, `user_id`, and `job.id`.
- Reset Redis/Bull state by job only. Keep database audit rows; fixture teardown deletes only records created under dedicated test principal in reverse FK order.
- Never reuse production customer credentials, URLs, callbacks, or credit ledger.
- Parallel tests own separate job IDs. No shared “next scenario” state.

## Authorization and input errors

| Condition | Response |
|---|---|
| Missing/invalid license, plugin, or site activation | Existing `401/403` license errors |
| Valid live license on mock control route | `404 MOCK_CONTROL_NOT_FOUND` |
| Mock license accessing foreign job | `404 JOB_NOT_FOUND` |
| Unsupported scenario/body | `422 VALIDATION_ERROR` |
| Release for non-held or terminal job | `409 INVALID_MOCK_TRANSITION` |
| Reset for non-terminal job | `409 JOB_NOT_TERMINAL` |
| Kill switch active | `503 MOCK_TRANSLATION_DISABLED`; no Gemini fallback |

## Observability

- Structured logs: `jobId`, `licenseId`, `executionMode`, `provider`, `scenario`, `attempt`, `terminalStatus`, `errorKind`, `processingTimeMs`. Never content or secrets.
- Metrics use bounded labels: provider=`gemini|mock`, scenario enum, outcome enum. Never job/license ID labels.
- Control status exposes timestamps/counters sufficient for event-driven polling with bounded test timeout.
- Startup log reports kill-switch state only. No mock credentials or site URLs.

## Rollout and rollback

1. Apply additive enum/columns with defaults; regenerate Prisma client.
2. Deploy API and worker together. New code reads persisted job mode; old jobs default `live`.
3. Create dedicated mock test principal/license only after health, live translation regression, and authorization tests pass.
4. Roll back code by setting `MOCK_TRANSLATION_KILL_SWITCH=1`, restarting API+worker, then deploying previous application image. Leave additive columns/migration in place; do not down-migrate production.
5. Revoke mock license and clear namespaced Redis keys if credential leaks. Live traffic stays Gemini throughout.

## Acceptance criteria

- Production-hosted API concurrently serves live license through Gemini and mock license through mock provider.
- No request-controlled provider selector exists.
- API and worker agree through persisted job mode/scenario.
- All five job statuses and all eight scenarios are deterministically observable without sleeps.
- Retry, timeout, malformed output, cancellation, credits, webhooks, authorization, isolation, metrics, kill switch, and rollback behavior have executable tests.
- Gemini model constants and default live behavior remain unchanged.

## Architecture decisions

- Keep provider boundary: two real implementations and API/worker consumers justify seam.
- Persist mode/scenario on job: worker must not trust queue/client state and must survive restarts.
- Keep Redis gate store narrow: cross-process release/counters need it; ownership and lifecycle stay in Prisma.
- Reject global opt-in: cannot distinguish simultaneous live/test requests on hosted backend.
- Reject second test-token header: duplicates license authentication and becomes client-supplied capability selector.
- Keep existing cancellation semantics: pending-only is public contract; `pending_hold` makes it deterministic.
