# Admin Dual-Layer Cache — request

Audience: AI coding agents first.

**Goal:** Make eligible International Press Zone translation routes paint scoped browser snapshots immediately, serve fresh or soft-stale plugin snapshots without expensive controller SQL, and refresh stale data safely for the current page and later clients.

**Canonical design:** `docs/specs/2026-08-10-admin-dual-layer-cache-design.md`. Implement every contract there. This request narrows files and acceptance; it does not replace the design.

## Context

Existing admin-snappy code already provides:

- Browser memory/localStorage stale-while-revalidate in `admin/src/utils/DataStore.js`.
- Permission-safe REST response short-circuiting in `includes/API/RestResponseCache.php`.
- Canonical browser/server route policy in `includes/Performance/AdminDataPolicy.php`.
- Generation-token invalidation in `includes/Performance/CacheInvalidation.php`.
- Redis, Memcached, and transient backends in `includes/Performance/ObjectCache.php`.

Current fresh server hits skip route callbacks. Current hard-only 60-second expiry makes misses block on translation SQL. Extend these seams; NEVER add a duplicate cache, lock service, route-specific polling implementation, or new queue dependency.

## Files

Modify only files required by the approved design, expected primarily:

- `includes/API/RestResponseCache.php` — soft/hard envelopes, observability, deterministic deferred refresh, internal dispatch, safe explicit storage.
- `includes/Performance/AdminDataPolicy.php` — explicit soft/hard TTL and async-refresh eligibility/cardinality policy.
- `includes/Performance/ObjectCache.php` — atomic add and owner-safe conditional release for every existing backend.
- `includes/Performance/CacheInvalidation.php` and exact writer paths discovered by inventory — successful-commit generation rotation.
- `includes/Core/Plugin.php` — register refresh hook only if existing construction does not already cover it.
- `admin/src/utils/DataStore.js` and its shared API response seam — bounded stale follow-up and metadata propagation.
- Existing translation controller/importer/worker/CLI mutation files only where writer inventory proves missing invalidation.
- Focused PHP, admin, local-integration, concurrency, and E2E tests required by Acceptance.

Do not touch unrelated admin-snappy geometry, Overview order, removed System Health/System Status, or removed plugin dark mode.

## Contracts

### Policy

Every cacheable descriptor declares both:

- `serverSoftTtl`: integer seconds.
- `serverHardTtl`: integer seconds.

Remove legacy `serverTtl` atomically. Merge overlapping descriptors with the minimum soft and minimum hard TTL. Require `0 < soft < hard`; malformed policy disables server caching for that request.

Translation list defaults:

- Soft TTL: `120`.
- Hard TTL: `300`.
- Pending descriptor TTL: `300`.
- Worker execution lease: `120`.

Do not raise hard TTL until all relevant writers are inventoried and covered by successful-commit invalidation tests.

### Cache identity and envelope

Retain schema version, plugin version, namespace/route, blog, locale, user/site scope, descriptor keys, registered defaults, normalized allowlisted query values, and every generation token.

Snapshot envelope carries creation/freshness timestamps, response status/data, replay-safe headers, and schema identity until hard expiry. Exclude cache observability and rate-limit headers from stored headers.

Recompute on every response:

- `X-IPZ-Cache: hit|stale|miss`
- `X-IPZ-Cache-Age: <whole seconds>` for hit/stale

Never expose cache keys, generation tokens, user IDs, or query values.

### REST delivery

Permission callbacks run before every replay.

- Fresh hit: replay immediately; no route callback or refresh.
- Soft-stale hit: replay immediately; attempt one bounded deterministic deferred refresh.
- First/hard miss: execute route callback synchronously and store only accepted success responses.
- Cache/backend failure: preserve correct live/stale API behavior; never turn cache availability into API availability.

Keep existing nonce, unknown-query, unsafe-header, non-GET, unencodable-value, error, non-success, permission, user/blog/locale, and rate-limit protections.

### Deferred refresh

Seam:

`RestResponseCache::refresh(string $descriptorId): void`

Descriptor ID is a deterministic HMAC/digest of canonical identity plus generation. Cron arguments contain only this opaque ID. Check `wp_next_scheduled()` and error-capable `wp_schedule_single_event(..., true)`. Every successful schedule MUST attempt nonblocking `spawn_cron()`.

Async-refresh eligibility:

- Scope-listed translation list routes only; no detail routes.
- Empty `search` only.
- `page` 1–20.
- `per_page` 1–100.
- Serialized descriptor at most 8192 bytes.
- At most 32 pending descriptors per user/blog namespace, enforced through 32 deterministic hash slots without scans.
- Descriptor lifetime at most 300 seconds.

Descriptor stores only schema/plugin version, canonical route, normalized allowlisted query, blog/user/locale, expected key digest, owner token, and timestamps. Never persist nonce, cookie, authorization header, request body, secret, or search text.

Worker MUST:

- Reject malformed/expired/version-mismatched descriptors.
- Validate user existence and blog membership.
- Restore blog, user, and locale with nested guaranteed cleanup.
- Acquire a distinct execution lease.
- Abort before SQL if generation changed, snapshot became fresh, or another worker holds the lease.
- Dispatch through `rest_do_request()` so normal matching/defaults/sanitization/permission/filters run.
- Use a server-owned object marker, never a caller-controlled header/parameter.
- Suppress both automatic cache replay and automatic `rest_request_after_callbacks` storage for the marked request.
- Avoid consuming external user rate allowance while retaining policy-based rate-limited classification for later hits.
- Recompute generation/key after dispatch.
- Explicitly validate and store only after the post-dispatch check.
- Catch `Throwable` and restore context on every path.

### Atomic backend operations

Seams:

- `ObjectCache::add(string $key, mixed $value, int $ttl): bool`
- `ObjectCache::compareAndDelete(string $key, string $ownerToken): bool`

Redis uses set-if-absent plus Lua compare/delete. Memcached uses native add plus CAS-safe owner replacement/release. Transient fallback uses non-autoloaded atomic `add_option()` plus conditional SQL against the exact serialized owner value. Expired reclamation is conditional, never read/delete/write. Backend exceptions return false.

Pending descriptors/slots use owner-safe conditional deletion. Worker leases expire naturally and are never manually released.

### Browser follow-up

Propagate cache metadata through the shared API/DataStore seam. On `X-IPZ-Cache: stale`, schedule ordinary non-bypass follow-up GETs for the same store key:

- Dedupe across subscribers.
- Abort on teardown or invalidation.
- Retry at 750ms and 2000ms.
- Stop after two attempts or first non-stale response.
- Reconcile current page only when payload changes while preserving focus, dirty input, selection, scroll, menus/details, selected rows, charts, and modal state.

Pages MUST NOT add route-specific polling or cache-bypass controls.

### Invalidation

Inventory every post, option, translation, string, language, worker, CLI, importer, and direct custom-table writer affecting eligible responses. Rotate exact policy generation tags only after successful commit. Never rotate for failed/rolled-back mutation or GET. Check generation before and after worker dispatch; never store obsolete refresh output.

### Live exclusions

Never persist, serve stale, schedule, or refresh:

- Active jobs.
- Polling/progress.
- Licensing/entitlements.
- Checkout/payment sessions.
- Migration live state.

## Out of scope

- No Action Scheduler or new service.
- No literal zero-SQL promise for transient fallback; avoid expensive translation controller queries on fresh/stale hits.
- No server-side cache management UI.
- No unrelated refactor, formatting sweep, or policy broadening.
- No testing against dev1; it is a customer deployment target only.
- No Claude/Opus/Sonnet agents. Implementation workers use GPT-5.6-Terra; review/security/audit workers use GPT-5.6-Sol.

## Acceptance

### PHP unit

Run each self-contained PHP test file in a separate process to avoid WordPress stub redeclaration:

- `vendor/bin/phpunit --no-configuration tests/unit/API/RestResponseCacheTest.php`
- Exact focused ObjectCache atomic-operation test file.
- `vendor/bin/phpunit --no-configuration tests/unit/Performance/AdminDataPolicyLocalizationTest.php`
- `vendor/bin/phpunit --no-configuration tests/unit/Performance/CacheInvalidationTest.php`
- Exact writer-invalidation tests added by inventory.

Expected: clean PASS proving fresh/stale/hard behavior, observability, deterministic scheduling, descriptor/slot bounds, internal storage suppression, pre/post generation checks, user/blog/locale restoration, policy-based rate limiting, owner-safe backend behavior, and unchanged legacy safety contracts.

### Admin unit

Run:

`cd admin && npm test -- --run`

Expected: clean PASS, including localStorage immediate paint, stale metadata follow-up, dedupe, abort, retry cap, current-page reconciliation, unchanged-payload stability, and live descriptor nonpersistence.

### Local/concurrency integration

Against local WordPress only, prove:

- First request executes controller.
- Fresh second request does not.
- Soft-stale response returns before deferred controller execution.
- Concurrent stale requests produce one pending event and one worker execution.
- Mutation before/during worker prevents obsolete storage.
- Revoked users, multisite context restoration, disabled cron, lease expiry/reacquisition, and transient parallel acquisition are correct.
- Redis/Memcached concurrency cases run when configured; otherwise report explicit justified skips.

Isolated PHPUnit stubs are not concurrency proof.

### Browser

Run Chromium and Firefox only through `~/.claude/bin/e2e-remote` against local WordPress `http://100.126.128.50:8081`.

Expected: translation routes paint scoped localStorage data immediately, avoid layout shift, preserve active UI state, receive plugin soft-stale responses without expensive query delay, and reconcile worker-refreshed data on the current page. Never target dev1 and never bypass the browser guard.

### Full gate and delivery

- `cd admin && npm run build`
- Run the plugin's clean PHP/admin gates required by `.claude/agents/expert.md`.
- Resolve or explicitly justify every warning and prevent-band signal.
- Commit locally with one terse imperative sentence and no co-author.
- Land using project-established workflow without force/no-verify.
- Build release, deploy through the existing secure tool, and verify archive/version/asset identity only on dev1.
- User-visible functional/browser proof remains local WordPress plus approved cluster browsers.

## Review-fix execution — 2026-08-11

Audience: AI coding agents first.

- Outcome: preserve dual-layer cache contract after verified review blockers.
- Status: DONE; review blockers, residual findings, and candidate gates are closed.
- Source request: correct verified blockers in listed changed implementation files; preserve unrelated WIP.
- Acceptance delta: descriptor canonicalization retains registered empty `search`; volatile generation survives replacement/delete failure; internal refresh bypasses external rate allowance; partial rollback invalidates mutated cache state; transient lock reclamation invalidates options cache; fixture uses `serverSoftTtl`/`serverHardTtl`; database queue claims compare pending state atomically; successful async submissions require a non-empty backend `jobId`.
- Preserved WIP/ref/path: worktree `/home/user/Projects/Press.zone/wordpress/wp-content/.worktrees/admin-snappy-contract-fix/plugins/international-press-zone`; do NOT modify unrelated paths.
- Constraints: run focused PHP, full admin, and candidate-wide gates in Acceptance. NEVER force or bypass hooks.
- Steps: inspect code/tests → patch verified contracts → run focused acceptance → commit required files → run candidate-wide gate → record outputs.
- Current receipt: commits `3f3aa103352de5b0fc205ace227d569b1a3c8b6e`, `924f11540`, and `a6c6b6977` close the seven review blockers, stale workflow expectation, atomic database queue claim, and missing async backend job ID. PASS: configured PHPUnit `25/119`; REST cache `60/205`; ObjectCache `6/21`; policy localization `18/246`; CacheInvalidation `34/172`; rollback `1/4`; controller/domain/translation/workflow invalidation `23/51`, `6/15`, `14/25`, `20/45`; QueueManager standalone; admin lint; admin `31 files/540`; admin build; full baselined PHPCS; full PHPStan. Admin WARN/ERROR console lines are deliberate assertions of degrade/error branches; expected PHP error_log lines exercise failure paths. Pre-commit exclusions are benign: test files are outside PHPCS/PHPStan configured scopes and have focused runtime coverage; no root JS package, Trivy binary, or root composer.lock exists.
- Next executable action: run UJ-021 through UJ-025 against local WordPress via `e2e-remote`; never browser-test dev1.
