# ESI operations The server ESI adapter uses the fixed trusted base `https://esi.evetech.net` and pins `X-Compatibility-Date: 2026-08-18` (`ESI_COMPATIBILITY_DATE`). It exposes a fixed allowlist of typed safe reads: character assets (paged GET), character asset names and locations (bounded read-only POST), character wallet balance (unpaged GET), and global market prices (unpaged GET), and character-scoped private structure resolution (fixed GET). Callers cannot provide an arbitrary ESI URL, host, method, or POST body. ## Asset synchronization `EsiClient.getAssets(characterId, accessToken, authorizationKey)` fetches page one, reads `X-Pages`, fetches all remaining pages, de-duplicates by `item_id` while checking for duplicates, and revalidates page one after the other pages. A changed page count, duplicate ID, malformed page, failed request, or changed first page fails the operation. The caller must not persist a returned partial result: the method returns only a complete bounded-read snapshot with `consistency: "bounded-non-atomic"`. Asset IDs are retained as decimal strings, and the response is parsed with `lossless-json` so an int64 cannot be rounded by JavaScript. This is a stability check, not a transaction. ESI's paginated endpoint does not promise a true multi-page snapshot while its cache expires between page requests. Only page one is conditionally revalidated: revalidating every page would multiply remote calls up to the bounded page cap and still could not make ESI's independently served pages atomic. The explicit `bounded-non-atomic` coverage state prevents this adapter from claiming transactional consistency. A caller should stage pages and replace the persisted Convex snapshot only after `getAssets` resolves. Convex persistence is intentionally outside this adapter. The cache is private to an `EsiClient` and keyed by a structured authorization context: application user/principal, character, sorted scopes, `grantRevision`, `authorizationEpoch`, and committed `credentialVersion`. Production callers pass the current counters: ```ts { userId, principal, characterId, scopes, grantRevision, authorizationEpoch, credentialVersion } ``` Tokens are never used as cache keys or emitted in metrics. Private reads reject legacy string keys and require all structured fields, including the matching character, required scope, `grantRevision`, `authorizationEpoch`, and `credentialVersion`. Changing any authority counter therefore cannot reuse a prior private body or snapshot. Responses retain their raw text, ETag, Last-Modified, and cache metadata. Fresh responses use the server TTL; stale responses send `If-None-Match` and `If-Modified-Since`, and a 304 reuses the cached body for both GET and declared read-only POST operations. Canonical helper bodies are sorted decimal int64 JSON literals, so ordering variants share one cache identity; operation/method/body cannot collide. Helper requests accept 1..1000 unique positive signed-int64 IDs and reject unsafe/duplicate/oversize input without Number coercion. Each operation also enforces a response `Content-Length`/stream byte cap and result-row cap before retaining a cache entry; asset pages and aggregate snapshots have separate bounds (1,000 pages, 5,000 rows/page, 50,000 rows/snapshot). `Cache-Control`, `Expires`, `Date`, and `Age` are honored. `no-store` responses retain neither body nor validators. Requests have one deadline covering response-body reads and bounded retries for transient network/5xx/420/429 responses. `Retry-After` (seconds or HTTP date) and the legacy ESI error-limit reset are respected. Exhausted new limiter buckets are cooled down per authorization context and group; the legacy error limiter is cooled down globally. Query a private cooldown with `client.getCooldown(authorizationKey, characterId)` or the public limiter with `client.getPublicCooldown()`. A server delay that exceeds the remaining request budget returns a typed rate-limit error with `retryAt`, `retryAfterMs`, and scheduler-readable `cooldown` rather than retrying early. Response cancellation is abort-first and non-blocking, so cleanup cannot extend a deadline. Process-local retention is bounded by weighted response bytes and aggregate snapshot rows; `client.getCacheStats()` exposes current usage and both budgets. Eviction is oldest-insertion deterministic. 401 and 403 are never blindly retried. OAuth exchange/refresh remain in `EveSso` and never use these safe-read retries. ## Asset names and locations `EsiClient.getAssetNames` and `getAssetLocations` call fixed character helper routes with the asset scope. Their outbound body is canonicalized and serialized as numeric JSON literals (including IDs above `2^53`). Typed observations include requested IDs, returned values, explicit `missing` IDs, `complete`, and retrieval/expiry provenance. A missing helper result is never silently represented as complete enrichment. Duplicate or unexpected IDs, malformed names, non-finite coordinates, and malformed arrays fail closed. Deterministic transport fixtures live under `tests/fixtures/esi/` so int64 names, finite positions, signed balances, and versioned route/scope metadata are parsed from raw JSON rather than rounded JavaScript literals. Asset helper metadata cites pinned OpenAPI evidence; wallet metadata cites the current official OpenAPI retrieval and explicitly records that it is not an immutable historical capture. ## Private structure resolution `EsiClient.getStructure` calls the pinned `GET /universe/structures/{structure_id}` operation with `esi-universe.read_structures.v1`. The route and scope are pinned in `docs/research/eve.md` and the captured OpenAPI evidence: a 200 is character-scoped ESI access evidence, while 403 and 404 remain explicit unresolved outcomes. IDs are validated and serialized as decimal text without `Number` coercion. The response retains the optional name, solar-system, owner/type IDs and finite position with retrieval/expiry provenance. The T08 cache includes the complete authority and reference version; it never stores tokens and never derives ACL membership or docking permission (`unknown` is the only permission value). Deadlines, rate limits, and transport failures remain typed errors for T07 retry/cooldown handling. ## Wallet balance `EsiClient.getWalletBalance` reads the unpaged character wallet route with the wallet scope. The adapter returns only a transport-level observation with signed integer cents in `amount` (via T04's half-away-from-zero `decimalIskToCents` normalization) and `retrievedAt` / `expiresAt`; sub-cent source values are rounded by that approved policy, not rejected. Persistence, scheduling, and UI/service orchestration belong to later work. Negative and very large balances remain lossless, while malformed/non-finite numeric bodies are rejected. The tracked fixture metadata records the official OpenAPI URL, operation ID, 120-second TTL, `char-wallet` limiter, response SHA-256 `9a9a0f4af77216300e960cfd2576c9baac085a8170022e40c928c28622d80fc6`, and the 2026-09-19T19:30:12Z retrieval. This is a current retrieval using the pinned compatibility date, not an immutable historical capture from 2026-08-18. Official references: [ESI OpenAPI](https://esi.evetech.net/meta/openapi.json?compatibility_date=2026-08-18), [versioning](https://developers.eveonline.com/docs/services/esi/overview/), [caching](https://developers.eveonline.com/docs/services/esi/overview/#caching), and [rate limiting](https://developers.eveonline.com/docs/services/esi/rate-limiting/). ## Market estimates `EsiMarketEstimates` reads public `GET /markets/prices`. It uses only `average_price`; a missing average price is omitted and `adjusted_price` is never a fallback. The returned `PriceSnapshot.source` includes the pinned compatibility date, `asOf` is the retrieval time (ESI supplies no source timestamp), and prices are integer ISK cents represented as validated decimal strings. The route is a cached global reference estimate, not a regional or structure order book. Metrics supplied through `EsiClient`'s `observe` callback include endpoint, status, retry/cache state, and redacted rate-limit headers. They do not include access tokens, authorization keys, or response bodies.