fix+docs: D34 — FINAL batch (F1+F4+F7+F8); audit cadence stops

cold-audit catch from 2026-05-24 (round 6 — FINAL)

This is the closing D-day of a 24-day round-1→round-6 audit cycle.
After this commit + the 9 round-6 follow-up issue filings, no more
audit rounds. Trajectory R1=17 → R2=13 → R3=13 → R4=10 → R5=12 →
R6=14 — the method did not converge; owner chose Option A (focused
batch of most consequential items, then STOP).

Changes (6 files, +138 / -47):

**Code changes**

1. lib/cache/keys.mjs (+14/-?) — F4 P2 cache key array-field
   normalization:
   - New `normalizeArrayField` helper: `(Array.isArray(v) && v.length === 0) ? null : (v ?? null)`
   - Applied to `tools` and `stop` in computeCacheKey
   - Now `tools: []` and `tools` omitted produce IDENTICAL cache
     keys (and same for `stop: []` vs omitted). ADR 0005 Amendment 2's
     own claim that "[] and undefined share a cache entry" was
     empirically FALSE pre-D34; round-6 reviewer verified hashes
     differ. The fix makes the claim literally true at the
     key-composition layer.

2. lib/providers/base.mjs (+16/-?) — F7 P2 dead error code removal:
   - `QUOTA_EXHAUSTED` removed from PROVIDER_ERROR_CODES
   - `RATE_LIMITED` removed from PROVIDER_ERROR_CODES
   - v0.1 live codes: SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING,
     SPAWN_TIMEOUT
   - Comment block documents removal + cites ADR 0004 Amendment 3

3. lib/fallback/engine.mjs (+29/-?) — F7 P2 sibling:
   - HARD_TRIGGER_CODES: QUOTA_EXHAUSTED + RATE_LIMITED removed
   - SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING(false), SPAWN_TIMEOUT
     remain
   - evaluateHardTriggers HTTP-status branches KEPT (option (b)) with
     forward-compat comment: "v0.1 plugins never attach statusCode;
     branches reserved for v1.x when plugin gains HTTP-status parsing"

4. test-features.mjs (+93) — F4 + F7 test work:
   - 4 new F4 regression tests (tools:[] vs undefined, stop:[] vs
     undefined, tools:[] vs null, tools:non-empty vs undefined sanity)
   - ~14 integration test code-swap edits (QUOTA_EXHAUSTED →
     SPAWN_FAILED, RATE_LIMITED → SPAWN_FAILED) preserving original
     hard-trigger semantic
   - 2 dead unit tests for QUOTA_EXHAUSTED/RATE_LIMITED removed
     (tombstone comment retained for audit trail)

**ADR amendments (docs-only, no code change)**

5. docs/adr/0004-fallback-engine.md (+12) — F7 Amendment 3:
   - Documents the v0.1 hard-trigger code narrowing
   - 4 live codes listed explicitly
   - Captures evaluateHardTriggers HTTP-status branch retention rationale
   - v1.x re-activation path: plugin gains HTTP-status parsing →
     re-add codes → branches activate naturally

6. docs/adr/0005-cache-cross-provider.md (+21) — TWO amendments + 1
   prior-amendment update:
   - **Amendment 6 (F1 P1)**: Formal v1.x deferral of D4 streaming
     singleflight. Buffered path (executeHopFn) uses cacheStore.getOrCompute
     and participates in D4 fully. Streaming cache-miss path
     (server.mjs:609-741) bypasses singleflight — N concurrent identical
     streamers each spawn fresh. v0.1 trade-off accepted for
     personal/family scale; v1.x design ADR needed for tee-streaming +
     per-key inflight Map. Cross-references CLAUDE.md release_kit.
     phase_rolling_mode as the deferral pattern precedent.
   - **Amendment 7 (F8 P2)**: Documents the v0.1 conservative cache-key
     posture: includes all IR fields including those plugins discard
     (anthropic/codex/mistral drop temperature/max_tokens/top_p/stop/
     tools/tool_choice at spawn). Consequence: 2 requests with different
     temperature produce identical CLI output (CLI ignores) but
     different cache keys → spurious miss. Trade-off justified:
     spurious miss > spurious hit. v1.x forward path:
     per-plugin cacheKeyFields contract extension (ADR 0002 amendment
     needed). 3 implementation subtasks enumerated for the v1.x PR.
   - **Amendment 2 update (F4)**: heading renamed to "Note on
     null-coalescing AND array normalization"; body documents the
     new normalizeArrayField helper; quotes the regression test name.

Tests: 414 → 416 (+4 F4 regression, -2 F7 dead, +0 net from F7
integration rewrites).

Pre-commit fold-in: NONE — D34 reviewer APPROVE with all 4 suggestions
non-blocking/cosmetic.

Authority:
- ADR 0005 Amendment 2 invariant restored at code level (F4)
- ADR 0005 Amendment 6 formalizes F1 streaming-singleflight deferral
  per the same pattern as D22 ADR 0004 Amendment 2 soft-trigger
  deferral
- ADR 0005 Amendment 7 documents F8 conservative posture as v0.1
  intentional design (not an accident)
- ADR 0004 Amendment 3 narrows v0.1 trigger taxonomy (F7)
- Round-6 cold audit findings F2 / F3 / F6 / F9 / F10 / F11 / F12 /
  F13 / F14 filed as GitHub issues after this commit (NOT in scope)
- CC 开发铁律 v1.6 § 10.x — final round of the audit cadence

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Critical depth checks:
- B5 over-normalization: verified `normalizeArrayField` only applies
  to `tools` and `stop`; `response_format: {}` and `tool_choice: ''`
  unaffected (Array.isArray guard)
- C10 test cleanup: 21 references reconciled (3 tombstone, 18
  rewrites/removals); integration test rewrites preserve hard-trigger
  semantics (QUOTA_EXHAUSTED → SPAWN_FAILED is also a hard trigger,
  so fallback advancement behavior unchanged)

---

**End of audit cycle.** 24 D-days shipped from D10 (P1 hardening) through
D34 (final batch). 6 cold-audit rounds executed; 78+ findings raised;
~50 closed via implementation; ~28 deferred to GitHub issues / v1.x
ADR amendments. v0.1 tag remains explicit-maintainer-action per
phase_rolling_mode policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:33:48 +10:00
co-authored by Claude Opus 4.7
parent f784fdb947
commit 60570ef074
6 changed files with 138 additions and 47 deletions
+64 -29
View File
@@ -1500,6 +1500,47 @@ describe('Cache layer — computeCacheKey (Suite 9)', () => {
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.equal(k1, k2);
});
// ── F4 regression (D34): tools:[] vs tools:undefined must produce identical keys ─
// ADR 0005 Amendment 2 claims: "tools: [] (explicit empty array) and tools omitted
// (undefined) both mean 'no tools available' and produce identical model output,
// so they correctly share a cache entry."
// computeCacheKey now normalizes [] → null before serialization so this claim holds.
it('F4 regression: tools:[] and tools:undefined produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, tools: [] };
const irUndef = { ...base, tools: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irUndef);
assert.equal(k1, k2, 'tools:[] and tools:undefined must produce identical cache keys');
});
it('F4 regression: stop:[] and stop:undefined produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, stop: [] };
const irUndef = { ...base, stop: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irUndef);
assert.equal(k1, k2, 'stop:[] and stop:undefined must produce identical cache keys');
});
it('F4 regression: tools:[] and tools:null produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, tools: [] };
const irNull = { ...base, tools: null };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irNull);
assert.equal(k1, k2, 'tools:[] and tools:null must produce identical cache keys');
});
it('F4 regression: non-empty tools array still differs from tools:undefined (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irWithTools = { ...base, tools: [{ type: 'function', function: { name: 'foo' } }] };
const irNoTools = { ...base, tools: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irWithTools);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irNoTools);
assert.notEqual(k1, k2, 'non-empty tools array must produce a different cache key from no tools');
});
});
describe('Cache layer — extractCacheControlMarkers + hasCacheControl (Suite 9 cont.)', () => {
@@ -3866,15 +3907,9 @@ describe('Fallback engine — trigger taxonomy (D9)', () => {
assert.equal(evaluateHardTriggers(err), true);
});
it('evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED → fires', () => {
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
assert.equal(evaluateHardTriggers(err), true);
});
it('evaluateHardTriggers: ProviderError RATE_LIMITED → fires', () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
assert.equal(evaluateHardTriggers(err), true);
});
// QUOTA_EXHAUSTED and RATE_LIMITED tests removed (D34 F7): those codes were
// removed from PROVIDER_ERROR_CODES and HARD_TRIGGER_CODES — no v0.1 plugin
// emits them. Re-add tests via ADR 0004 amendment when HTTP-status parsing lands.
it('evaluateHardTriggers: ProviderError SPAWN_FAILED → fires', () => {
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
@@ -4014,7 +4049,7 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('single-hop chain with hard-triggered error → exhausted, returns originalError', async () => {
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const result = await executeWithFallback(chain, dummyIR, makeHopFn({ anthropic: err }));
assert.equal(result.chunks, null, 'Expected null chunks on exhausted chain');
@@ -4038,7 +4073,7 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('three-hop chain, primary + secondary fail → tertiary returns chunks, fallbackHops=2', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = Object.assign(new Error('Service unavailable'), { statusCode: 503 });
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
@@ -4057,8 +4092,8 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('three-hop chain, all fail → exhausted, originalError is from FIRST hop (not last)', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errB = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = new ProviderError('Spawn timeout', 'SPAWN_TIMEOUT');
const errC = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
@@ -4158,7 +4193,7 @@ describe('Fallback engine — first-chunk safety (D9)', () => {
it('error from executeHopFn (hard trigger) means zero chunks emitted — advance is safe', async () => {
// Simulate: anthropic throws (no bytes emitted), openai succeeds
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4273,7 +4308,7 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('success on fallback: providerUsed + modelUsed match the serving hop', async () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4289,9 +4324,9 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('chain exhausted: originalError is from FIRST hop, not second or third', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errB = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errC = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const errC = new ProviderError('Spawn timeout', 'SPAWN_TIMEOUT');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -4310,7 +4345,7 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('triedProviders lists all attempted hops in chain order', async () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -4980,7 +5015,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('chain_id is present in all events and is 16-char hex', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4999,7 +5034,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('chain_id is consistent across all hops from one executeWithFallback call', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -5034,7 +5069,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('ir_request_hash is the same across all hops within one chain', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5060,9 +5095,9 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
// ── trigger_type: classification per error type ───────────────────────────
it('trigger_type is "hard" for QUOTA_EXHAUSTED (fallback_hard_trigger event)', async () => {
it('trigger_type is "hard" for SPAWN_FAILED (fallback_hard_trigger event)', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5142,7 +5177,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('next_provider is chain[i+1].provider when chain advances on hard trigger', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5159,7 +5194,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('next_provider is null on the last hop (chain exhausted)', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
];
@@ -7486,7 +7521,7 @@ describe('D33 round-5 cold-audit cleanup', () => {
describe('F8_fallback — fallback-hop cache-hit reports X-OLP-Cache: hit', () => {
it('F8_fallback: 2-hop chain, primary fails, secondary cache-hit → X-OLP-Cache: hit', async () => {
// Two providers: primary (alpha) always fails with QUOTA_EXHAUSTED; secondary
// Two providers: primary (alpha) always fails with SPAWN_FAILED (hard trigger); secondary
// (beta) succeeds. On second request, secondary serves from cache.
// The X-OLP-Cache header must be 'hit' on the second request.
//
@@ -7497,12 +7532,12 @@ describe('D33 round-5 cold-audit cleanup', () => {
setProviders33({ anthropic: true, mistral: true });
clearCache33();
// Override anthropic spawn to always throw QUOTA_EXHAUSTED
// Override anthropic spawn to always throw SPAWN_FAILED (hard trigger)
const savedAnthropic = loadedProviders33.get('anthropic');
const failingPrimary = {
...savedAnthropic,
spawn: async function* () {
throw new ProviderError('quota exhausted (test)', 'QUOTA_EXHAUSTED');
throw new ProviderError('spawn failed (test)', 'SPAWN_FAILED');
},
};
loadedProviders33.set('anthropic', failingPrimary);