fix(cache)+docs(adr-0005): D15 — expand cache key composition to include max_tokens/top_p/stop/tool_choice

cold-audit catch from 2026-05-23

Cold-audit Finding 7 (P2 cache correctness). ADR 0005 § Cache key
composition (v1.0) listed 7 fields. ADR 0003 § Optional fields added
`max_tokens`, `top_p`, `stop`, `tool_choice` as IR-carried fields
that affect model output. Pre-D15 cache key omitted those 4 —
identical IRs differing only in those fields collided on the same
cache key but produced legitimately different outputs. Concrete
hazard: a request with `max_tokens: 100` could receive a cached
response generated by `max_tokens: 4000` — wrong content (truncated
or unexpectedly extended).

Coordinated change across two layers, single commit per the ADR-with-
code pattern established by D11:

1. docs/adr/0005-cache-cross-provider.md — Amendment 2 (top of doc,
   matching D11 Amendment 1 placement convention)
   - Documents the 4 missing fields + concrete failure mode
   - Expands the v1.0 cache key composition spec
   - Documents the `?? null` collapsing semantics (explicit-null
     equals absent — consistent with existing temperature/response_format)
   - Adds forward-looking note: future IR field additions must be
     evaluated for cache-key inclusion at addition time; default is
     include unless explicit rationale documents safe omission

2. lib/cache/keys.mjs — append the 4 fields to `keyObj` after the
   existing 7 (preserves field ordering; pre-D15 cache entries are
   forced misses on first request — schema forward-compatible)
   - `max_tokens: ir.max_tokens ?? null`
   - `top_p: ir.top_p ?? null`
   - `stop: ir.stop ?? null`
   - `tool_choice: ir.tool_choice ?? null`
   - Updated file-level docblock + function-level JSDoc + @param ir
     enumeration to reflect new schema (the @param fix also closes a
     pre-existing partial-list issue that omitted cache_control)

3. test-features.mjs — 5 new tests in Suite 9:
   - max_tokens differs → different key
   - top_p differs → different key
   - stop differs → different key
   - tool_choice differs → different key (covers string-form
     `'auto'` vs `'none'`; object-form coverage left for future
     defense-in-depth — slot existence is verified by the string case)
   - Both-absent stability check (`?? null` collapsing produces
     identical keys for omitted-vs-omitted)

Tests: 292 → 297 (+5). No hash constants pinned in existing tests
(grep verified) so no pre-D15 tests required updating; assertions
were already shape-based (`assert.equal` / `assert.notEqual` on
hash strings, never specific hex values).

Authority:
- ADR 0003 § Optional fields — source of the 4 IR field definitions
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0005's stated invariant: "different output → different cache
  entry" — the addition restores compliance with this invariant
- OpenAI /v1/chat/completions spec — confirms the 4 fields affect
  output:
  https://platform.openai.com/docs/api-reference/chat/create
- ALIGNMENT.md Rule 2(c) spirit — ADR amendment + code change land
  in same merge (D11 precedent established this pattern for
  Provider-contract changes; D15 applies same pattern for cache-key
  schema changes)
- CC 开发铁律 v1.6 § 10.x — Cold Audit caught this; diff-review pass
  that approved original ADR 0005 did not cross-reference all ADR
  0003 optional fields

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Folded the one minor (`@param ir`
JSDoc stale partial list) before commit — same JSDoc precision logic
that drove D11's B1 fold-in. Two remaining non-blocking suggestions
(tool_choice object-form test coverage; D11-vs-D15 amendment structure
template) tracked as defense-in-depth opportunities, not required for
spec compliance.

Reviewer's highest-value verification: field ordering preserved.
keyObj at lib/cache/keys.mjs:203-217 reads exactly:
`{provider, model, messages, tools, temperature, response_format,
cache_control, max_tokens, top_p, stop, tool_choice}` — existing 7
in unchanged positions, 4 new appended at end. JSON.stringify on
Node 18+ preserves insertion order; SHA-256 hash deterministic
across runs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 11:24:12 +10:00
co-authored by Claude Opus 4.7
parent a7085d9718
commit 8ae77c3ae3
3 changed files with 79 additions and 5 deletions
+47
View File
@@ -1206,6 +1206,53 @@ describe('Cache layer — computeCacheKey (Suite 9)', () => {
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.equal(k1, k2);
});
// ── D15 Tests: Amendment 2 — max_tokens, top_p, stop, tool_choice ────
// ── Test 9: max_tokens distinguishes ─────────────────────────────────
it('computeCacheKey differs when max_tokens differs (D15 Amendment 2)', () => {
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }], max_tokens: 100 });
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], max_tokens: 4000 });
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.notEqual(k1, k2);
});
// ── Test 10: top_p distinguishes ──────────────────────────────────────
it('computeCacheKey differs when top_p differs (D15 Amendment 2)', () => {
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }], top_p: 0.5 });
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], top_p: 0.9 });
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.notEqual(k1, k2);
});
// ── Test 11: stop distinguishes ───────────────────────────────────────
it('computeCacheKey differs when stop sequences differ (D15 Amendment 2)', () => {
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }], stop: ['\n'] });
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], stop: ['END', '\n'] });
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.notEqual(k1, k2);
});
// ── Test 12: tool_choice distinguishes ───────────────────────────────
it('computeCacheKey differs when tool_choice differs (D15 Amendment 2)', () => {
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }], tool_choice: 'auto' });
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }], tool_choice: 'none' });
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.notEqual(k1, k2);
});
// ── Test 13: undefined max_tokens stable (both absent → same key) ────
it('computeCacheKey is identical when max_tokens absent in both requests (D15 Amendment 2)', () => {
const ir1 = makeIR({ messages: [{ role: 'user', content: 'x' }] });
const ir2 = makeIR({ messages: [{ role: 'user', content: 'x' }] });
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir1);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.equal(k1, k2);
});
});
describe('Cache layer — extractCacheControlMarkers + hasCacheControl (Suite 9 cont.)', () => {