fix(server): fold the alias map into CONFIG_EPOCH so alias repoints invalidate the cache

Cache keys hash the model string exactly as the client sent it — `const model =
parsed.model || modelsConfig.aliases.sonnet` (server.mjs:2806), which then goes
straight into `cacheHash(model, ...)`. A request for "opus" is therefore cached
under the literal string "opus", not under the canonical id it resolved to.

models.json is read once at boot, so repointing an alias only takes effect on
restart — and the response cache is SQLite-backed (keys.mjs getCachedResponse
queries the `response_cache` table), so it OUTLIVES that restart. CONFIG_EPOCH
folded four config values into every cache key for exactly this hazard (#176 /
#177) but omitted the alias map. Consequence: an operator running
CLAUDE_CACHE_TTL > 0 who repoints an alias keeps being served the OLD model's
answers under that alias until TTL expiry — silently defeating the repoint.

Fix: add `modelsConfig.aliases` to the epoch digest, so any alias change is an
instant whole-cache invalidation. This is the mechanism #177 already
established for the other config inputs, applied to the one it missed; it
covers the normal, structured and singleflight paths at once because they all
key off the same epoch, and it needs no change at the three call sites.

Reproduced end-to-end on the real server, both directions (NODE_ENV=test +
OCP_DIR_OVERRIDE, isolated SQLite store, CLAUDE_CACHE_TTL=600000):

  1. boot with aliases.opus = claude-opus-4-8, request model:"opus"
     -> claude_spawned model=claude-opus-4-8, response cached
  2. stop, repoint aliases.opus -> claude-sonnet-5, restart
  3. same request again:
       on origin/main : cache_hit    (stale Opus 4.8 answer, no spawn)
       on this branch : cache MISS -> claude_spawned model=claude-sonnet-5

Default CLAUDE_CACHE_TTL is 0 (caching off), so shipped default behavior is
unaffected; this bites only operators who deliberately enabled the cache.

cli.js citation: NOT APPLICABLE — no cli.js-derived wire behavior is touched.
Scope justified under ALIGNMENT.md Rule 2 as OCP-owned cache bookkeeping:
cli.js has no response cache and no equivalent operation, and the spawn
arguments and protocol are unchanged.

Found by an independent codex review (gpt-5.6-sol, reasoning=high) of the
Opus 5 alias repoint in #192; verified first-hand before applying rather than
taken on the reviewer's word.

Tests: 449 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
This commit is contained in:
2026-07-26 22:13:45 +10:00
co-authored by Claude Opus 5
parent a32bc9af17
commit 042dfa63b2
+8 -1
View File
@@ -393,8 +393,15 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
// API) are excluded because a const epoch cannot track them; truncation also only drops
// context rather than changing the instruction set.
// The ALIAS MAP is folded in for the same reason. Cache keys hash the model string exactly as
// the client sent it (`const model = parsed.model || aliases.sonnet`), so a request for "opus"
// is cached under the literal "opus" — not under the canonical id it resolved to. models.json is
// read once at boot, so repointing an alias (e.g. opus: 4-8 -> 5) only takes effect on restart,
// and the SQLite response_cache outlives that restart. Without the alias map in the epoch, an
// operator running CLAUDE_CACHE_TTL > 0 keeps being served the OLD model's answers for the alias
// until TTL expiry — silently defeating the repoint. Same bug class as #176.
const CONFIG_EPOCH = cryptoCreateHash("sha256")
.update(JSON.stringify([SYSTEM_PROMPT, SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
.update(JSON.stringify([SYSTEM_PROMPT, SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT, modelsConfig.aliases]))
.digest("hex").slice(0, 16);
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's