fix+docs: D32 — round-4 cleanup batch (F2/F3/F4/F5/F7/F8/F9)

cold-audit catch from 2026-05-24 (round 4)

Round-4 cold-audit cleanup batch. 7 items grouped per IDR cleanup-batch
convention (D19/D20/D25/D26/D30/D31 precedent). 2 P2 + 3 ADR amendments
+ 2 small docs/code cleanups. F10 (P3 semantic edge case) filed
separately as GitHub issue #8.

Changes (8 files, +200 / -38):

**P2 fixes**

1. **F3 — README missing 6 provider auth env vars** (README.md):
   D30 fixed the `OLP_*` table but missed the auth-bearing env vars
   actually read by plugin code:
   - `CLAUDE_CODE_OAUTH_TOKEN` (anthropic.mjs:84) — highest-precedence
     override; bypasses keychain + .credentials.json file lookup
   - `OPENAI_CODEX_AUTH_PATH` (codex.mjs:163) — overrides full auth
     file path; when set, no other path tried
   - `CODEX_HOME` (codex.mjs:176) — overrides base dir; default auth
     path becomes `$CODEX_HOME/auth.json`
   - `MISTRAL_API_KEY` (mistral.mjs:260) — directly supplies API key;
     highest precedence per Mistral DOCS-2
   - `MISTRAL_VIBE_AUTH_PATH` (mistral.mjs:265) — overrides full .env
     path; evaluated only when MISTRAL_API_KEY absent
   - `VIBE_HOME` (mistral.mjs:277) — overrides Vibe base dir; default
     auth path becomes `$VIBE_HOME/.env`
   Real onboarding-blocker fix: new users couldn't run OLP without
   knowing about these env vars; README now documents them in a
   "Per-provider auth env vars" subsection.

2. **F8 — Early-return error paths missing X-OLP-* headers** (server.mjs):
   ADR 0004 § Observability + README claim "every response carries the
   5 X-OLP-* headers" but 503 (no-enabled-providers), 415 (wrong
   Content-Type), and early 400s (bad JSON, IR validation) emitted only
   Content-Type + Content-Length + X-OLP-Latency-Ms (D18 only wired the
   chain-exhausted path). Operators debugging these paths got less info
   than the ADR promised.
   New helper `olpErrorHeaders({ startMs, model })` emits the canonical
   "no provider attempted" defaults:
     X-OLP-Provider-Used: 'none'
     X-OLP-Model-Used: model ?? 'unknown'
     X-OLP-Fallback-Hops: '0'
     X-OLP-Cache: 'bypass'
     X-OLP-Latency-Ms: <delta>
   6 sendError call sites updated: 415 / 400-bad-JSON / 400-IR-parse
   (model: undefined → 'unknown') + 503-no-providers / 503-provider-
   disappeared / 500-engine-programming (model: ir.model). The 502
   streaming-error-before-first-chunk path correctly stays on
   `olpHeaders(...)` since a provider WAS attempted.

**ADR amendments**

3. **F2 — ADR 0003 model-mapping example correction** (docs/adr/0003,
   Amendment 2): the § Required fields example claimed `claude-sonnet-4-6`
   → `claude-sonnet-4-6-20260301` mapping happens in the provider plugin.
   This was wrong: per D17 SPOT decision (commit cb86807), `irRequest.model`
   is passed verbatim to the CLI; each provider CLI accepts its own
   aliases natively. Both inline correction (in § Decision) AND new
   Amendment 2 (in § Amendments) added.

4. **F4 — OUTPUT_PARSE_ERROR dead code removal** (base.mjs, engine.mjs):
   `PROVIDER_ERROR_CODES.OUTPUT_PARSE_ERROR` and
   `HARD_TRIGGER_CODES.OUTPUT_PARSE_ERROR = true` were registered but
   ZERO plugins emit OUTPUT_PARSE_ERROR. ADR 0004 § Trigger taxonomy
   enumerates 4 hard-trigger categories (5xx, quota 4xx, exit-code,
   spawn-timeout) — OUTPUT_PARSE_ERROR was a 5th never authorized by
   ADR. Removed from both enums with removal-reason comments for
   auditability. Re-add via ADR 0004 amendment if a future plugin
   surfaces parse failures (cheaper than authoring an amendment for
   dead code now).

5. **F5 — ADR 0002 Amendment 4 ratifying contractVersion** (docs/adr/0002):
   `lib/providers/base.mjs:76-81` enforces `p.contractVersion === '1.0'`
   and all 3 plugins declare it, but ADR 0002 § Provider contract field
   list never named it. Same governance-violation class as D11's
   `maxSpawnTimeMs` retroactive sync (Amendment 1). Amendment 4 ratifies
   contractVersion as a required v1.0 contract field; § Provider contract
   field list updated to 10 fields (was 9).

**Small cleanups**

6. **F7 — README API Endpoints table 📋 markers** (README.md): added
   Status column with  Shipped (3 rows: /v1/chat/completions, /v1/models,
   /health) and 📋 Planned (3 rows: /cache/stats Phase 5, /v0/management/
   quota Phase 6, /dashboard Phase 6). Matches D20's Implementation
   Status table convention.

7. **F9 — Codex parser inline assumption labels** (codex.mjs): added 5
   inline `// A4:` comments on each defensive branch in `codexChunkToIR`.
   Pre-D32 these branches had only the top-of-function block comment;
   ALIGNMENT.md Speculative-Candidate condition 5 promises future
   implementers can grep assumption labels to find every speculative
   branch — D32 honors that promise for codex.mjs (mistral.mjs already
   had this pattern from D8).

**Tests** (test-features.mjs):
- 17g retitled + assertion expanded to full 5-header set (was partial)
- 17h new: 415 wrong Content-Type → 5 X-OLP-* headers with 'unknown' model
- 17i new: 503 no-enabled-providers → 5 X-OLP-* headers with ir.model
- OUTPUT_PARSE_ERROR test removed (replaced with comment for audit trail)

Test count: 400 → 401 (-1 OUTPUT_PARSE_ERROR + 2 new F8 + 1 retitled
existing = net +1).

**F10 filed as issue, NOT in D32**:
https://github.com/dtzp555-max/olp/issues/8 — "Soft-skip + chain-
exhausted: X-OLP-Provider-Used semantics ambiguity". Semantic edge
case requiring soft trigger reactivation (deferred to v1.x per D22
Amendment 2); not a v0.1 user-affecting issue.

Pre-commit fold-in (per evidence-first checkpoint #4):
- **D32 reviewer caught a doc-accuracy bug**: README's
  CLAUDE_CODE_OAUTH_TOKEN row described the no-env fallback chain as
  "Searches keychain first, then `~/.claude/.credentials.json`" — but
  the actual code in anthropic.mjs:82-112 checks file FIRST, then
  keychain (darwin-only). Order was reversed. Folded in: corrected
  to "Searches `~/.claude/.credentials.json` first, then macOS
  keychain (darwin only)". Same class of doc-accuracy mistake as the
  D25 → D31 / D27 → D31 pattern: D-batch reviewer catches docs that
  contradict source.

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Independently verified:
- All 6 F3 env vars: precedence chains traced through plugin source
  (caught the anthropic reversed-order — folded in)
- F8 olpErrorHeaders helper correctly distinguishes "no provider"
  defaults from olpHeaders' "provider attempted" defaults
- F8: all 6 sendError call sites use the right model arg (undefined
  pre-IR-parse → 'unknown'; ir.model post-IR-parse)
- F8: 502 streaming-error stays on olpHeaders (provider was attempted)
- F4: OUTPUT_PARSE_ERROR removed from both enums, zero plugin emit
  references remain, test cleanup is auditable
- F5: ADR 0002 Amendment 4 matches Amendment 1's retroactive-sync
  structure; contract field list now 10 items
- F2: D17 commit cb86807 verified to match the SPOT decision claim
- F7/F9: docs/code cleanups verified
- F10 issue #8 verified OPEN with correct title
- 401/401 tests pass

3 non-blocking suggestions (anthropic precedence column folded; F2
inline mistral `--model` caveat could be added; F4 wording asymmetry
between base.mjs and engine.mjs comments) — minor polish, not folded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 17:28:42 +10:00
co-authored by Claude Opus 4.7
parent d6347e33f2
commit 30de965e8e
8 changed files with 200 additions and 38 deletions
+35 -6
View File
@@ -238,6 +238,31 @@ function olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus = 'miss', fa
};
}
/**
* Returns the standard 5-header OLP diagnostic set for pre-chain error paths
* (no provider was attempted yet). Used by sendError call sites that occur
* before chain construction (415 wrong Content-Type, 400 bad JSON, 400 IR
* validation failure, 503 no-enabled-providers).
*
* Per F8 (D32 round-4 cold-audit): ADR 0004 § Observability requires the full
* 5-header set on every response; early error paths must emit canonical
* "no provider attempted" defaults.
*
* @param {object} opts
* @param {number} opts.startMs — request start timestamp (Date.now())
* @param {string|null|undefined} [opts.model] — ir.model if IR was parsed; undefined/null otherwise
* @returns {Record<string,string>}
*/
function olpErrorHeaders({ startMs, model }) {
return {
'X-OLP-Provider-Used': 'none',
'X-OLP-Model-Used': model ?? 'unknown',
'X-OLP-Fallback-Hops': '0',
'X-OLP-Cache': 'bypass',
'X-OLP-Latency-Ms': String(Date.now() - startMs),
};
}
// ── Route handlers ────────────────────────────────────────────────────────
/**
@@ -320,7 +345,7 @@ async function handleChatCompletions(req, res) {
const ct = req.headers['content-type'] ?? '';
if (!ct.includes('application/json')) {
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
{ 'X-OLP-Latency-Ms': String(Date.now() - startMs) });
olpErrorHeaders({ startMs }));
}
let body;
@@ -328,7 +353,7 @@ async function handleChatCompletions(req, res) {
body = await readJSON(req);
} catch (e) {
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
{ 'X-OLP-Latency-Ms': String(Date.now() - startMs) });
olpErrorHeaders({ startMs }));
}
// Translate OpenAI → IR (ADR 0003)
@@ -338,7 +363,7 @@ async function handleChatCompletions(req, res) {
} catch (e) {
if (e instanceof BadRequestError) {
return sendError(res, 400, e.message, 'invalid_request_error',
{ 'X-OLP-Latency-Ms': String(Date.now() - startMs) });
olpErrorHeaders({ startMs }));
}
throw e;
}
@@ -365,6 +390,7 @@ async function handleChatCompletions(req, res) {
res, 503,
`No enabled providers for model ${ir.model}. See README § Supported Providers.`,
'no_enabled_provider',
olpErrorHeaders({ startMs, model: ir.model }),
);
}
@@ -558,7 +584,8 @@ async function handleChatCompletions(req, res) {
if (!streamPlugin) {
// Provider disappeared between chain build and here (edge case).
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider');
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
olpErrorHeaders({ startMs, model: ir.model }));
}
const streamHeaders = olpHeaders({
@@ -676,7 +703,8 @@ async function handleChatCompletions(req, res) {
error: e.message,
});
if (!res.headersSent) {
sendError(res, 502, e.message ?? 'Provider error', 'provider_error');
sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
} else {
res.end();
}
@@ -693,7 +721,8 @@ async function handleChatCompletions(req, res) {
} catch (e) {
// executeWithFallback throws only on programming errors (empty chain).
logEvent('error', 'fallback_engine_error', { error: e.message });
return sendError(res, 500, 'Internal server error', 'internal_error');
return sendError(res, 500, 'Internal server error', 'internal_error',
olpErrorHeaders({ startMs, model: ir.model }));
}
const {