mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
cc250e71bfbbbe92ac6485133994881d9740f261
57
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fa2d1af130 |
feat+test: F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin (#59)
## F4 — bin/olp.mjs + olp-plugin/index.js migrate to quota_v2 Codex post-v0.5.0 review Q4: both `olp usage` CLI and `/olp usage` plugin handler read legacy `body.quota` shape, which never has `percent_used` or meaningful `available` data → display always fell through to "no quota api" even when anthropic live quota was visible on the dashboard (D81/D82). Authority: codex review Q4 (PR #58); ADR 0008 Amendment 2 (quota_v2 shape: { provider, status, utilization, reset, representative_claim, fallback_percentage, overage, failure?, failure_kind?, backoff_until? }). ### bin/olp.mjs cmdUsage - When `body.quota_v2` present (non-empty array, server v0.5.0+): render per-provider rows with status badge (live/stale/unreachable/unavailable), 5h + 7d utilization % (color-coded: green <50% / yellow 50-80% / red ≥80%), reset countdowns, binding claim, ⚠ stale / ❌ unreachable annotations. - When `body.quota_v2` absent: fall through to existing legacy `body.quota` rendering (preserves backwards compat with pre-v0.5.0 servers). - Added `formatResetCountdown(epochSeconds)` — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from dashboard.html D82. Exported for tests. Kept in bin/olp.mjs (not lib/) per spec guidance. - Added `formatAgo(diffMs)` — small helper for stale-row age display. ### olp-plugin/index.js fmtUsage() - Same quota_v2 migration. Plain-text output (no ANSI), one line per provider. Legacy body.quota fallback preserved. - Added `pluginFormatResetCountdown(epochSeconds)` — intentionally duplicated from bin/olp.mjs (olp-plugin ships as a separate package and must not import from bin/). Exported for tests. - Also fixed fmtUsage to read `w.request_count` (dashboard-data shape) in addition to legacy `w.requests` (OCP-era shape) for completeness. ### Backwards compat - Pre-v0.5.0 server returns body.quota only → both surfaces use legacy display (unchanged behaviour). - v0.5.0+ server returns both quota and quota_v2 → both surfaces prefer quota_v2. - Legacy code paths kept (5-10 lines each, not deleted). ## v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — CLOSED The dedicated test was already shipped at D56 (test-features.mjs line 6255: 'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'). This commit closes the roadmap entry with a date stamp and PR reference. Authority: docs/v1x-roadmap.md § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)". ## Tests Suite 40 (9 new tests — 40a through 40i): 40a — cmdUsage parses quota_v2 live rows (mock server) 40b — cmdUsage falls back to legacy body.quota when quota_v2 absent 40c — olp-plugin fmtUsage parses quota_v2 live row 40d — olp-plugin fmtUsage falls back to legacy body.quota 40e — formatResetCountdown covers all 5 time ranges 40f — pluginFormatResetCountdown covers past/<1h/<24h/<7d/≥7d 40g — cmdUsage renders quota_v2 stale row with ⚠ stale note 40h — cmdUsage renders quota_v2 unreachable row with ❌ indicator 40i — cmdUsage renders quota_v2 unavailable rows correctly 759 → 768 tests, 0 fail. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
bddf2cba1e |
release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) (#58)
* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on PR #57, reproduced with local mocks): F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3) `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly." F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5) A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live). Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset + 7d-utilization + 7d-reset present; absence → `failureKind: 'schema_drift'`, backoff scheduled, result NOT cached. ADR 0013 Rule 5 updated with the gate specification. F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6) `aggregateProviderQuota()` collapsed all failure modes into `status: 'unavailable'` ("no public quota api or probe disabled") — same as providers with no API at all. Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift | network | other. Dashboard renders `unreachable` with red border + failure detail. Authority: ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency) ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status) ADR 0002 Amendment 8 unchanged (constitutional permission for the probe) Codex review findings F1–F3 (codex on PR #57) Changes: - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind; _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes through quotaStatus(); reset functions updated - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field; aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status - dashboard.html: unreachable CSS classes + render path + footer v0.5.1 - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1; 38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract) - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus); Rule 5 min-viable-schema gate specification - package.json: 0.5.0 → 0.5.1 - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum) Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking, 4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes). Deferring #3 (test-only edge case in module-level seam restore) and #4 (pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy quota shape; separate PR planned). Nit #1 — ADR 0002 Amendment 8 documentation drift. Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather than throwing", and line 33 said "stale-cache-on-failure (`null` is returned only when no cache entry exists; if a stale entry exists it's returned with a `stale: true` marker)". v0.5.1 refined this contract: `null` is now reserved STRICTLY for opt-in-off, and all failure modes return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. The substantive idempotent-failure constraint (no throw to caller) is unchanged. The operational description in Amendment 8 was stale — fixed to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as the regression coverage producing the new shape. Nit #2 — `failureKind: 'opt_in_off'` declared but never produced. The enum value was listed in both the code comment (anthropic.mjs:250) and ADR 0008 Amendment 2 (line 30) but never actually assigned — because when opt-in is off, `quotaStatus()` returns the literal `null` BEFORE any state mutation happens. The enum value was dead. Fix: removed `opt_in_off` from both enum declarations + added an inline note explaining that consumers (audit-query, doctor) distinguish opt-in-off by checking `quotaStatus() === null`, not via failureKind. Deferred: - Nit #3 (test-seam restore edge case): if a caller pre-sets `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`, the finally block restores to null clobbering pre-set value. Test-only impact, no production risk. Pure hygiene; defer. - Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js still read legacy `body.quota` field, never consume `quota_v2`. Reviewer confirmed neither crashes — both gracefully fall through to "no quota api" branch. Out of scope for this hotfix per the hotfix dispatch contract; separate PR will migrate them. Tests: 759/759 still pass post-fold-in. No test changes needed. Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency) + ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
2b07a3bd1b |
test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) (#55)
* test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) ADR 0012 D83 row: comprehensive test coverage for the Phase 5 quota probe machinery (D80) and dashboard rendering (D82). ## Authorities cited - ADR 0012 D83 (D-day specification) - ADR 0013 Rules 2–6 (quota probe constraints being tested) - D80 PR #52 (anthropic.quotaStatus() + _probeOnce + _parseRateLimitHeaders) - D81 PR #53 (aggregateProviderQuota quota_v2 shape) - D82 PR #54 (dashboard.html Claude.ai-style restructure) - Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md ## Test seams added to lib/providers/anthropic.mjs Minimal test seams added — 0 production-logic changes: - `_setQuotaUrlsForTest(apiUrl, oauthUrl)` — redirects probe HTTP to local mock server (auto-detects http: vs https: to switch transport module) - `_resetQuotaProbeStateForTest()` — resets cache + backoff + URLs + auth seam - `_resetQuotaStateOnlyForTest()` — resets cache + backoff only (URLs stay) - `_getQuotaProbeStateForTest()` — returns direct reference to quotaProbeState for test assertions and controlled state mutation - `_setQuotaAuthReadFnForTest(fn)` — injects a mock auth reader into quotaStatus() so tests are not affected by real ~/.claude/.credentials.json on the machine (fixes 38f: no-auth test was finding real keychain credentials) ## Suite 38 — 20 quota-probe unit tests (38a–38t) 38a: all 13 ratelimit-unified-* headers parsed correctly (numeric types, null defaults) 38b: missing overage-reset → overage_reset: null 38c: missing all three overage fields → all three null 38d: new 5h-status + 7d-status fields (NEW vs OCP 2026-04) parsed correctly 38e: quota_probe_enabled: false → null without HTTP call 38f: auth returns null → quotaStatus returns null without HTTP call 38g: 200 + all 13 headers → full shape with stale:false + backoff reset 38h: cache hit within 5min TTL → no second HTTP call (request count stays 1) 38i: expired cache (6min > 5min TTL) → fires fresh HTTP probe 38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8 §3) 38k: 429 + stale cache → returns stale cache with stale:true + last_fresh_at 38l: 429 + no cache → null + backoff scheduled (backoffUntil in the future) 38m: exponential backoff growth: 60s→120s→240s→cap at 3600s 38n: successful probe resets backoffMs to 60s + backoffUntil to 0 38o: schemaVersion from models-registry.json (falls back to constant) 38p: doctor quota_probe_reachable disabled → ok with opt-in advisory 38q: doctor probe enabled + probe succeeds → ok with utilization in message 38r: doctor probe enabled + fails + stale cache → warn 38s: doctor probe enabled + fails + no cache → fail with fix_commands 38t: doctor probe enabled + no creds → fail with human_steps only ## Suite 39 — 8 dashboard rendering smoke tests (39a–39h) 39a: /dashboard with owner token → 200 + text/html 39b: /dashboard without token → 401 39c: /dashboard with guest key → 401 (owner-only_block enforcement per ADR 0008 §8) 39d: HTML contains "Plan Usage" header (D82 panel) 39e: HTML contains ↻ Refresh button (D82 manual refresh) 39f: HTML contains QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh) 39g: HTML contains visibilitychange / visibilityState guard (D82 ADR 0012) 39h: HTML contains quota_v2 consumer + legacy renderQuota fallback code ## Test count: 727 → 755 (+28) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: D83 fold-in — fix 38j misleading name + add 38j2 positive-path + fix 38o ESM require Fresh-context reviewer (PR #55) flagged one blocking issue + 1 nit: BLOCKING (Q5) — 38j misleading name + uncovered 401→refresh→retry positive path. The original 38j test description said "401 → refresh succeeds → retry probe succeeds" but the test actually asserted the OPPOSITE behavior (401 with no refreshToken → null, no retry). The most important non-trivial control-flow branch in _probeOnce (anthropic.mjs:451-457 the refresh-and-retry on 401) was uncovered, and the misleading name actively masked the gap. Fix: - Renamed 38j to accurately describe what it tests: "401 with no refreshToken → null (idempotent-failure, no refresh attempt)". The test pins valuable behavior — idempotent-failure when refreshToken is absent — but now its name matches. - Added 38j2 (NEW) to exercise the actual positive-path: inject creds with refreshToken via _setQuotaAuthReadFnForTest, mock returns 401 on first API call + 200 with 13 headers on retry. Asserts: 2 API calls, 1 OAuth call, retry parsed full shape, OAuth body contains the injected refreshToken (Rule 1 credential reuse verification). NIT (Q5 dead assertion) — 38o tried to read models-registry.json via require('fs') which is undefined in ESM. The assert was silently never exercised. Replaced with the already-imported readFileSync from node:fs (added _readFileSync38 import). Also tightened: schemaVersion MUST equal the registry's quota_probe.schema_version (no longer conditional on "if expected" which was always falsy). Other reviewer nits deferred (non-blocking per reviewer): - N2: seam naming convention (_ vs __) — defer - N3: commit message "zero production changes" — note for v0.5.0 close - N4: _getQuotaProbeStateForTest returns mutable ref — defer - N5: test seam runtime guard — defer - N6: coverage gaps (concurrent probes, registry-missing fallback, network error path) — v1.x roadmap follow-ups - N7: Suite 39 stray error listener — defer (benign) Test count: 755 → 756 (+1 net, +2 new minus existing 38j rename). All 756 pass; 0 fail. Local re-run: stable. Authority: PR #55 review thread (D83 fresh-context opus reviewer) + ADR 0013 Rule 1 (credential reuse via refresh). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
5288493f19 |
feat: D81 — dashboard-data quota_v2 shape + models-registry schema_version (Phase 5) (#53)
Authority citations (required per CLAUDE.md § Hard requirements):
1. ADR 0012 D81 — the D-day being implemented (Phase 5 charter, audit-query
+ dashboard-data extension row in § D-day table)
2. ADR 0013 Rule 5 — schema_version in models-registry.json mandate. D80
used a local constant QUOTA_SCHEMA_VERSION = '2026-05-26' (reviewer nit
#4 at PR #52). D81 folds in Rule 5 compliance: adds quota_probe.schema_version
to models-registry.json and has anthropic.mjs read from there with the
constant as fallback via _resolveSchemaVersion().
3. ADR 0008 — the audit-query design being amended (Amendment 1 added at D81
to docs/adr/0008-dashboard-and-audit-query.md documenting: quota_probe in
registry, aggregateProviderQuota() API shape, quota_v2 key, deprecation
timeline for legacy quota key).
4. D80 PR #52 (commit
|
||
|
|
82d2e1cbea |
feat: D80 — anthropic plan-usage probe port (Phase 5) (#52)
Port OCP server.mjs:842-1109 plan-usage probe to lib/providers/anthropic.mjs:quotaStatus(). ## Authority citations (CLAUDE.md hard requirement #1) 1. Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md — 13-field canonical schema verified live 2026-05-26; 3 fields new vs OCP 2026-04 capture (5h-status, 7d-status, overage-reset). 2. OCP port source: OCP server.mjs:842-1109 — usageCache, oauthRefreshBackoff, getOAuthCredentials, refreshOAuthToken, fetchUsageFromApi, parseRateLimitHeaders. Claude Code CLI uses this same POST /v1/messages internally (verified 2026-05-26 by `strings` on @anthropic-ai/claude-code v2.1.142 / v2.1.150 Mach-O binary — see audit memory). This is observed CLI behaviour, not invention. 3. ADR 0002 Amendment 8 — READ-ONLY exemption for quotaStatus() direct-API access. Three constraints satisfied: READ-ONLY (max_tokens:1, body discarded), subscription-scope (same readAuthArtifact() creds as spawn path), idempotent-failure (returns null / stale on any error, never throws). 4. ADR 0013 — OAuth READ-ONLY consumption rules. All 7 rules satisfied: Rule 1: credential reuse via readAuthArtifact() (env → .credentials.json → keychain). Rule 2: only POST /v1/messages (no other endpoints). Body discarded; headers-only. Rule 3: 5min TTL cache; 60s–3600s exponential backoff; stale-on-failure. Rule 4: opt-in via ~/.olp/config.json providers.anthropic.quota_probe_enabled (default false). Rule 5: schema pin committed to memory file; drift detection protocol in place. Rule 6: doctor check anthropic.quota_probe_reachable surfaces probe status. Rule 7: does not govern spawn-path refresh (separate concern). 5. ADR 0012 D80 — Phase 5 charter: this commit is the D80 deliverable. ## Live probe transcript (2026-05-26 from MacBook keychain OAuth credentials) Path B verification per ADR 0013 Rule 5: curl -s -i -m 20 -X POST https://api.anthropic.com/v1/messages \ -H "Authorization: Bearer <token>" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ -d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"."}]}' Response (header lines only): HTTP/2 200 anthropic-ratelimit-unified-status: allowed anthropic-ratelimit-unified-5h-status: allowed anthropic-ratelimit-unified-5h-reset: 1779794400 anthropic-ratelimit-unified-5h-utilization: 0.09 anthropic-ratelimit-unified-7d-status: allowed anthropic-ratelimit-unified-7d-reset: 1780225200 anthropic-ratelimit-unified-7d-utilization: 0.32 anthropic-ratelimit-unified-representative-claim: five_hour anthropic-ratelimit-unified-fallback-percentage: 0.5 anthropic-ratelimit-unified-reset: 1779794400 anthropic-ratelimit-unified-overage-disabled-reason: org_level_disabled_until anthropic-ratelimit-unified-overage-status: rejected (no anthropic-ratelimit-unified-overage-reset — expected: only present on active overage) 12/13 fields present. overage-reset absent = no active overage (expected per audit memory). All fields parsed correctly by _parseRateLimitHeaders(). Confirmed via D80 smoke test. ## Implementation A. quotaStatus() — full probe implementation replacing D4 null stub: - _readProviderConfig('anthropic') gate (Rule 4 opt-in) - 5min module-level cache check (quotaProbeState.cache) - 60s–3600s exponential backoff check (quotaProbeState.backoffUntil / backoffMs) - readAuthArtifact() credential read (env → .credentials.json → macOS keychain) - _probeOnce() → POST /v1/messages with 4 required headers; body discarded - 401/403 → single refresh-and-retry via _refreshAccessToken() - On success: cache { fetchedAt, data } + reset backoff to MIN - On failure: _scheduleBackoff() (doubles backoffMs, caps at MAX) + return stale or null - Return shape: { probedAt, source, schemaVersion, stale, fields:{...13}, raw:{...} } B. _parseRateLimitHeaders() — all 13 fields (3 new vs OCP): - status, representative_claim, reset, fallback_percentage (aggregate) - status_5h, utilization_5h, reset_5h (5h window) - status_7d, utilization_7d, reset_7d (7d window) - overage_status, overage_disabled_reason, overage_reset (overage) - Numeric strings → numbers; missing fields → null (not 0 or "unknown") C. _refreshAccessToken() — uses Node.js built-in https (no fetch/3rd-party deps). Shared backoff state via quotaProbeState. Max one refresh per backoff window. D. _probeOnce() — uses Node.js built-in https. 15s timeout. Drains + discards body. E. _readProviderConfig() — reads ~/.olp/config.json providers.<name> block. OLP_HOME respected (same as lib/keys.mjs). Never throws; returns {} on error. F. doctorChecks() — new anthropic.quota_probe_reachable check (ADR 0013 Rule 6): - status: ok when probe disabled (returns advisory message) - status: ok when probe succeeds (shows utilization %) - status: warn when stale cache exists (probe failed but cache present) - status: fail when no cache + probe failed (fix_commands + human_steps recipe) G. docs/v1x-roadmap.md — #8 Dashboard enrichment entry (D79 follow-up) added. ## Tests - All 720 existing tests pass (npm test). - Suite 33j updated to include anthropic.quota_probe_reachable in the expected probe set (3 probes total, previously 2). - D83 (Suite 38) will add quota-probe unit tests with mock HTTP server. ## What NOT changed - dashboard.html — untouched (D82) - lib/audit-query.mjs — untouched (D81) - lib/providers/codex.mjs, mistral.mjs — untouched (D84 NO-GO per ADR 0012 Amendment 1) Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
704d4fc8a0 |
fix+docs+release(v0.4.4): D78 — olp-connect stale strings + CDN-safe README URL + pre-publish audit (#49)
* fix+docs+release(v0.4.4): D78 — olp-connect stale strings + README CDN-safe tag URL + repo public-flip pre-audit Patch release on top of v0.4.3. Three small issues caught when running olp-connect for real on MacBook (D77 client-install verification): ## G11: repo visibility flip Repo dtzp555-max/olp flipped PRIVATE -> PUBLIC during this session. Closes the original G11 finding (anonymous curl can't fetch raw URL from private repos). Pre-publish audit (per cc-rules pre-publish-audit.md checklist): - Identity scrub: 0 hits — no taodeng, no 老大, no /Users/.../ paths, no personal hostnames, no personal emails, no real LAN IPs (only RFC documentation placeholders 192.168.1.10 + 10.0.0.5) - Credential scrub: gitleaks 'no leaks found' — all olp_ matches are placeholder (olp_XXXX...) or test fixtures (olp_not-a-real-key-...) - Git history: maintainer accepted Option A (GitHub-account email already verified-public on profile; flip exposes nothing new) ## G11 mitigation: README curl URL CDN-cache-safe GitHub raw CDN serves a stale 404 for /main/<file> for ~5-15min after a private->public visibility flip (negative-cache TTL). Tag-pinned URLs bypass this because the tag ref was never queried while private. D78 makes README's primary olp-connect curl URL tag-pinned: bash <(curl -fsSL .../v0.4.4/bin/olp-connect) <ip> with /main/ listed as alternative for trusted-head users. ## G12: detect_openclaw claimed plugin not shipped bin/olp-connect's OpenClaw detection block said "The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED" — but D71-D73 shipped olp-plugin/ at v0.4.0. Replaces stale text with real install instructions: git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo openclaw plugins install /tmp/olp-repo/olp-plugin # or symlink: ln -sf .../olp-plugin ~/.openclaw/extensions/olp Points at docs/integrations/openclaw.md for the full setup with dedicated bot apiKey + restart-gateway notes. ## G13: olp-connect self-version hardcoded literal Pre-D78 the script declared OLP_CONNECT_VERSION="0.4.0-phase4" as a hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3. D78 derives the version at runtime from sibling package.json via python3. When invoked from a checked-out repo, version resolves to the actual value; when curl-piped (no on-disk package.json next to script), falls back to "unknown". bash bin/olp-connect --version # -> olp-connect 0.4.4 (automatic) ## Test count 717 (v0.4.3) -> 720 (v0.4.4). +3 D78 regression tests in Suite 36: - 36v: pins absence of NOT YET SHIPPED text + presence of real install path - 36w: pins runtime version derivation from package.json - 36x: pins README tag-pinned URL recommendation ## Authority - D77 MacBook client-install verification session (2026-05-26) - ~/.cc-rules/docs/guides/pre-publish-audit.md (the checklist that preceded the visibility flip) - Process learning: every README that includes a `curl raw-URL | bash` install pattern should pin to a release tag (not /main/) for CDN- cache resilience. ## Out of D78 scope (deferred) - F6 (doctor client-side limitation) — Phase 5 ADR amendment - D75 reviewer P2-1 (ADR 0004 per-hop schema) + P2-2 (defensive type assert) — non-blocking - scripts/migrate-from-ocp.mjs — Phase 7 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D78 reviewer P2 fold-in — _resolve_version defensive guards (require /bin suffix + env-var path passthrough + nounset default) --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
6605b7b14a |
feat+docs+release(v0.4.3): D76 — README install-path overhaul + OLP_BIND env + AI-install prompt + ADR 0011 amendment (#48)
10 README install gaps catalogued and fixed in one D-day after v0.4.2's PI231 E2E session exposed that the v0.4.0-v0.4.2 README Quick Start was fictional (npm package isn't published; olp setup/start commands don't exist). F5 (OLP_BIND env) ships in the same patch so the documented LAN onboarding flow actually works. AI-driven install prompt added per Phase 4 charter brainstorm Top-5 inheritance candidate #2 (D64-D67 built the doctor framework; D76 closes the README half). ## G1-G7: README Quick Start now real Rewrote § "Manual install" from placeholder text to the empirically-verified sequence: - Prerequisites (Node >= 18 + provider CLI install matrix) - git clone + npm test verify - olp-keys keygen --owner FIRST (allow_anonymous=false default needs a key) - Per-provider OAuth (claude setup-token / codex login --device-auth / MISTRAL_API_KEY) - ~/.olp/config.json with the minimum that actually serves traffic - npm start - Smoke-test via curl + olp doctor - IDE pointing ## G8 / F5: OLP_BIND env shipped server.mjs: - const BIND = process.env.OLP_BIND ?? '127.0.0.1' (safe default unchanged) - server.listen(PORT, BIND, ...) replaces hard-coded '127.0.0.1' - New startup warn anonymous_key_advertised_with_lan_bind fires when OLP_BIND is non-loopback AND auth.advertise_anonymous_key: true (operator visibility into trust-context overlap) Pre-D76 the server only accepted loopback connections, so the documented olp-connect <ip> family-onboarding flow was unreachable from LAN without SSH tunneling. F5 makes ADR 0011 operational instead of aspirational. ## G10: AI-driven install prompt README § "Install with your AI (the fast path)" — verbatim prompt the operator pastes into Claude Code / Cursor / Copilot / Aider. The AI follows README + uses olp doctor --json next_action.ai_executable[] for self-repair, stopping only when human_required[] is non-empty (provider OAuth dances). Closes the Phase 4 brainstorm #2 inheritance candidate. ## Opening compressed § "Why OLP" (3 paragraphs of Anthropic 2026-06-15 billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + install paths without needing to digest 2026-05-14 billing history first. OCP users get a one-line pointer at the top. ## Configuration + Env Variables sections updated - § Configuration: placeholder replaced with full ~/.olp/config.json schema documentation, every field cross-referenced to its ADR - § Environment Variables: added OLP_BIND, OLP_API_KEY, OLP_OWNER_TOKEN, OLP_PROXY_URL rows that were used in the manual-install flow but previously undocumented ## ADR 0011 § Deployment configurations amendment Codifies the three deployment trust contexts: - 127.0.0.1 (loopback only) — safe with any auth posture - RFC1918 / tailnet / specific LAN IP — anonymous_key OK (the documented trusted-LAN zero-config family onboarding flow) - Public IP — incompatible with advertise_anonymous_key: true Documents the new anonymous_key_advertised_with_lan_bind startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent BIND_ADDRESS concept. ## Test count 714 (v0.4.2) -> 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning OLP_BIND wiring + safety warn + ADR amendment. ## Process learning Every D-day reviewer rubric should add "open README §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0 close. Combined with D74 (review-against-spec) + D75 (review-without-deployment), D76 (review-without-following-README) codifies the third tier of review discipline. ## Out of D76 scope (deferred) - F6 (doctor client-side vs server-side check separation) — needs design ADR for --remote mode. Phase 5. - D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive typeof hopModel === 'string') — both genuine follow-ups, neither blocking. - scripts/migrate-from-ocp.mjs — Phase 7. Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
6edf6e0b94 |
fix+release(v0.4.2): D75 — codex CLI v0.133.0 schema + per-hop model override (#47)
Patch release fixing 5 bugs caught by real-machine E2E testing on PI231 +
Mac mini (2026-05-26 session). Prior D-day reviewers + the post-v0.4.0
maintainer review all missed these because they reviewed against spec text
and against the local OLP install's cached codex CLI shape, not against a
fresh `npm install -g @openai/codex` on a remote operator host getting
v0.133.0 for the first time.
F1 — codex auth.json schema pin (lib/providers/codex.mjs readAuthArtifact)
Real codex CLI v0.133.0 nests the access token under `tokens.access_token`,
not at top-level access_token / token / accessToken. Pre-D75 readAuthArtifact
returned null → OLP reported "auth artifact missing" even for fully
logged-in users. Fix: prepend creds?.tokens?.access_token to the precedence
chain at both override + default branches. Legacy fields preserved as
fallback. Authority: codex CLI v0.133.0 on-disk auth.json shape verified
empirically on PI231 2026-05-26 E2E session.
F2 — codex spawn args + --skip-git-repo-check (lib/providers/codex.mjs irToCodex)
codex CLI v0.133.0 trusted-directory sandbox refuses with "Not inside a
trusted directory" outside git repos. OLP deploys typically outside a git
repo. Fix: add '--skip-git-repo-check' to args before '--model'. Authority:
codex CLI v0.133.0 reference (`codex exec --help` documents the flag).
F3 — codex NDJSON event shape pin (lib/providers/codex.mjs codexChunkToIR)
Real v0.133.0 stream: thread.started → turn.started → item.completed
(item.type='agent_message', item.text=<response>) → turn.completed.
D6 defensive parser only recognised top-level content/delta/text +
type:'stop'/done:true → every chunk silently dropped → response body had
content: null. Fix: add three new recognisers (item.completed → delta;
turn.completed → stop; turn.failed → error) before the legacy fallback
chain. Legacy recognisers preserved for backward/forward compat.
F4 — `olp status` reads body.stats.cache.size, not body.cache.entries
(bin/olp.mjs cmdStatus). Server payload nests stats under stats.cache;
CacheStore.stats() exposes {hits, misses, size, inflightCount} — there is
no `entries` field. D74 P2-3 fixed cmdUsage + cmdCache for the same bug
class but missed cmdStatus.
F7 — per-hop chain `model` overrides IR model in provider.spawn()
(server.mjs executeHopFn + streaming sourceFactory). Pre-D75 executeHopFn
used hopModel for cache key + audit ctx but passed the original irReq
(with irReq.model = user's request) to provider.spawn(). Chain config
[{anthropic, claude-X}, {openai, gpt-5.5}] would always spawn BOTH plugins
with --model claude-X — openai rejected the unknown model and the chain
died. This broke the core OLP value prop (cross-provider fallback with
provider-appropriate model substitution). Fix: build per-hop IR variant
with { ...irReq, model: hopModel } and pass to spawn. Conditional skips
clone when hopModel === irReq.model. Applied to BOTH buffered path AND
streaming path. Authority: ADR 0004 § Chain advancement step 1 (per-hop
config supplies provider AND model — contract always specified, code
didn't complete it).
Out of scope (deferred to Phase 5):
- F5 (server bind / OLP_BIND env) — needs anonymous-key trust review
- F6 (doctor client-vs-server-side limit) — needs trigger-taxonomy ADR
Test count: 704 → 714 (+10 Suite 36 D75 regression tests: 36i–36r).
Files touched: lib/providers/codex.mjs, bin/olp.mjs, server.mjs,
test-features.mjs, package.json, CHANGELOG.md.
Phase 5 process learning: every provider plugin D-day must include a
real-CLI E2E on a remote operator host before merging — not on the
maintainer workstation (which may have an older CLI cached from a prior
install). D6/D7 codex E2E was deferred and that deferral compounded across
3 layers. F7 reinforces a separate lesson: when a function signature takes
(provider, model, ir), reviewers must check that `model` is consumed
everywhere downstream — not just at the call site they happened to look at.
Authority: ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback
engine — per-hop model contract), lib/providers/codex.mjs D6 assumption
A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex
CLI v0.133.0 on-disk schema + `codex exec --help` output verified
empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律
evidence-over-should-work; CLAUDE.md release_kit.phase_rolling_mode
cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump
patch, tag, release before next push").
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f3716a19fd |
fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — 5 maintainer-review findings (#46)
* fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — maintainer-review findings Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent review (main / v0.4.0 / commit |
||
|
|
53afea47ca |
feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)
Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11 IDR (plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together; README index links all). After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter + port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor / D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay. ## D71 — olp-plugin/ (OpenClaw gateway plugin) Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482 lines) as the /olp Telegram+Discord slash command, but MINUS mutations (no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs — all of these require SSH out of chat for security). Plugin shape: - olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand - olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl default http://127.0.0.1:4567 (matches D60) - olp-plugin/package.json — minimal: name/version/type:module + OpenClaw discovery block - olp-plugin/README.md — install + configure + use docs; documents the "no mutations from chat" security stance and the dedicated-bot-key pattern (don't share maintainer's personal key with the bot) Subcommand parity with olp CLI (D64-D67): - /olp status → GET /v0/management/status (owner-only) - /olp health → GET /health (public-ok) - /olp usage → GET /v0/management/dashboard-data (owner-only) - /olp models → GET /v1/models (public-ok) - /olp cache → GET /cache/stats (owner-only) - /olp providers → local cross-ref (public-ok) - /olp chain show [<model>] → local (public-ok, advisory if no FS access — defer to ssh + olp chain show) - /olp doctor → informational (HTTP doctor endpoint deferred; advisory to ssh + olp doctor for live use) - /olp help → usage text Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for the 4096-char message limit. No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses fetch + node builtins only. ## D72 — docs/integrations/*.md (6 IDE pages + index) Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope posture for Claude Code: - continue.md ✅ — config.yaml (NOT config.json); apiBase; requestOptions.headers - cline.md ✅ — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning - cursor.md ⚠️ — known base-URL fragility; only enable models OLP serves - aider.md ✅ — OPENAI_API_BASE env + openai/ prefix; .env support - claude-code.md ❌ — explicitly NOT supported per ADR 0010 § /v1/messages defer rationale; recommended alternative: Cline + OLP - openclaw.md ✅ — install olp-plugin via CLI or symlink; configure apiKey; restart gateway Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. docs/integrations/README.md is the index. ## D73 — README cross-references - New § "IDE Setup" links to docs/integrations/README.md - New § "Telegram / Discord Usage" — install + configure + restart + use - Quick Start mentions olp-connect <ip> as family-onboarding command - package.json `files` field extended to include olp-plugin/ so the published tarball ships the plugin ## Test count 672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters / dispatch / error paths). All 696 pass locally. ## Scope discipline - server.mjs UNTOUCHED (plugin consumes EXISTING endpoints) - No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport) - No /v1/messages (out of Phase 4 per ADR 0010) - No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json `files` extended for olp-plugin/ publication) ## Implementor flagged for reviewer 1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When future phase exposes /v0/management/doctor, swap advisory branch for real fetchJSON + fmtDoctor (already implemented + tested). 2. /olp providers + chain show have no FS access (plugin runs in OpenClaw gateway process); registry read via lazy-imported models-registry.json from repo root. For live enabled-state visibility users still need /olp status (owner-tier) or ssh + olp providers / olp chain show. 3. No live-server wire test in Suite 35 — existing Suites 31/32 already cover the integration path against the same endpoints; mock-fetch in Suite 35 is sufficient signal for the plugin layer. ## Authority - ADR 0010 § Phase 4 D-day plan D71-D73 line - OCP ocp-plugin/index.js (port reference) - ADR 0010 § Out-of-Phase-4-scope (claude-code.md ❌ rationale) - 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art survey IDE-specific quirks for cline/cursor/continue docs) Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0bdecd1235 |
feat+test+docs: D68-D70 — bin/olp-connect + /health.anonymousKey + ADR 0011 (#43)
* feat+test+docs: D68+D69+D70 — bin/olp-connect + /health.anonymousKey + ADR 0011
Third substantive Phase 4 implementation. 3 D-days bundled per Iron Rule
11 IDR — olp-connect consumes /health.anonymousKey for zero-config
client setup, both governed by ADR 0011's trusted-LAN-only invariant.
## D68 — bin/olp-connect (zero-config client setup)
Ports OCP ocp-connect (721 lines) → OLP olp-connect (564 lines, pure bash).
Bash over Node (per ADR 0010 § Notes) because client machines may lack
recent Node; bash + curl + python3 = max portability.
CLI: `olp-connect <host-ip> [--port PORT] [--key API_KEY] [--no-system-env]
[--dry-run] [--help] [--version]`
Workflow:
1. Connectivity probe (curl /health, 5s timeout, distinguishes TCP
unreachable from auth-required)
2. Auth resolution: --key flag → /health.anonymousKey (D69) → interactive
prompt fallback
3. Smoke test (GET /v1/models with bearer)
4. IDE detection + per-IDE config:
- Claude Code: detect + warn (NOT supported as OLP client per ADR 0010)
- Cline: detect + print manual VSCode-settings snippet
- Continue.dev: detect (extension OR ~/.continue/config.yaml) + write
idempotent models: entry
- Cursor: detect + print snippet + WARNING (per prior-art known
base-URL fragility)
- Aider: detect + write OPENAI_API_BASE + OPENAI_API_KEY to rc files
- OpenClaw: detect + print "install /olp plugin (D71-D73 deliverable)"
5. System-level env: macOS launchctl setenv / Linux ~/.config/environment.d
(so VSCode/Cursor started via Dock inherit)
6. Summary + test command
Idempotent (bracketed `# OLP LAN (added by olp-connect)` ... `# /OLP LAN`
blocks in rc files). --dry-run exercises every state-change site without
modifying anything. Exit 0/1/2 conventions.
Installed via package.json bin so `npx olp-connect` works.
## D69 — /health.anonymousKey + auth.advertise_anonymous_key
server.mjs handleHealth emits OPTIONAL `anonymousKey: "olp_..."` field
when ALL THREE prerequisites hold:
1. config.json auth.advertise_anonymous_key === true
2. config.json auth.allow_anonymous === true (per ADR 0007 § 7)
3. At least one non-revoked key has plaintext_advertise field set
Default-off: field is ABSENT (not null) — preserves v0.3.x /health shape;
existing tests don't regress.
bin/olp-keys.mjs new flags: `keygen --anonymous --advertise` writes the
plaintext into the manifest's optional `plaintext_advertise` field AND
prints a WARNING about disk-storage + /health exposure + ADR 0011
pointer. Owner-tier --advertise rejected at BOTH CLI + lib layers.
Implementation note: reused existing guest tier (no new owner_tier:
'anonymous'); plaintext_advertise is a forward-compat optional manifest
field per ADR 0007 § 4 unknown-fields-allowed convention. Cleaner than
introducing a new tier.
anonymousKey appears in BOTH trimmed AND full /health payloads — the
trimmed payload's purpose is to be readable by anonymous clients so they
can self-bootstrap. Tested.
Startup warns on prereq failure (anonymous_key_advertised_but_denied /
anonymous_key_advertised_but_no_anonymous_key_exists) so the relaxed-
posture failure mode is observable. Graceful-degrade: server still
boots; handleHealth re-checks at request time and silently omits the
field when any prereq fails (defense-in-depth).
## D70 — ADR 0011 (anonymous-key deployment-context limits)
New ADR codifying the trusted-LAN-only invariant.
Trade-off documented: anonymous key advertised via /health = anyone who
can reach the server can read /health and use the key. Acceptable ONLY
when "anyone who can reach the server" ≈ "trusted family on the LAN".
Public-internet deployment = instant compromise.
Soft enforcement: server logs startup warn if BIND_ADDRESS resolves to
a public IP AND advertise_anonymous_key: true. No hard allowlist (TLS-
fronted private networks indistinguishable from public from server's
perspective).
Re-evaluation trigger: any time OLP gains "expose to public internet"
deployment mode (e.g., Cloudflare Tunnel guidance in README), revisit.
References ADR 0007 § 7 (identity classes), ADR 0010 § Phase 4 charter
D68-D70 line, OCP server.mjs:148/1454/1488/1555 (PROXY_ANONYMOUS_KEY
reference).
## Test count
658 → 672 (+14 D68-D70 tests across Suite 34: 5 keys.mjs unit + 6 /health
HTTP integration + 3 CLI integration).
## Scope discipline
NO /v1/messages entry surface (out of Phase 4 per ADR 0010).
NO olp-plugin/ Telegram plugin (D71-D73).
NO docs/integrations/*.md files (D71-D73).
NO CHANGELOG / package.json version bump (Phase 4 close handles versioning;
only package.json bin entry for olp-connect added).
NO new npm deps.
## Authority
- ADR 0010 § Phase 4 D-day plan D68-D70 line
- ADR 0011 (this commit — new ADR)
- ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity
classes) + § 9 (keygen flow) — extended by D69 plaintext_advertise
- OCP ocp-connect /Users/taodeng/ocp/ocp-connect (port reference)
- OCP server.mjs:148, 1454, 1488, 1555 (PROXY_ANONYMOUS_KEY reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 3:
/health.anonymousKey + olp-connect zero-config UX)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D68-D70 reviewer P1 + P2 fold-in — README impact note + listKeys redaction + schema_version note
Reviewer APPROVE WITH MINOR — 0 P0, 1 P1, 2 P2; all three folded in.
P1 — README impact note for new Phase 4 user-visible surfaces.
Per CLAUDE.md release_kit.new_feature_doc_expectations:
- new env / config knob → README § Environment Variables
- new endpoint or response field → README § API Endpoints
- new CLI surface → dedicated §
README now documents:
- /health.anonymousKey optional field (in API Endpoints table) with cross-
ref to ADR 0011 + the three-prereq gate
- streaming.heartbeat_interval_ms config (D61) + auth.advertise_anonymous_key
config (D69) under new "config.json keys introduced at Phase 4" subsection
- Operator CLI surfaces summary: olp / olp-connect / olp-keys keygen
--anonymous --advertise, with cross-refs to ADR 0010 + 0002 Amendment 7
P2-1 — lib/keys.mjs listKeys() now strips plaintext_advertise alongside
token_hash. Callers wanting the advertised plaintext for the /health
publication path MUST go through findAdvertisedKey() — the only sanctioned
read site. Defends against a future caller of listKeys() leaking the
plaintext into logs / HTTP responses / dashboards. Tests still pass
(no in-repo caller of listKeys depends on plaintext_advertise being
present).
P2-2 — ADR 0011 now documents the schema_version-stays-at-1 decision
explicitly. Additive optional fields don't require bump per ADR 0007 § 4,
but a future archaeologist asking "why didn't D69 bump schema_version?"
now has a one-line answer. Same paragraph documents the listKeys()
redaction policy in plain text alongside the manifest-field contract.
672/672 tests still pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e69e908dae |
feat+test+docs: D64-D67 — olp Node CLI + doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 (#42)
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule 11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via the new contract method; ADR amendment authorizes the contract change. Single PR is the minimum reviewable unit for "does plugin amendment + plugin impl + doctor consumer line up?" ## D64 — bin/olp.mjs Node CLI scaffold Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with python3 JSON parsing is a known fragile point; OLP standardizes on Node). Subcommands: - status / health / usage / models / cache — HTTP calls to existing endpoints - providers — local: cross-references models-registry.json + config.json - chain show [<model>] — local: prints routing.chains from ~/.olp/config.json - logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query - restart — launchctl (macOS) / systemctl --user (Linux), best-effort - keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated) - doctor [--check <id|category>] [--json] — D65 framework - help / --help / -h Token / URL resolution: - OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default) - OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces helpful 401 message pointing at olp-keys keygen) Output: - Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed under --json) - --json: raw JSON for scripting - Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth No npm deps. Built-ins only. Installed via package.json bin entry so `npx olp <subcommand>` works. ## D65 — lib/doctor.mjs framework Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the OCP audit's #2 inheritance candidate). Machine-readable next_action so a Claude Code / Cursor / etc. agent can self-repair OLP. Check shape: { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } } Built-in checks: server.running, server.version, config.exists, config.providers_enabled, config.chains_configured, auth.owner_key_exists, system.node_version. Per-provider checks collected dynamically via provider.doctorChecks() per D67. --json output: { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install| fix_server|fix_provider, next_action: { ai_executable: [], human_required: [], verify: 'olp doctor' }, summary } --check <id-or-category> for tight repair-loop fast paths. ## D66 — Per-provider doctorChecks() implementations Each shipped plugin contributes its own checks (lives in plugin file so the provider's maintainer updates it naturally): - anthropic.mjs: cli_available (claude --version) + oauth_token_present (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env) - codex.mjs: cli_available (codex --version) + auth_present (~/.codex/config.json) - mistral.mjs: cli_available (vibe --version) + api_key_present (MISTRAL_API_KEY env OR ~/.vibe/.env) Each fail returns evidence.fix_commands (for ai_executable[]) or evidence.human_required (e.g., 'run: claude auth login'). ## D67 — ADR 0002 Amendment 7 New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the Provider contract. Backwards compatible — plugins without doctorChecks() contribute no provider checks (default behavior). Validator extended in lib/providers/base.mjs validateProvider. ## Test count 636 → 658 (+22 tests across Suites 32, 33). - Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE, unknown-subcommand, providers local + --json, chain show, status via ephemeral server with owner token, ECONNREFUSED → exit 2, resolveBearerToken precedence - Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches (noop / fresh_install / fix_server / fix_oauth / fix_provider), collectProviderChecks reads doctorChecks(), throwing plugin captured, --check filter, built-in checks against temp HOME, anthropic plugin probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units ## Scope discipline server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints. No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram plugin. No IDE docs bundle. No CHANGELOG / package.json version bump (Phase 4 close handles versioning; only package.json bin entries updated). ## Known limitations (flagged for reviewer) - olp restart not unit-tested (would require mocking child_process.spawn in invasive way; manual smoke-test only at this D-day) - olp logs --level filtering matches optional level field if present in audit-event objects; appendAuditEvent already populates it where meaningful — no schema change needed in this bundle - olp usage panel shape inferred from lib/audit-query.mjs exports; if /v0/management/dashboard-data wire shape differs in subtle ways, formatter degrades to '?' but --json always works ## Authority - ADR 0010 § Phase 4 D-day plan D64-D67 line - ADR 0002 Amendment 7 (this commit — new amendment) - OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference, translated to Node) - OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs (framework reference) - 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2: olp doctor machine-readable next_action) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in. P2-1 — Shell-quote interpolated paths in fix_commands. lib/doctor.mjs config.exists fix_commands previously interpolated ${olpHome} / ${configPath} unquoted into the printf template. A malicious OLP_HOME env value containing shell metacharacters could inject commands into the suggested-fix string an AI agent (or human) pastes back into a terminal. Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for embedded single quotes per POSIX shell rules). Risk surface is narrow at family scale (operator local env, single-user proxy), but hardening cost is one helper. P2-2 — Document launchctl kickstart -k env-stale pitfall. cmdRestart header now carries an explicit caveat that `launchctl kickstart -k` does NOT re-read the plist EnvironmentVariables block — launchd uses cached env from the most recent bootstrap. This is a known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The comment documents the bootout/bootstrap dance for env reloads and notes that the Phase 4 installer (post-D73) will expose `olp restart --full` for the safer reload path. 658/658 tests still pass; the _shellQuote change is invisible to existing tests because the test fixtures use safe paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e6701ff698 |
feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] + /v0/management/status (#41)
* feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] ring + /v0/management/status
First substantive Phase 4 implementation. Bundle of 3 D-days per Iron Rule 11
IDR rationale: all three converge on the same observability surface (status
endpoint reads recentErrors + provider stats + heartbeat-related counters;
heartbeat shares the streaming branch with recentErrors emission; all live
in server.mjs).
## D61 — SSE heartbeat
Ported from OCP server.mjs:660-685 startHeartbeat() with the OCP db11105
"eager-headers-post-spawn" fix folded in from day one.
- New config field streaming.heartbeat_interval_ms in ~/.olp/config.json
(default 0 = disabled, matching OCP's safe default)
- When enabled (>0), streaming branch emits `: keepalive\n\n` SSE comment
every interval_ms ms during silent windows
- Timer resets on every real chunk written
- Cleanup on stream end / error / abort / client disconnect
- SSE_DEFAULT_HEADERS constant centralizes Content-Type / Cache-Control /
Connection / X-Accel-Buffering: no (the last was the missing OCP lesson
that broke long streams behind nginx 60s idle)
- Per-attached-client lifecycle (each tee output gets its own timer)
- One heartbeat_active log per stream on first fire; no per-fire log noise
Note: heartbeat NOT wired in the buffered-replay streaming branch because
that branch writes the burst synchronously into the socket buffer — no
silent windows exist there. Inline comment notes this.
## D62 — recentErrors[20] ring buffer
Module-scope bounded ring, surfaced via /v0/management/status at D63.
- _pushError({ error, provider, path, statusCode }) entry shape:
{ time (ISO8601), message (200-char cap), code, provider, path, status_code }
- Filter: only ProviderError OR statusCode >= 500 (401/403 brute-force noise
excluded — protects ring from auth-probe flooding)
- Path sanitization via .replace(/\/[\w./-]+/g, '[path]') ported from
OCP server.mjs:1395 — strips internal paths before they leave the proxy
- Wired into 5 server-side error paths: chain-exhausted, pre-first-chunk
streaming error, mid-stream IR error chunk, fallback-engine programming
error, router-level unhandled error
- In-memory only (not persisted across restart) per OCP precedent
- Test seam __clearRecentErrors / __snapshotRecentErrors
## D63 — /v0/management/status combined endpoint
OCP /status equivalent, OLP-namespaced per stricter discipline.
- New route GET /v0/management/status, owner-only_block (matches ADR 0007
§ 7 + ADR 0008 Phase 3 management endpoint gating pattern)
- Returns { ok, version, uptime_ms, uptime_human, started_at,
providers: {enabled, available, status},
stats: {total_requests, active_requests, cache: cacheStore.stats()},
recent_errors: [<ring>], generated_at }
- _totalRequests + _activeRequests module-scope counters incremented at top
of handleChatCompletions; _activeRequests decremented in res.on('close'/
'finish') with idempotent guard
- Counters NOT exposed via /health (owner-trim intentional there); only via
/v0/management/status (owner-only_block)
- Reuses _runOwnerOnlyManagementEndpoint helper from D50 Phase 3 work
## Test count
623 → 636 (+13 D61-D63 tests across Suites 29, 30, 31). All 636 pass locally.
## Scope discipline
server.mjs + test-features.mjs + lib/fallback/engine.mjs only (engine.mjs
touched only to extend loadFallbackConfigSync to surface the new
streaming.heartbeat_interval_ms field; no engine behavior change).
Untouched: provider plugins, IR, cache layer, dashboard.html, audit-query,
README, CHANGELOG, package.json. /health payload unchanged. None of the
existing 623 tests regressed.
## Authority
- ADR 0010 § Phase 4 D-day plan (D61-D63 line)
- OCP server.mjs:660-685 (startHeartbeat reference impl)
- OCP commit db11105 (eager-headers-post-spawn fix)
- OCP server.mjs:301, 354-358 (recentErrors ring pattern)
- OCP server.mjs:1151-1188 (/status combined endpoint pattern)
- OCP server.mjs:1395 (error path sanitization)
- ADR 0007 § 7 (identity classes — owner-only_block gating)
- ADR 0008 (management endpoints pattern reused)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, items 1 + 4)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D61-D63 reviewer P2 fold-in — explicit 401/403 filter + null status_code for post-headers
Reviewer APPROVE WITH MINOR — 0 P0/P1, 2 P2 (both about _pushError filter
clarity / defense-in-depth).
P2-1 — explicit 401/403 reject at function level. The current call sites
never invoke _pushError from authenticate() failures (call-site discipline),
but a future contributor passing a ProviderError tagged statusCode=401
would slip past the isProviderError branch and flood the ring under
brute force. Added explicit `if (statusCode === 401 || statusCode === 403)
return;` as defense-in-depth.
P2-2 — pass `statusCode: null` for the two streaming-error-after-first-
chunk _pushError sites instead of `statusCode: 200`. Headers are already
sent so any numeric status is misleading; null + record-by-error-code is
the explicit intent. Avoids a future filter-refactor accidentally
dropping these entries because they look like 200-OK.
Test count unchanged 636/636 pass (filter behavior identical from
call-site perspective; the changes are defensive + intent-clarifying).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9b66326e72 |
feat+test: D58 — server.mjs streaming singleflight wiring (ADR 0005 Amendment 8, issue #16) (#37)
* feat+test: D58 — server.mjs streaming singleflight wiring (ADR 0005 Amendment 8 §§7,11,12, issue #16) Second of three D-days for v1.x roadmap #1. D57 landed the cache-layer primitive (cacheStore.getOrComputeStreaming); D58 wires it into the streaming branch of server.mjs and adds the X-OLP-Streaming-Inflight header. D59 closes issue #16 + polishes README known-limitations. ## What server.mjs — replaced the streaming branch (formerly the peek+spawn pattern at lines 1138-1327) with cacheStore.getOrComputeStreaming(...): - tryAcquireSpawn moves INSIDE sourceFactory closure (§7). Only first caller acquires; attached joiners share the slot. releaseSpawn lives in the source generator try/finally so it fires once on source completion / error / abort. - CONCURRENCY_LIMIT thrown by the factory triggers fallthrough to the buffered path (preserving today's behaviour); any other pre-stream factory error surfaces a 502. - X-OLP-Streaming-Inflight: source | attached header per §11 (cache_hit role omits — X-OLP-Cache: hit already says it). The §11 'solo' value is deferred to a future amendment — observable only post-stream via the streaming_inflight_source_done log event's attached_count. - auditCtx.cache_status: 'miss' for source, 'streaming_attached' for joiners, 'hit' for the TTL-race cache_hit branch. - res.on('close', () => stream.return?.()) propagates client disconnect into the tee's attachedClients accounting (§9). Note: Node 25 emits 'close' on ServerResponse, NOT on IncomingMessage — empirically verified in test 28g. - Cache writes now happen inside the cache layer's tee task on source completion (§4). Server still issues cacheStore.delete on stop-less exhaustion to preserve D16 truncated-not-cached invariant — the cache layer is IR-agnostic and writes accumulatedChunks unconditionally; the IR-aware server deletes the entry if no stop chunk was observed. - The pre-cache-store-acquire and matching releaseSpawn-on-503 branches are gone — they were vestigial once the factory owns acquire+release. lib/audit.mjs — JSDoc cache_status enum extended with 'streaming_attached'. Free-form string at the wire (no schema validator on append); the JSDoc is the source of truth for the consumer enum. test-features.mjs Suite 28 — 7 HTTP integration tests: - 28a single SSE request (source role + cache populated + second request → X-OLP-Cache: hit) - 28b 2 concurrent identical SSE → one spawn, source + attached roles, identical chunk sequences delivered - 28c TOCTOU regression — pre-populated cache + 2 concurrent → both hit buffered replay path, no streaming branch entry, no X-OLP-Streaming-Inflight header - 28d mid-stream join — late joiner receives accumulated burst + live tail - 28f one-of-N disconnect — source NOT aborted; survivor completes - 28g ALL clients disconnect → source aborted; no cache write; subsequent request gets fresh source spawn - 28h CONCURRENCY_LIMIT fallthrough — factory throws at maxConcurrent=1; buffered path's chain-exhausted 502 surfaces (28e backpressure deferred to Suite 27g unit-level coverage.) ## Scope server.mjs + lib/audit.mjs + test-features.mjs. Untouched: cache/store.mjs (D57 frozen), provider plugins, fallback engine, IR. CHANGELOG and package.json bump fires at D59 close. ## Authority - docs/adr/0005-cache-cross-provider.md Amendment 8 §§7, 8, 9, 11, 12 - docs/v1x-roadmap.md #1 - GitHub issue #16 (TOCTOU window; closed in D59) - ADR 0002 Amendment 6 (D38 tryAcquireSpawn/releaseSpawn semantics that §7 now invokes inside the sourceFactory closure) ## Test count 615 → 622 (+7 D58 integration tests). Local: 622/622 pass. ## Iron Rule 10 follow-up notes for the reviewer - res.on('close') vs req.on('close'): switched to res after empirical verification (28g fails on req under Node 25). Comment in code. - Cache-layer write + server-layer delete for stop-less exhaustion is cosmetically inconsistent with streaming_inflight_source_done's cache_written: true log. Functionally correct; flagged for future amendment. - 'solo' header value deferred — would need trailer mechanics or post-stream emission. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D58 reviewer follow-ups — P2-1 (audit-query gauge drift) + P2-2 (stop-less HTTP test) Fold-in for D58 PR #37 fresh-context opus reviewer findings (APPROVE WITH MINOR — 0 P0/P1, 4 P2). P2-3 (`isFirst` unused) and P2-4 (`solo` not emitted) are design-acceptable per ADR 0005 Amendment 8 §11 and left as-is. P2-1 — `lib/audit-query.mjs` gauge reconciliation. `aggregateRequests` and `cacheHitRateWindow` previously counted `streaming_attached` rows in `total` / `pe.total` without contributing to hit/miss/bypass numerators, breaking the invariant that the cache_status breakdown should sum to the total. Added an explicit `streaming_attached` field to both the global return shape and the `by_provider` shape; the counter is excluded from `hit_rate` numerator AND denominator (joiners did not hit a literal cache so they don't belong in either side of the ratio). Test count is unchanged for D49 suites — they only assert presence + non-negative + reconciliation invariants that the new field preserves; if a test asserted exact value equality on a fixture with NO streaming_attached rows, the new field defaults to 0 and the assertion still passes. P2-2 — Suite 28 stop-less HTTP coverage gap. Test 28i fires an SSE request to a fake provider whose source generator returns WITHOUT a {type:"stop"} chunk; asserts (a) the synthetic truncation marker appears in the body (D26 F19 in-band signal), (b) [DONE] terminator follows, (c) a subsequent identical request triggers a fresh spawn (cache was NOT populated by the truncated stream). Pins the D58 `cacheStore.delete` path at server.mjs:1344-1346 end-to-end. 622 → 623 (+1 D58 follow-up test). 623/623 pass locally. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1062e88e77 |
feat+test: D57 — cache layer streaming singleflight (ADR 0005 Amendment 8, issue #16) (#36)
* feat+test: D57 — cache layer streaming singleflight (ADR 0005 Amendment 8, issue #16) First of three D-days implementing the v1.x roadmap #1 streaming-path singleflight. D57 lands the cache-layer coordination primitive only; server.mjs wiring is D58, docs polish is D59. ## What lib/cache/store.mjs — new method `getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts) → { stream, isFirst, role }`. Three outcomes per Amendment 8 §1: cache_hit (no spawn), attached (joins existing inflight), source (first caller, spawns via factory). Backed by `_streamingInflight` Map keyed by `${keyId}\\0${cacheKey}` with synchronous check+insert per Amendment 8 §1 + §6 atomicity invariant. Internals (Amendment 8 §§2-10, §14): - StreamingInflightEntry + AttachedClient typedefs - Tee fan-out loop: single reader drains source, pushes to accumulatedChunks + every client's queue, fires per-client resolveNext promises - Late-joiner replay buffer (synchronous drain on attach; reject with synthetic STREAM_BACKPRESSURE terminator if drain exceeds cap) - Per-client backpressure (PER_CLIENT_QUEUE_CAP=1MB default, overridable) - Replay buffer cap (ACCUMULATED_REPLAY_CAP=10MB default, overridable; cache write skipped if exceeded) - AbortController propagation: when attachedClients.size === 0 after client iterator return(), source.return() + abort.signal fire - D38 coordination via sourceFactory closure (factory wraps tryAcquireSpawn internally; cache layer just invokes it once) lib/providers/base.mjs — `'STREAM_BACKPRESSURE'` added to PROVIDER_ERROR_CODES per Amendment 8 §8. NOT in HARD_TRIGGER_CODES (engine update lands in D58; whitelist-only map gives correct default). test-features.mjs Suite 27 — 12 new tests (27a-27l) covering: solo stream, 2-concurrent dedup, mid-stream join + post-completion cache_hit, per-client disconnect with other clients continuing, full disconnect → abort, source error propagation, per-client backpressure, replay cap, TTL race during inflight, sourceFactory throw, stats accuracy, composite key isolation. ## Scope Strictly cache layer + base.mjs PROVIDER_ERROR_CODES entry. Untouched: server.mjs, providers/{anthropic,codex,mistral}.mjs, fallback/engine.mjs, IR, dashboard.html, README, CHANGELOG, package.json. D58 will wire server. ## Authority - docs/adr/0005-cache-cross-provider.md Amendment 8 (2026-05-25, design ratified at D42, implementation gated on maintainer "go" — fired 2026-05-25 post-v0.3.1) - docs/v1x-roadmap.md #1 (streaming SF + TOCTOU close) - GitHub issue #16 (round-6 cold-audit F13 sibling TOCTOU window) - ADR 0002 Amendment 6 (D38 tryAcquireSpawn/releaseSpawn — invoked via sourceFactory closure at server layer, not directly by cache) ## Test count 603 → 615 (+12 D57 tests). Local: 615/615 pass. ## Iron Rule 11 (IDR) D57 is the cache-layer minimum reviewable unit. D58 wires server.mjs + adds X-OLP-Streaming-Inflight header + integration tests through HTTP layer. D59 polishes README + closes issue #16. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: D57 reviewer follow-ups — P2-2 (constant cleanup) + P2-3 (join-event deferral note) Fold-in for D57 PR #36 fresh-context opus reviewer findings (APPROVE WITH MINOR — 0 P0/P1, 3 P2). P2-1 is the D58 split (already planned); this commit addresses P2-2 + P2-3. P2-2 (cosmetic) — replace `Object.freeze({ value: X }).value` baroque declaration of PER_CLIENT_QUEUE_CAP_DEFAULT + ACCUMULATED_REPLAY_CAP_DEFAULT with a plain `export const X = 1*1024*1024`. The freeze-then-extract pattern freezes a throwaway wrapper, which the `.value` immediately discards — does nothing useful. Const declaration already gives binding immutability. P2-3 (observability event parity deferral) — ADR 0005 Amendment 8 §11 lists `streaming_inflight_join` as one of four log events. The cache layer cannot emit it correctly because provider/model identity lives in the sourceFactory closure (server-layer concern). Added TODO note in `_attachClient` pointing at D58 server wiring where the event will fire on the consumer of `role: 'attached'`. The other three §11 events (stream_backpressure_disconnect / streaming_inflight_source_done / streaming_inflight_abort) ARE emitted from the cache layer with {client_id, composite_key, ...} payloads; provider/model is enriched at the server-side wrapper. No test-surface change. 615/615 still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5ebe3dc77c |
feat+test+docs: D56 — v1.x cleanup batch #1: AUTH_MISSING tuple test + /health activeSpawns (#34)
Post-Phase-3 cleanup batch #1. Bundles two small v1.x-roadmap deferrals (#4 and #7 in docs/v1x-roadmap.md) into one D-day. No new user-facing feature; pins existing behaviour into tests + finally wires the ADR-documented activeSpawns field on /health. (1) AUTH_MISSING tuple test (v1.x #7, D45 P3 deferral) New engine-level test in Suite D40 asserts that an AUTH_MISSING hop produces a fallbackDetail tuple with trigger_type: 'auth_missing' AND that the engine does NOT advance past it (HARD_TRIGGER_CODES[AUTH_MISSING]=false). Pre-D56 the behaviour was implicit through neighbouring tests; D56 makes it explicit so a future refactor that moves the tuple-push past the auth_missing branch fails this test directly. Authority: ADR 0004 § Decision (hard-trigger table) + Amendment 5 (tuple shape). (2) /health activeSpawns integration (v1.x #4, ADR 0002 Amendment 6 forward note) handleHealth now surfaces providers.status.<name>.activeSpawns (sourced from D38 getActiveSpawnCount(name) — already imported at server.mjs:39). The field is computed BEFORE healthCheck() is awaited so it remains present even when healthCheck() throws — getActiveSpawnCount is a cheap in-memory counter read. New Suite 21c-extra test pins the field presence + numeric + non-negative for every enabled provider in the fixture. Authority: ADR 0002 Amendment 6 § "Forward note — exposing the counter via HTTP" — names the path providers.status.<name>.activeSpawns and pins it as the Phase 1 deferral that becomes due once management endpoints exist (which they now do, post-Phase-3). Test count: 601 → 603 (+2 D56 tests; 603/603 pass locally). Release-kit: under Unreleased per phase_rolling_mode (Phase 3 closed at v0.3.0; Phase 4 entries also land here once Phase 4 opens — D56 is a cleanup-D-day that ships under v0.3.x, not a Phase 4 deliverable). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
68e50da68a |
fix+test+docs: D53 — tried_providers schema semantic fix (D45 P2 deferral closed) (#30)
Sixth Phase 3 D-day. Small focused fix for the D45 fresh-context opus
reviewer P2 finding that was deferred: auditCtx.tried_providers on the
key_no_provider_access 403 path was being stamped with the ORIGINAL
chain (which was filtered out, never dispatched), distorting downstream
audit queries like "which providers did key X actually call".
CHANGES:
- server.mjs handleChatCompletions ~L815: on key_no_provider_access
403, auditCtx.tried_providers = [] (was _originalChainProviders).
The configured-but-blocked chain still appears in the human-
readable error message body — the audit just doesn't claim those
providers were "tried" when the server's filter dispatched zero.
- docs/adr/0007-multi-key-auth.md § 8 amendment: new paragraph
spelling out the tried_providers semantic. "The list of providers
the server actually dispatched a spawn against. A provider that
was configured in the chain but filtered out by providers_enabled
gating is NOT included — the key didn't try the provider, the
gate did. On the 403 path tried_providers is the empty array."
Plus a forward note that audit log rotation moved to Phase 3 /
ADR 0008 § 5.
- test-features.mjs Suite 20h-extra-audit (+1 test — 600 → 601):
creates guest key with providers_enabled: ['mistral']; fires
request for Anthropic-routed model; asserts 403
key_no_provider_access; reads audit row from audit.ndjson;
asserts tried_providers === []. Pins the D53 semantic against
regression.
- CHANGELOG.md: D53 entry under Unreleased.
NOT IN D53:
- E2E + docs polish (D54)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 600 → 601 (+1). Verified locally via npm test.
AUTHORITY:
- ADR 0007 § 8 amendment (D53, 2026-05-25).
- D45 fresh-context opus reviewer P2 deferral note.
- CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: small entry-surface change (audit context
field assignment on one error path) + ADR amendment + new test. Per
ALIGNMENT.md Rule 1 the ADR amendment is the authority citation for
the server change. No provider plugin / IR / models-registry change.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
408d5a839a |
feat+test+docs: D52 — daily audit rotation (lib/audit.mjs + bin/olp-audit-rotate.mjs) (#29)
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to lib/audit.mjs
per ADR 0008 § 5 + ships an external cron tool. Rotation is
SYNCHRONOUS at v0.3.0 — synchronous design eliminates the race that
an async wrapper would create between date-change-detection and the
append.
lib/audit.mjs EXTENSIONS:
- New _maybeRotateAudit({ olpHome, logEvent }) (sync): probes live
audit.ndjson; if it holds events from past UTC date, renames it
to audit-YYYY-MM-DD.ndjson. Idempotent. If target file exists
(cron beat in-server check), logs warn + skips per § 5.3.
- appendAuditEvent extended: cheap fast-path date check via module-
cached _lastSeenUtcDate. On date change, calls _maybeRotateAudit
synchronously BEFORE appendFileSync — so old-date events land in
the rotated file and new-date events land in the fresh live file.
No event straddles the boundary.
- Why SYNCHRONOUS: an async wrapper would let the sync
appendFileSync race the not-yet-completed renameSync, landing
today's event in the about-to-be-renamed file. Sync rotation is
the only correct ordering at the append-fired-from-many-routes
scale OLP runs. (Test 26b-1 caught this during local run; the
initial async-wrapper implementation failed because the live
file at assertion time didn't exist.)
- New exports: _maybeRotateAudit (sync), getAuditRotateCount,
getAuditRotateFailCount, __resetAuditRotateState,
__setLastSeenUtcDateForTesting.
- First-event-date discovery: when probing the live file's date,
reads only the first ndjson line + parses its ts. Falls back to
file mtime if events absent (corrupt/empty edge).
bin/olp-audit-rotate.mjs (~95 lines): external cron tool per § 5.2.
Calls _maybeRotateAudit once + reports outcome. Exit codes 0
(success or no-op), 1 (bad usage), 2 (rotation failed). Installed
via package.json bin so `npx olp-audit-rotate [--olp-home=<path>]`
works. Example cron line in file header.
CONCURRENT-SAFETY (§ 5.3):
In-process sequential appends after the first date-change detection
short-circuit via the updated _lastSeenUtcDate cache → exactly 1
rename even under N sequential appends. Cross-process (cron + server)
coexistence handled by the "target already exists → skip + warn"
branch.
TESTS — Suite 26, +12 (588 → 600):
26a-1..5: _maybeRotateAudit (no live file / today already /
yesterday→rotate / idempotent re-call / cron-race target-exists
warn)
26b-1: appendAuditEvent past UTC date change triggers sync rotation
+ append lands in fresh live file
26c-1: 10 sequential appendAuditEvent across date change → exactly
1 rotation + all 10 events in new live file
26d-1..4: bin/olp-audit-rotate.mjs CLI (--help / no-live-file /
yesterday-file-rotates / unknown-flag exit 1)
26e-1: rotated files queryable via lib/audit-query.mjs
discoverAuditFiles + readAuditWindow cross-file read
package.json: bin.olp-audit-rotate + scripts.olp-audit-rotate entries
added.
DOCUMENTATION:
- AGENTS.md: lib/audit.mjs marker promoted ✅ (D45 append + D52
rotation both shipped); new bin/olp-audit-rotate.mjs entry.
- CHANGELOG.md: D52 entry under Unreleased per release_kit overlay.
NOT IN D52:
- tried_providers schema fix (D53; D45 P2 deferral)
- E2E + docs polish (D54)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 588 → 600 (+12). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger).
- ADR 0008 § 5.2 (external cron alternative).
- ADR 0008 § 5.3 (concurrent-rotation safety + cron-coexistence).
- ADR 0008 § 5.4 (renamed-file query path consumed by D49 lib/
audit-query.mjs).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: extends lib/audit.mjs (a Phase 2 internal
module) + adds new bin/ CLI + small package.json bin/scripts entries.
No provider plugin / entry surface / IR change.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
251b578114 |
feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3) (#28)
* feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3)
Fourth Phase 3 D-day. Replaces the D50 dashboard.html placeholder with
the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no
build step, no framework, no CDN (Lane 1 = A). 30s page poll with
document.visibilityState pause/resume (Lane 4 = A).
4 PANELS (all rendered from /v0/management/dashboard-data — single
backing endpoint per Lane 2 in-memory query model):
Panel 1 — Per-provider quota
Table: { Provider | Available | Status }. Null available → "n/a"
pill; provider.quotaStatus() error → red status pill (graceful
degradation per ADR § 9).
Panel 2 — Last 24h: request count + cache hit + fallback rate
Per-provider row: { Requests | Cache hit % | Fallback rate % }.
Cache hit from cache_hit_24h.by_provider[p].hit_rate; fallback
rate computed from window_24h.by_provider[p].fallback_count/count.
Panel 3 — Request count last 30 days (SVG sparkline)
Vanilla SVG bar chart with <title> tooltips showing per-day per-
provider breakdown. Y-axis: requests per day (max-scaled). X-axis:
30 daily UTC buckets.
Panel 4 — Top fallback chains (last 24h)
Numbered table: { # | Chain (monospace, arrow-joined) | Count |
First seen | Last seen }.
POLL + VISIBILITYCHANGE PAUSE (ADR 0008 § 6.5):
- setInterval(refresh, 30000) after initial fetch.
- document.addEventListener('visibilitychange') → stopPolling() on
hidden / refresh()+startPolling() on visible.
- Prevents 2880 background polls/day per owner when tab hidden.
ERROR HANDLING:
- 401 from dashboard-data → in-page error banner explains owner-tier
requirement + suggests SSH-tunnel + header-injection workaround.
- Other HTTP errors → generic "HTTP <code>" banner; console.warn
for operator debugging.
- Per-panel empty states ("Loading…", "No requests in window.",
"No fallback chains triggered.").
CRITICAL CORRECTNESS INVARIANTS (ADR 0008 § 6 + Lane 1 = A):
- No <script src> — entire JS inline (Suite 25d asserts).
- No <link rel="stylesheet" href> — all CSS in <style> (25d).
- Only one backing endpoint hit: /v0/management/dashboard-data
(Suite 25e asserts).
- 401 path keeps panels in last-good state rather than clearing —
operator sees the error banner + can debug.
TESTS — Suite 25, +6 (582 → 588):
25a: owner /dashboard response contains all 4 panel container IDs
25b: JS declares POLL_INTERVAL_MS = 30000 + setInterval/clearInterval
25c: visibilitychange listener + document.visibilityState check
25d: NO external script src / NO external stylesheet href (Lane 1 = A
pinning)
25e: dashboard JS fetches /v0/management/dashboard-data only
25f: 401 in-page error banner mentions owner-tier guidance
MANUAL SMOKE (ADR 0008 § 10 #12):
Dashboard renders without console errors in a real browser when
served by a running OLP instance + owner-tier Bearer via SSH-tunnel
+ header-injection extension. Not automated at Phase 3.
DOCUMENTATION:
- AGENTS.md: dashboard.html marker promoted 🟡 D50 placeholder → ✅
D51 full UI.
- CHANGELOG.md: D51 entry under Unreleased per release_kit overlay.
NOT IN D51:
- Daily audit rotation (D52)
- tried_providers schema fix (D53; D45 P2 deferral)
- Phase 3 close (D55; v0.3.0; maintainer-triggered)
Test count: 582 → 588 (+6). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll +
visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A
(30s poll) + Lane 5 = B (full 4-panel scope).
- ADR 0008 § 9 (graceful degradation surfaced in Panel 1).
- ADR 0008 § 10 #12 (HTML smoke criterion satisfied at server-side
level).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR replaces an existing entry-surface
static file (dashboard.html). Per Rule 5: management surface, not
OpenAI-spec-compatible — outside /v1/* spec scope. No code change in
server.mjs / lib/ / providers / IR / models-registry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: D51 fold-in — dashboard.html cosmetic polish (opus P3)
Fresh-context opus reviewer (PR #28) flagged 4 P3 cosmetic findings;
addressing the 2 trivial ones inline. The other 2 (visibilitychange
race + defensive date null-check) are negligible at family-scale per
reviewer; deferred to Phase 4 if UX feedback warrants.
- Line ~193: deleted orphan empty <text> SVG element (no textContent,
rendered nothing — debris from initial pass).
- Line ~200 comment: was "Date labels (first / mid / last)" but only
first + last rendered. Tightened to clarify intent + note mid label
deferred to Phase 4 if needed.
No behavior change. 588/588 tests pass.
Authority: PR #28 fresh-context opus reviewer P3 findings (2 of 4
addressed; remaining 2 documented as negligible).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f9f2eaa059 |
feat+test+docs: D50 — server.mjs management endpoints (Phase 3 dashboard wire-up) (#27)
* feat+test+docs: D50 — server.mjs management endpoints (Phase 3 dashboard wire-up)
Third Phase 3 D-day. Wires the D49 lib/audit-query.mjs aggregate query
layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8.
Ships a placeholder dashboard.html at repo root (D51 lands the full
multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit
+ touchLastUsed pattern.
4 NEW ENDPOINTS — all owner_only_block per ADR 0008 § 8:
GET /dashboard
Serves dashboard.html (text/html). D50 stub explains state +
lists backing endpoints. D51 replaces with full UI.
GET /v0/management/dashboard-data
Full aggregate per § 7.2:
{ generated_at, window_24h, cache_hit_24h, quota,
spend_trend_30d, top_fallback_chains_24h, cache_stats }
GET /v0/management/quota
Quota subset only (per-provider provider.quotaStatus + error
capture per § 9 graceful degradation).
GET /cache/stats
Live in-memory cacheStore.stats() with generated_at wrapper.
HELPER:
_runOwnerOnlyManagementEndpoint(req, res, method, path, inner)
Factors common auth + audit ctx + owner-block + res.on('finish')
wire. inner is async (req, res, olpIdentity, auditCtx) → void.
Eliminates 4× boilerplate.
OWNER_ONLY_BLOCK MODE (ADR 0008 § 8 D48-fold-in):
authenticate → if owner_tier !== 'owner' → 401 owner_required.
Distinct from owner_only_trim (Phase 2 /health pattern). Anonymous
identity (when allow_anonymous: true) REACHES the handler and is
401'd by the owner check (Suite 24c). Allow_anonymous: false + no
header → 401 auth_required at middleware (Suite 24d).
PROVIDER QUOTASTATUS ERROR CAPTURE:
Dashboard-data + quota endpoints catch per-provider throws and
surface { provider, error, available: null } so one bad provider
doesn't fail the whole panel (ADR 0008 § 9 graceful degradation).
DASHBOARD.HTML PLACEHOLDER (~50 lines at repo root):
Explains D50 state, lists backing endpoints with curl example.
Cached in memory at first /dashboard request via _loadDashboardHtml
with module-scope _dashboardHtmlCache; falls back to in-memory stub
if file missing (defensive for test imports from non-repo cwd).
AUDIT ON MANAGEMENT ENDPOINTS (ADR 0008 § 7.5):
Every management request appends audit row including 401 paths
(verified by Suite 24j). Touch wire skips anonymous + env-owner
identities (matches Phase 2 pattern).
TESTS — Suite 24, +11 (571 → 582):
24a-d: /dashboard owner_only_block matrix (owner 200 / guest 401
/ anonymous-with-allow_anonymous=true 401 / no-auth-with-
allow_anonymous=false 401)
24e: dashboard-data owner → 200 JSON with all § 7.2 fields
(asserts spend_trend_30d.length === 30)
24f: dashboard-data guest → 401 owner_required
24g: quota owner → 200 JSON with quota array
24h: cache/stats owner → 200 JSON shape
24h-401: cache/stats guest → 401
24i: successful dashboard-data appends audit row with status 200
+ key_id + correct path
24j: 401 (guest blocked) dashboard-data appends audit row with
error_code: 'owner_required' + owner_tier: 'guest'
DOCUMENTATION:
- AGENTS.md: dashboard.html new entry (D50 placeholder); lib/audit-
query.mjs marker note unchanged.
- CHANGELOG.md: D50 entry under Unreleased per release_kit overlay.
NOT IN D50 scope:
- Full dashboard UI (D51 — replaces dashboard.html with the real
4-panel layout + 30s poll JS)
- Daily audit rotation (D52)
- tried_providers schema fix (D53)
- Phase 3 close (D55; v0.3.0; maintainer-triggered)
Test count: 571 → 582 (+11). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 7 (endpoint definitions) + § 8 (owner_only_block
mode) + § 9 (graceful degradation) + § 7.5 (audit on management
endpoints).
- ADR 0007 § 7 (auth model reused).
- ADR 0002 § Provider contract (quotaStatus).
- ADR 0005 (cacheStore.stats source of truth).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR adds 4 new entry-surface endpoints
under owner-only_block gating + a new lib/audit-query consumer surface.
Per Rule 5: management endpoints are owner-only operational surface,
not OpenAI-spec-compatible — they exist outside the /v1/chat/completions
+ /v1/models spec scope. No provider plugin / IR change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: D50 fold-in — AGENTS.md dashboard.html duplicate (opus P3)
Fresh-context opus reviewer (PR #27) flagged a duplicate dashboard.html
entry: my D50 addition was added directly above a stale
"Planned (Phase 6) — not yet authored" line that should have been
removed. The file contradicted itself.
Fix: merge into single entry — keep the original line phrasing and
attach the D50 status update.
No code change, no test change.
Authority: PR #27 fresh-context opus reviewer P3 finding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
686794e316 |
feat+test+docs: D49 — lib/audit-query.mjs (Phase 3 audit aggregate query layer) (#26)
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory
ndjson scan; cross-file walk over audit.ndjson (live) +
audit-YYYY-MM-DD.ndjson (rotated). No server.mjs integration in this
D-day (D50 wires the consuming endpoints).
NEW lib/audit-query.mjs (~370 lines): 5 public API functions per
ADR 0008 § 4.1:
- discoverAuditFiles({ olpHome }): filesystem scan; returns
Map<date|'live', path>.
- readAuditWindow({ startMs, endMs, olpHome, logEvent }): generator
over events in half-open window [startMs, endMs). Walks rotated
date files + live file. Skips malformed lines + logs warn.
- aggregateRequests({ windowMs, olpHome }): counts + status buckets
+ by_provider + by_owner_tier + by_path + median/p95 latency over
rolling window.
- topFallbackChains({ windowMs, limit, olpHome }): top-N chains by
trigger count from events with fallback_hops > 0. Tied-count
tiebreak: ascending first_seen.
- spendTrendDaily({ days, olpHome }): daily series ending today
with sparse-fill for zero-request days. Per-day request_count +
median latency + by_provider breakdown.
- cacheHitRateWindow({ windowMs, olpHome }): audit-derived cache
hit rate (bypass excluded from denominator); per-provider + overall.
PII discipline (ADR 0008 § 4.3): every aggregate function relays only
schema fields; never message content. Suite 23g actively asserts the
absence of content/message/messages/prompt/response/body keys in every
aggregate output.
Cross-file walk semantics (ADR 0008 § 4.2): half-open window
[startMs, endMs); date-range computed once from window bounds; each
rotated date file checked; live audit.ndjson always checked (it
covers today regardless of whether the window endpoint is past
midnight).
spendTrendDaily calendar-date semantics:
days: N returns "last N calendar UTC dates ending today" — NOT
"events within a rolling N*86400-ms window" (which would span N+1
distinct UTC dates and produce off-by-one buckets at non-midnight
call times). Computed via:
for (let i = days-1; i >= 0; i--)
dates.push(_utcDateFromMs(now - i*86400*1000));
cacheHitRateWindow denominator: hit_rate = hit / (hit + miss).
Bypass is intentional non-cacheable (Anthropic cache_control marker),
NOT a cache miss; excluding it from the denominator gives a clean
cache-effectiveness signal.
TESTS — Suite 23, +27 (544 → 571):
23a-1..4: discoverAuditFiles (empty dir / live only / live+rotated /
non-audit files ignored)
23b-1..6: readAuditWindow (all-coverage / single-day / half-open
exclusivity / empty window / missing files / malformed-skip with
warn)
23c-1..4: aggregateRequests (counts + status buckets + by_provider;
by_owner_tier; median+p95 latency over realistic distribution;
invalid windowMs rejection)
23d-1..4: topFallbackChains (sort desc by count; limit truncation;
fallback_hops=0 excluded; first_seen/last_seen carried)
23e-1..3: spendTrendDaily (N-day range correctness — caught off-by-
one during local run; populated day breakdown; empty day sparse-
fill)
23f-1..3: cacheHitRateWindow (overall + per-provider hit_rate;
bypass not in denominator; cache_status=null events excluded)
23g-1..3: PII guard for aggregateRequests / spendTrendDaily /
topFallbackChains + cacheHitRateWindow — every output JSON-
stringified + scanned for forbidden PII keys
DOCUMENTATION:
- AGENTS.md: lib/audit-query.mjs new entry; lib/audit.mjs note added
that D52 extends with daily rotation.
NOT IN D49 scope:
- server.mjs endpoints consuming these queries (D50)
- dashboard.html (D51)
- lib/audit.mjs rotation extension + bin/olp-audit-rotate.mjs (D52)
- tried_providers schema fix (D53; D45 P2 deferral)
- Phase 3 close → v0.3.0 (D55; maintainer-triggered)
Test count: 544 → 571 (+27). Verified locally via npm test.
AUTHORITY:
- ADR 0008 § 4 (query API surface) + § 5 (rotation file naming
pattern) + § 3 (storage layout).
- ADR 0007 § 8 (audit ndjson event schema — input data).
- CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased.
- Standing autopilot grant.
ALIGNMENT.md scope check: this PR adds a new lib/ module. No provider
plugin / entry surface / IR change. Rule 5 commit-citation requirements
for those scopes do not apply.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
939f3e6bd9 |
feat+test+docs: D47 — bin/olp-keys.mjs keygen CLI (Phase 2 functional scope closes) (#23)
Fourth Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criterion #9 (bootstrap workflow must be reproducible without manual file editing) by shipping a minimal keygen CLI per § 9.1. Phase 2 functional scope is complete with this D-day — remaining work is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md release_kit.phase_close_trigger). NEW bin/olp-keys.mjs (~250 lines): subcommand CLI keygen [--owner|--name=X|--tier=guest|owner|--providers=csv|--force] Creates a key + prints plaintext token to stdout ONCE; manifest stores only SHA-256 hash. --force revokes existing owner keys before creating the new owner (ADR § 9.3 recovery flow). list [--owner-only|--include-revoked] Lists keys with token_hash redacted. revoke --id=<key-id> Marks the key's revoked_at; idempotent (already-revoked → no-op + status message); missing id → exit 2. Common flag: --olp-home=<path> overrides ~/.olp/ (defaults to OLP_HOME env then ~/.olp/). package.json bin field "bin": { "olp-keys": "./bin/olp-keys.mjs" } so npx olp-keys ... resolves. Also "scripts": { "olp-keys": "node bin/olp-keys.mjs" } for npm run. Module shape (testability) Exports runCli(argv, { out, err }) so tests invoke with synthetic argv + IO writers (no process spawn). Main guard auto-runs when invoked as entrypoint. Plaintext token discipline (ADR § 5 + § 9.1) Plaintext printed exactly once on stdout. Never logged, never written to manifest, never written to audit. Operators capture immediately; lost → --force revoke + regenerate. --force async correctness cmdKeygen is async and awaits each revokeKey (which is async — acquires per-key write lock per § 6.4). Sequence: revoke each existing owner manifest atomically → then createKey for new owner. Avoids race where create-new runs before revoke-old completes. TESTS — Suite 22, +20 (524 → 544): 22a-1..5: parseArgv unit (--flag=value, --flag value, boolean, mixed positional) 22b-1..5: keygen (owner default, name+providers, missing-name error, invalid-tier error, --force revoke-then-create with isolation tmpdir) 22c-1..3: list (empty, populated with token_hash-redaction check, --owner-only filter) 22d-1..4: revoke (valid id, idempotent re-revoke, missing-id error, nonexistent-id error) 22e-1..3: top-level CLI (--help / no args / unknown subcommand exit codes) DOCUMENTATION: - AGENTS.md: lib/keys.mjs marker promoted to ✅; new bin/olp-keys.mjs entry. Implementation-status-note + shipped-set updated. - README.md: Implementation Status row added for bin/olp-keys.mjs; Known limitations note rewritten to "Phase 2 functional scope complete; close pending"; new Bootstrap workflow section with copy-pasteable npx commands + recovery flow. - CHANGELOG.md: D47 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 — § 5 token format, § 9.1 minimal keygen command surface, § 9.3 recovery, § 10 acceptance criterion #9 covered. - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Standing autopilot grant. Verified: 544/544 pass via npm test (no regression in 524 pre-D47 tests; 20 new Suite 22 tests all green). Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
06f619120d |
feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) (#22)
* feat+test+docs: D46 — owner-vs-guest gating for /health + X-OLP-Fallback-Detail (Phase 2) Third Phase 2 implementation D-day. Closes ADR 0007 § 10 acceptance criteria #4 (/health payload trimming for non-owner) + #5 (X-OLP-Fallback-Detail emission gating per fallback_detail_header_policy). Phase 2 server surface now fully gated end-to-end; remaining D-days are keygen CLI surface (D47+) and Phase 2 close (v0.2.0, maintainer- triggered). server.mjs handleHealth identity-aware payload per § 7.1: - Auth gate at top — 401 for unauth + allow_anonymous=false; 200 with trimmed { ok, version } for non-owner; 200 with full payload for owner. - Trim controlled by _authConfig.owner_only_endpoints — operator removing /health from the list reverts to v0.1.1 full-payload-to- everyone (opt-out knob). - touchLastUsed fires on res.on('finish') for filesystem identities; no audit row on /health (high-volume monitoring; out of scope at Phase 2 per § 8). server.mjs withFallbackDetailHeader identity-aware emission per § 7.2: - New shouldEmitFallbackDetailHeader(olpIdentity) helper reads _authConfig.fallback_detail_header_policy: 'owner_only' (default) → emit only to owner 'all' → emit unconditionally (v0.1.1 opt-back-in) 'none' → suppress unconditionally - olpIdentity null on pre-auth paths → emit (preserves D40 v0.1.1 behaviour for pre-auth errors where identity is unknown). - withFallbackDetailHeader signature gains 3rd `olpIdentity` arg; both call sites in handleChatCompletions updated. Test surface — Suite 21, +9 tests; +1 in Suite 20 (20m); 515 → 524: 20m: /health with no auth + allow_anonymous=false → 401 (consistency with /v1/*) 21a-d: /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; owner_only_endpoints: [] opts out 21e-h: X-OLP-Fallback-Detail emission gating (criterion #5): owner_only + guest → header absent owner_only + owner → header present + valid JSON 'all' + guest → header present (v0.1.1 opt-back) 'none' + owner → header absent (full suppression) Tests use 2-hop chain anthropic→openai with anthropic primary failing to produce non-empty fallbackDetail for header content. Test-mode setup updated: Global __setAuthConfig({ allow_anonymous: true }) extended to also pass owner_only_endpoints: [] + fallback_detail_header_policy: 'all' so pre-D46 tests (Suite 18, F5 /health tests, D40 fallback-detail tests, etc.) continue to pass; Suite 21 overrides per-case. DOCS: - AGENTS.md: lib/keys.mjs marker updated to reflect D46 ship; impl- status-note + shipped-set updated. - README.md: Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI. - CHANGELOG.md: D46 entry under Unreleased per release_kit overlay. AUTHORITY: - ADR 0007 §§ 7.1 + 7.2 implementation contracts + § 10 criteria #4 + #5 covered. - ADR 0004 Amendment 5 (D40 — "Phase 2 will re-introduce owner-vs- non-owner gating when lib/keys.mjs lands"): this D-day fulfils the deferral. - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 524/524 pass via npm test (no regression in 515 pre-D46 tests; 9 new Suite 21 tests + 1 new Suite 20m test all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: D46 fold-in — opus reviewer P3 polish (constant import + comment tighten) Fresh-context opus reviewer (PR #22) returned APPROVE_WITH_MINOR with 2 P3 findings, both trivial polish. - server.mjs imports gain ENV_OWNER_KEY_ID from lib/keys.mjs (already used the namesake ANONYMOUS_KEY_ID import). handleHealth touchLastUsed guard now uses the imported constant for SPOT discipline. - handleHealth audit-deferral comment tightened: removed the "§ 8 schema doesn't mandate auditing" phrasing (overstates the ADR — § 8 doesn't enumerate paths); replaced with the operational rationale (high-volume noise, no observability value until Phase 3 Dashboard). No behaviour change. 524/524 tests pass (verified locally). Authority: PR #22 fresh-context opus reviewer findings. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
40064955ab |
feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (#21)
* feat+test+docs: D45 — server.mjs auth integration + lib/audit.mjs (Phase 2 wire-up) Second Phase 2 implementation D-day. Wires the D44 lib/keys.mjs identity layer into the request flow + lands lib/audit.mjs per ADR 0007 § 6.2 + § 8. Closes ADR § 10 acceptance criteria #1 (per-key cache isolation), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full coverage with D45), #8 (audit ndjson round-trip), #10 (OLP_OWNER_TOKEN env override — full server-side coverage), #11 (providers_enabled 403 scope). Owner-vs-guest gating for /health + X-OLP-Fallback-Detail (criteria #4, #5) remains in D46 scope. NEW lib/audit.mjs (~110 lines): - appendAuditEvent(event, opts): one JSON event per line to ~/.olp/logs/audit.ndjson (file 0600, dir 0700). § 6.2 retry: warn + 1 retry; per-process drop counter + warn on second failure; NEVER throws. Per-call OLP_HOME env resolution (matches lib/keys.mjs). - getAuditDropCount(): for future /health surface. lib/keys.mjs extended: - loadAuthConfigSync({ olpHome }): reads auth block from ~/.olp/config.json with ADR § 7.2 defaults (allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only'). Never throws; missing file / malformed JSON falls back to defaults. - _resolveOlpHome(opts): precedence opts.olpHome → process.env.OLP_HOME → ~/.olp. Per-call resolution so tests + operator deployments can redirect without code edits. server.mjs auth middleware integration: - extractToken(req): parses Authorization Bearer / x-api-key. - authenticate(req): validateKey + 401 paths (auth_required vs invalid_or_revoked_key). - isProviderEnabled(olpIdentity, providerKey): '*' = all; else array allowlist. - _authConfig loaded at startup; warn auth_allow_anonymous_enabled when true. Test seams __setAuthConfig / __resetAuthConfig. - handleChatCompletions + handleModels both gated by authenticate at top. Audit ctx built throughout; res.on('finish') appends row + fires touchLastUsed async. - IDENTITY-VS-CREDENTIALS SEPARATION: olpIdentity (new validated identity) consumed for cache namespacing + providers_enabled + audit; authContext passed to provider.spawn() REMAINS null so providers continue their own credential discovery (env / keychain / file). Per-provider per-key credential mapping is Phase 3+ per ADR § 12. - handleChatCompletions chain filtered by isProviderEnabled; empty result returns 403 key_no_provider_access. - keyId = olpIdentity.keyId (replacing hardcoded '__anonymous__'). - Audit captures fields throughout: post-auth, post-IR, post-chain (success or exhausted). Status + latency populated on res.on('finish'). TESTS — Suite 20, +15 (499 → 514): 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401) 20e: revoked key 401 (criterion #6 end-to-end) 20f: OLP_OWNER_TOKEN env override returns 200 (criterion #10 full) 20g: allow_anonymous=true + no header returns 200 (criterion #3) 20h + 20h-extra: providers_enabled=['mistral'] for anthropic model → 403; '*' baseline returns 200 (criterion #11) 20i: per-key cache namespace isolation (criterion #1 end-to-end) 20j + 20j-401: audit.ndjson written with § 8 schema fields + PII guard; 401 path also appends (criterion #8) 20k: filesystem key last_used_at populated post-request (D45 touch wire) 20l + 20l-200: /v1/models also enforces auth TEST-MODE SETUP (test-features.mjs): - process.env.OLP_HOME = mkdtempSync(...) at module load so audit + key writes don't pollute ~/.olp/. - __setAuthConfig({ allow_anonymous: true }) after server.mjs imports so pre-D45 HTTP integration tests (Suite 18 etc.) continue to pass. - Suite 20 explicitly overrides __setAuthConfig per-case to exercise production-default-off coverage. DOCUMENTATION: - AGENTS.md: lib/keys.mjs 🟡 marker updated + NEW lib/audit.mjs entry; Implementation-status-note + shipped-set updated. - README.md: Implementation Status table gains lib/audit.mjs row + lib/keys.mjs row updated; Known limitations Multi-key auth note rewritten to reflect D45 ship + D46 follow-up; new env-vars (OLP_HOME, OLP_OWNER_TOKEN) and auth config block surfaced. - CHANGELOG.md: D45 entry under Unreleased per release_kit overlay phase_rolling_mode discipline. AUTHORITY: - ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 criteria #1/#2/#3/#6/#8/#10/#11 covered). - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. - Phase 2 kickoff handoff (~/.cc-rules/memory/handoffs/ 2026-05-25-phase-2-kickoff.md in cc-rules d9da966). - Standing autopilot grant (~/.cc-rules/memory/auto/ standing_autopilot_phase_2.md in cc-rules bf0ed9a). Verified: 514/514 pass via npm test (no regression in 499 existing tests; 15 new Suite 20 tests all green). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix+test+docs: D45 fold-in — CI fail recovery + opus reviewer P1/P2/P3 Fresh-context opus reviewer (PR #21) returned APPROVE_WITH_MINOR with 4 findings; CI Node 24 separately reported 9 Suite 20 failures (all 200-expecting tests). Root cause of CI: Suite 20 setup did not stub CLAUDE_CODE_OAUTH_TOKEN before mock spawn, so anthropic.mjs AUTH_MISSING pre-check fired and tests 502'd. (Local Node 22 had the env from the maintainer's claude install — masked the gap.) CI FIX — Suite 20 OAuth env stub Added ensureSuite20FakeOAuth / restoreSuite20OAuth helpers in makeSuite20Server / teardownSuite20. Matches the existing pattern in Suite 9 line ~2154 (test-fake-oauth-token-for-cache-tests). P1 — Real-streaming path audit fidelity Single-hop streaming success (server.mjs ~L1050, the most common deployed shape) did not populate auditCtx.provider / tried_providers / cache_status. Audit rows for streaming requests carried provider: null. Fixed by stamping these at the top of the streaming branch and amending error_code on the two streaming failure exit paths (streaming_error_after_first_chunk + streaming_error_before_first_chunk). New regression test 20j-stream: streaming request asserts the audit row's provider, cache_status, and tried_providers fields are populated. P2 — Global test tmpdir cleanup process.env.OLP_HOME = mkdtempSync(...) at test-features.mjs module load left /var/folders/.../olp-test-home-* leak per npm test run. Fixed by process.on('exit', () => rmSync(_GLOBAL_TEST_OLP_HOME)). Best-effort; swallows errors so exit handler never throws. P3 — handleModels 401 lacks OLP diagnostic headers handleChatCompletions 401 passes olpErrorHeaders({ startMs }); handleModels 401 did not. Aligned. DEFERRED — P2 tried_providers semantics on 403 Reviewer noted that key_no_provider_access 403 stamps original chain in tried_providers, but the field name implies hops actually dispatched. Either ADR § 8 amendment or D46+ semantic fix. Marked in CHANGELOG; not in this fold-in scope. Test count: 514 → 515 (+1 streaming-audit regression test 20j-stream; 14 existing Suite 20 tests still pass). Verified locally via npm test. CI Node 24 recovery via the OAuth env stub. Authority: PR #21 fresh-context opus reviewer findings; CI Node 24 run 26382758946 failure logs; CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4b9916341b |
feat+test+docs: D44 — lib/keys.mjs core landed (multi-key auth, no server wire-up yet) (#20)
* feat+test+docs: D44 — lib/keys.mjs core landed (multi-key auth, no server wire-up yet)
First Phase 2 implementation D-day. Lands the lib/keys.mjs module per
ADR 0007 §§ 5 / 6.1 / 6.3 / 6.3.5 / 6.4 / 9.4. Identity / lifecycle
layer for OLP API keys is now in-tree; server.mjs integration is
scheduled D45 (until then, requests still use the hardcoded
'__anonymous__' cache namespace — no behavioural change at v0.1.1 / D44).
NEW FILE lib/keys.mjs (~437 lines, public API):
- createKey({ name, owner_tier, providers_enabled, notes, olpHome })
Generates opaque 'olp_<32-byte base64url>' token (47-char total),
SHA-256 hashes for manifest storage, atomically writes
keys/<id>/manifest.json (file 0600, dir 0700). Returns
{ id, plaintext_token, manifest } — plaintext printed once, never
persisted.
- validateKey(plaintext, { allowAnonymous, olpHome })
Three-tier resolution per § 5 / § 7 / § 9.4: env override
(OLP_OWNER_TOKEN -> __env_owner__) -> anonymous (only when
allowAnonymous: true, returns __anonymous__) -> filesystem
manifest lookup (constant-time hash compare via timingSafeEqual).
Revoked manifests return null (caller produces 401). Per § 6.3.5:
MUST hit manifest every request; no in-process validation cache.
- revokeKey({ id, olpHome })
Idempotent; sets revoked_at via atomic write inside per-key lock.
- listKeys({ olpHome })
Returns manifest objects with token_hash redacted.
- touchLastUsed(id, { olpHome })
Async best-effort lazy update per § 6.3 revoke-dominates-touch:
re-reads latest manifest inside per-key lock, NO-OPs if revoked_at
is non-null, otherwise merges last_used_at preserving all other
fields. Failure logs warn and never throws.
Plus internal helpers: hashToken (SHA-256 hex), generateToken,
generateKeyId, validateManifest (§ 4 schema validation), readManifest,
writeManifestAtomic (tmpfile + fsync + rename + chmod), _withKeyLock
(§ 6.4 in-process per-key write-lock chain), _safeHexCompare
(timing-safe).
Test-only hooks: __setTouchInterleaveHook (inject deterministic pause
for race tests), __resetWriteLocks (test cleanup).
NOT IN D44 (split per ADR §§ 6.2 / 9.1 separation):
- audit ndjson append (§ 6.2) — request-layer concern; D45 server glue
- keygen CLI bootstrap surface (§ 9.1) — D45+ separate command entry
- server.mjs integration replacing hardcoded '__anonymous__' at
server.mjs:502, :531 — D45
- owner-vs-guest gating for /health (server.mjs:392) + X-OLP-Fallback-
Detail (server.mjs:1072, :1101) — D46
TEST COUNT: 468 -> 496 (+28 tests in new Suite 19):
- 19a-d: token generation (§ 5)
- 19e-j: manifest write+read + chmod 0600/0700 + schema validation
(§ 4, § 6.1)
- 19k-p: validateKey (filesystem / wrong / missing / anonymous /
revoked / env override) (§ 5, § 6.3.5, § 9.4)
- 19q-r: revokeKey idempotency + non-existent id
- 19s-t: listKeys empty + redaction
- 19u-x: touchLastUsed updates + NO-OP on revoked + NO-OP on
anonymous/env identities + best-effort failure
- 19y-1 to 19y-4: ACCEPTANCE CRITERION #7 — concurrent revoke + touch
race tests:
19y-1 revoke -> touch (revoked_at survives)
19y-2 touch -> revoke (revoked_at + last_used_at both present)
19y-3 interleaved external-revoke via __setTouchInterleaveHook
(deterministically reproduces the § 6.3 race the
maintainer's D43-B text review caught — confirms our impl
observes the revoke and NO-OPs)
19y-4 30-iteration concurrent Promise.all stress
DOCS UPDATED IN THIS COMMIT:
- AGENTS.md: lib/keys.mjs marker 📋 -> 🟡 'core landed at D44';
Implementation-status-note + shipped-set updated.
- README.md: Implementation Status row + Known limitations
'Multi-key auth' note updated to 'core landed, server integration
pending D45'.
- CHANGELOG.md: D44 entry under Unreleased per release_kit overlay
phase_rolling_mode discipline.
AUTHORITY:
- ADR 0007 (multi-key auth) — Decision: Option 2 filesystem manifest +
opaque token; §§ 5/6.1/6.3/6.3.5/6.4/9.4 implementation contracts;
§ 10 acceptance criteria #6/#7 partially covered by D44 tests
(#7 fully covered; #6 partially covered — full coverage requires
D45+ server integration).
- CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased.
- Phase 2 kickoff handoff:
~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md
(cc-rules d9da966).
- CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required.
Verified: 496/496 pass via npm test before commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat+test+docs: D44 fold-in — opus reviewer findings (2 P2 correctness + 2 P3 polish)
Fresh-context opus reviewer (PR #20) returned APPROVE_WITH_MINOR with 4
findings — 2 P2 real correctness gaps + 2 P3 polish. All accepted; the 2
P2 fixes ship with new regression tests.
P2 #1 — lib/keys.mjs _withKeyLock lock-map cleanup
Prior version stored `prev.then(() => next)` as the Map tail, but the
cleanup-identity check `_writeLocks.get(id) === next` could never match
the derived promise. Result: Map entries leaked one-per-unique-key-id
forever. Bounded impact at family scale (~5–10 entries) but a real
correctness bug uncovered by reviewer empirical reproduction
("CLEANUP SKIPPED every call").
Fix: store `next` directly as the Map tail. The chain still works
because new callers chain off `_writeLocks.get(id)` (the prior caller's
`next`); compare-and-delete by identity correctly cleans up when the
current caller is the last in queue.
Regression tests:
- 19x-extra: after 5 sequential touchLastUsed calls, __writeLockSize()
must be 0.
- 19x-extra-2: after 9 concurrent touch calls across 3 keys (3 per
key), __writeLockSize() must drain to 0.
P2 #2 — lib/keys.mjs validateKey non-string defensive coding
Prior version threw TypeError when called with a non-string truthy
plaintext (validateKey(42), validateKey({}), etc.), reaching
hashToken(<non-string>) which calls createHash().update(<non-string>)
which throws. Q2 (defensive-coding acceptance criterion) promised
"bad inputs return null."
Fix: top-of-function guard
`if (plaintextToken != null && typeof plaintextToken !== 'string') return null;`
Falls through to null path for non-string truthy; preserves existing
null / undefined / '' handling.
Regression test 19m-extra: validateKey(42), validateKey({}),
validateKey([]), validateKey({ token: 'olp_xxx' }), and the same with
allowAnonymous: true — all must return null without throwing.
P3 #3 — 19y-3 test scope comment clarification
Reviewer noted that 19y-3 simulates external revoke landing BEFORE
touch's read (not BETWEEN touch's read and write — currently
unreachable due to synchronous read→write in touchLastUsed). Added
explanatory comment documenting:
- The scenario this test does cover (pre-read external revoke).
- The scenario this test does NOT cover (between-read-and-write).
- Why scenario 3 is unreachable in the current impl (no await between
readManifest and writeManifestAtomic).
- The trigger for adding a post-read hook (any future refactor that
introduces an await between read and write).
P3 #4 — CHANGELOG line-count corrections
D44 entry said ~330 lines (initial estimate); actual is 462 lines
after fold-in. Test count claim updated from "+28 tests" to
"+31 tests" (28 initial + 3 fold-in regression).
New module-level export: __writeLockSize (test-only) — reports current
size of in-process write-lock Map for the regression tests above. Not
intended for production callers.
Test count: 496 → 499 (+3 fold-in regression tests; +31 total from
D44 inclusive of initial Suite 19). Verified locally via npm test.
Authority: PR #20 fresh-context opus reviewer findings; CLAUDE.md
release_kit overlay phase_rolling_mode — under Unreleased.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: D44 fold-in #2 — CHANGELOG line-count consistency (trivial)
Delta opus reviewer flagged internal CHANGELOG inconsistency: header
bullet correctly stated `~462 lines` but the P3 #4 self-description
bullet still said the prior fold-in corrected to `~445 lines`. Both now
agree on 462 (matches `wc -l lib/keys.mjs`).
No code change. Test count: 499 / 499 pass (unchanged).
Authority: PR #20 delta opus reviewer trivial inconsistency note.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
04f797f917 |
feat+docs+test: D40 — X-OLP-Fallback-Detail header (issue #7)
ADR 0004 § Decision § Chain advancement step 4 promised a per-hop
failure detail debug header `X-OLP-Fallback-Detail`. From D9 through
D39 the engine logged per-hop events (D28 added correlation fields)
but never surfaced the failure trail on the response. D40 fulfills
the promise via Option A — ungated v0.1 emission. Phase 2 will
re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands.
**Per-hop tuple collection** (lib/fallback/engine.mjs)
executeWithFallback now collects `fallbackDetail` — an array of per-hop
tuples — and returns it on EVERY return shape (success / client-error /
AUTH_MISSING / non-trigger / chain-exhausted). Soft-trigger skipped
hops are also recorded with `trigger_type: 'soft'` (currently dead-
code-by-config per ADR 0004 Amendment 2; shape forward-compatible).
Tuple shape (reuses D28 log-event field shapes so logs and the header
pivot on the same keys):
```
{
hop: <0-indexed hop number>,
provider: <provider name>,
model: <model name from IR>,
code: <ProviderError code, engine-synthetic SOFT_TRIGGER, or 'UNKNOWN'>,
error_message: <truncated to 200 chars with ellipsis on overflow>,
trigger_type: 'hard' | 'soft' | 'client_error' | 'auth_missing' | 'unclassified'
}
```
**Header serialiser** (server.mjs)
- `FALLBACK_DETAIL_BYTE_CAP = 4096` (UTF-8 bytes).
- `serializeFallbackDetailHeader(fallbackDetail)` exported. Returns
`null` for empty/null/undefined → caller omits the header.
- `jsonStringifyAscii` escapes every non-ASCII code point as `\uXXXX`
to satisfy RFC 7230 §3.2.6 field-vchar (Node's HTTP header validator
rejects multi-byte UTF-8). The D38 CONCURRENCY_LIMIT synthesised
message contains a U+2014 em dash — without this escape, every
CONCURRENCY_LIMIT response crashed at writeHead with
"Invalid character in header content". The regression test pins
this exact string.
- 4KB cap algorithm: builds candidates as `[...slice(0, kept), sentinel]`
and measures the FULL serialised length (including sentinel) before
comparing against the cap, so the result is guaranteed under cap.
Tail tuples dropped one at a time. Sentinel form:
`{ truncated: true, omitted_hops: N }`.
**Header emission** (server.mjs)
- `withFallbackDetailHeader(base, fallbackDetail)` wraps the base
header object; emits the header only when serialiser returns
non-null.
- Emitted on chain-exhausted, non-trigger-error, client-error,
AUTH_MISSING, and success-with-prior-failure paths.
- ABSENT on clean primary success — verified by a dedicated HTTP
integration test.
**Tests** (test-features.mjs): 452 → 468 (+16):
- Engine-level tuple shape: 2-hop/exhausted, 2-hop/success-with-prior-
failure, 1-hop/success (empty array), 1-hop/fail, non-ProviderError-
yields-UNKNOWN, 500-char-message → 200-char-with-ellipsis, client
error → 1 tuple + client_error trigger type
- Serialiser: empty/null → null, small array round-trip, >4KB cap
with `{truncated:true,omitted_hops:N}` sentinel, RFC 7230 newline/CR
escaping, non-ASCII escaping (em dash regression guard for the D38
CONCURRENCY_LIMIT synthesised message)
- HTTP integration: clean-1-hop-success (header absent), 2-hop-
exhausted (2 tuples on the wire), 2-hop-success-with-prior-failure
(1 tuple on the wire)
Pre-commit fold-in (per evidence-first checkpoint #4):
- **Reviewer Suggestion #2**: `jsonStringifyAscii` regex character
class `[U+0080-U+FFFF]` contains an invisible U+0080 boundary marker
that editors render as nothing, making the line easy to misread as
the empty class `[-...]`. Folded in a 5-line comment block above
the function body explaining the literal byte range and citing
RFC 7230 §3.2.6.
Two reviewer suggestions not folded:
- **Suggestion #1**: AUTH_MISSING tuple path lacks a dedicated D40
test. Code is structurally correct (tuple pushed before early-
return); low priority. Future polish.
- **Suggestion #3**: defensive `err.code != null` guard. Extremely
low priority — PROVIDER_ERROR_CODES is a closed enum with no
falsy values.
**ADR amendments** (docs/adr/0004-fallback-engine.md)
- Amendment 5 added at top of amendments stack — full tuple schema,
cap behavior, RFC 7230 hygiene, ungated v0.1 rationale, Phase 2
follow-up.
- § Chain advancement step 4 updated to remove TBD / not-yet-
implemented qualifiers and cross-reference Amendment 5.
- § Observability headers section gains the X-OLP-Fallback-Detail
schema as IMPLEMENTED at v0.1.
**CHANGELOG.md** — D40 sub-entry appended under existing D38/D39
entries in Unreleased section. No package.json bump per
phase_rolling_mode.
Authority:
- ADR 0004 § Decision § Chain advancement step 4 — D40 fulfils
the promise
- ADR 0004 Amendment 5 (this commit) — implementation contract
- ADR 0004 § Observability headers — updated to IMPLEMENTED state
- D18 (5 standard X-OLP-* headers) — D40 builds on this convention
- D28 (per-hop structured log fields) — D40 reuses the field shapes
- GitHub issue #7 — closed by this commit
- CC 开发铁律 v1.6 § 10.x — fresh-context opus reviewer independent
- CLAUDE.md release_kit_overlay phase_rolling_mode — no version bump
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE. Critical depth checks:
- Tuple pushed once per hop, BEFORE client-error / AUTH_MISSING
early-return branches — both paths include the failing tuple
- fallbackDetail returned on all 5 return paths (verified line by line)
- Cap algorithm measures FULL serialised length (with sentinel)
before comparing — no overshoot
- RFC 7230 compliance verified end-to-end: imported the function
in Node, confirmed em-dash → —, unescaped form throws
"Invalid character in header content"
- Serialiser handles null / undefined / [] gracefully → header
absent on clean primary success (HTTP test pins this)
- Hygiene: 0 hits for personal markers, home paths, tokens
- 468/468 tests pass in reviewer's independent npm test run
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bdfea6884b |
feat+docs+test: D39 — D16 follow-ups (issue #3, 4 parts)
D16 reviewer (commit `bafa6d1`) left 4 non-blocking suggestions
batched into issue #3 as a tracker. D39 closes all 4.
**Part 1 — CacheStore.delete(keyId, cacheKey) API** (lib/cache/store.mjs)
D16 originally evicted truncated entries via
`cacheStore.set(keyId, hopCacheKey, result, ttlMs=0)` — a TTL=0
tombstone purged lazily on next access. D39 introduces an explicit
delete primitive that removes the entry immediately.
- Synchronous: `delete(keyId, cacheKey) → boolean`. Returns true if
the entry was present and removed, false if absent. Sync (not
async) for the simplest in-memory Map contract — mirrors clear().
Other CacheStore methods are async to leave room for a Phase 2
file-backed adapter; delete being sync was a deliberate choice.
- Namespace cleanup: when the inner Map becomes empty after delete,
the outer Map's per-keyId entry is also removed (memory hygiene;
mirrors the _activeSpawns cleanup pattern from D38).
- Behavior: peek/get/getOrCompute see no trace after delete; the
subsequent getOrCompute triggers a fresh compute.
**Part 2 — `cache_evicted_truncated` observability log** (server.mjs)
After the D16 eviction call in collectAllChunks, emit:
```js
logEvent('info', 'cache_evicted_truncated', {
provider, model, cache_eviction_hit,
});
```
Dashboard sees salvage frequency per (provider, model). The
cache_eviction_hit boolean distinguishes "we evicted an entry" (true)
from "we tried to evict but it was already gone" (false — race with
concurrent eviction or TTL purge), preserving observability accuracy
under concurrency.
**Part 3 — Sticky-cache regression test** (test-features.mjs)
Defense-in-depth around the eviction path. Two consecutive identical
buffered requests; the first triggers SPAWN_FAILED after partial
chunks → Case B salvage returns `{ chunks..., finish_reason: 'length' }`
to the client and evicts via delete(). The second identical request
must trigger a fresh spawn (NOT serve the salvaged response from a
stale cache entry).
Asserts on BOTH invariants for defense-in-depth:
- Mock provider spawn count == 2 across the 2 identical requests
- Second request's X-OLP-Cache header is 'miss'
If eviction silently breaks in a future regression, both assertions
catch it independently.
**Part 4 — SPAWN_TIMEOUT salvage parity: DOCUMENT ASYMMETRY**
(docs/adr/0004-fallback-engine.md)
Maintainer decision: SPAWN_TIMEOUT is NOT salvaged. Document the
asymmetry rather than implementing parity. ADR 0004 Amendment 1 is
extended with a new section "Why SPAWN_TIMEOUT is excluded from
salvage" with 4-point rationale:
1. SPAWN_FAILED indicates the provider crashed mid-stream — there's
nothing more coming; partial > nothing. Next-hop spawn has no
advantage (same input may crash same way).
2. SPAWN_TIMEOUT indicates the provider was slow (deadline exceeded
per `hints.maxSpawnTimeMs`). Fallback advancement to a DIFFERENT
provider is more likely to give a complete response than salvaging
a partial from a slow provider.
3. The "user paid for partial content" framing from D16 captures only
SPAWN_FAILED. For SPAWN_TIMEOUT the user actually paid for "result
within time T" — partial-at-time-T is not what was paid for;
"full result soon after T" via fallback is closer.
4. Code-level inspection confirms the asymmetry: collectAllChunks
catch matches ONLY `code === 'SPAWN_FAILED'` (server.mjs:563).
SPAWN_TIMEOUT propagates via re-throw and hits evaluateHardTriggers.
v1.x re-evaluation trigger: if real usage shows users want partial-
on-timeout for very long deadlines, add a v1.x design ADR.
Stale comment fix: `lib/providers/anthropic.mjs:369` previously said
"SPAWN_TIMEOUT salvage parity is tracked in issue #3". D39 closes
that issue, so the comment is updated to point at ADR 0004 Amendment 1.
**Tests** (test-features.mjs): 447 → 452 (+5):
- 3 unit tests on CacheStore.delete (Suite 9): present-key true, absent-key
false, namespace cleanup at empty
- 1 D16 integration test: cache_evicted_truncated log fires with
correct fields during salvage
- 1 sticky-cache regression: spawn count 2 across 2 identical requests,
X-OLP-Cache miss on second
Pre-commit fold-ins (per evidence-first checkpoint #4):
- **Reviewer Suggestion #1**: cacheStore.delete() return value was
discarded at the call site → log inflated salvage metric under
concurrent-eviction race. Folded: captured `evicted` boolean and
added to log payload as `cache_eviction_hit`.
- **Reviewer Suggestion #2**: anthropic.mjs:369 stale comment
pointing at now-closed issue #3. Folded: rewrote to point at
ADR 0004 Amendment 1 § "Why SPAWN_TIMEOUT is excluded from
salvage".
- **Reviewer Suggestion #3**: ADR 0004 attribution ambiguity —
parenthetical "(per Amendment 3 — SPAWN_TIMEOUT is one of the 4
live hard-trigger codes alongside SPAWN_FAILED, CLI_NOT_FOUND, and
CONCURRENCY_LIMIT from Amendment 4)" could mis-parse as Amendment 3
covering all four. Folded: split to
"(per Amendment 3: SPAWN_FAILED, CLI_NOT_FOUND, SPAWN_TIMEOUT;
per Amendment 4: CONCURRENCY_LIMIT)".
**CHANGELOG**: D39 sub-entry appended under the existing D38 entry
in Unreleased section. No package.json bump (phase_rolling_mode).
Authority:
- ADR 0005 § Cache layer — CacheStore API extension (Part 1)
- ADR 0004 Amendment 1 update — SPAWN_TIMEOUT asymmetry rationale (Part 4)
- GitHub issue #3 — closed by this commit
- D16 commit
|
||
|
|
994568a8fb |
feat+ci: D38 — maxConcurrent runtime enforcement (issue #1)
ADR 0002 Amendment 1 declared `hints.maxConcurrent` declarative-only at
v0.1 — type-validated at startup but with no runtime enforcement.
D38 wires the runtime enforcement via a per-provider in-flight spawn
counter with immediate-advancement on saturation.
Design choice: **immediate-advancement via fallback engine** (queue +
timeout DEFERRED). When a provider is at its maxConcurrent limit, the
spawn call synchronously fails with a new `CONCURRENCY_LIMIT` error
code; the fallback engine treats this as a hard trigger and advances
to the next chain hop. If the entire chain is saturated, the user
sees a chain-exhausted error (existing path).
Rationale for immediate-advancement over queue+timeout:
1. The fallback chain exists precisely for this kind of overflow —
adding a queue layer would duplicate the advancement semantics.
2. Queue + timeout adds new config surface (timeout duration, queue
depth bounds, queue eviction policy) that isn't needed at the
personal/family scale OLP serves.
3. Head-of-line blocking risk: a long-running spawn would stall
queued requests behind it even though other providers in the chain
could serve them immediately.
4. Fail-fast latency aligns with the multi-provider proxy philosophy
("spread risk across providers, not within a provider").
Queue + timeout is deferred to a v1.x design ADR if real usage shows
demand. ADR 0002 Amendment 6 and ADR 0004 Amendment 4 capture the
decision explicitly.
Changes (8 files, +<delta>):
**Code**
1. **lib/providers/base.mjs** — add `CONCURRENCY_LIMIT` to
`PROVIDER_ERROR_CODES`. JSDoc clarifies the code is synthesised by
the orchestration layer, not thrown by provider plugins themselves.
2. **lib/providers/index.mjs** — new semaphore primitives:
- `tryAcquireSpawn(providerName, maxConcurrent)` — atomic
check-then-increment. Returns `true` on success, `false` if at
limit. Atomicity rests on the JS single-threaded invariant
(read + write synchronous, NO `await` between them); module-level
comment block warns future maintainers against breaking this.
- `releaseSpawn(providerName)` — decrement; throws on
under-decrement (defensive bug guard for missing acquire / double
release). Map.delete at zero for clean memory footprint.
- `getActiveSpawnCount(providerName)` — returns current count
(0 for unseen providers). For diagnostics + tests.
- `DEFAULT_MAX_CONCURRENT_SPAWNS = 4` — defense-in-depth fallback
matching the v0.1 plugin defaults (anthropic/codex/mistral all
declare hints.maxConcurrent: 4). Also coerces non-integer / NaN
/ negative inputs to the default.
- `__resetSpawnCounters()` — internal test seam.
3. **lib/fallback/engine.mjs** — `CONCURRENCY_LIMIT: true` added to
`HARD_TRIGGER_CODES`. `classifyTrigger` and `evaluateHardTriggers`
both pick it up via the same lookup. v0.1 live hard-trigger codes
are now 5 (was 4 post-D34): SPAWN_FAILED, CLI_NOT_FOUND,
AUTH_MISSING:false, SPAWN_TIMEOUT, CONCURRENCY_LIMIT.
4. **server.mjs** — gate the spawn call in handleChatCompletions at
BOTH spawn call sites:
- **Buffered path** (executeHopFn → collectAllChunks): acquire
before provider.spawn; on failure synthesise
`ProviderError(CONCURRENCY_LIMIT)` with providerName /
maxConcurrent / activeSpawns diagnostic fields and throw —
fallback engine catches and advances. On success, outer try/
finally wraps the inner D16 truncation-salvage try/catch so
releaseSpawn fires on EVERY exit path (return, D16 salvage
return, re-throw).
- **Streaming path** (single-hop real-SSE branch): acquire BEFORE
the streaming branch entry. If acquire fails, branch is skipped
and request falls through to buffered path (whose own gate
surfaces chain-exhausted for single-hop chains). If acquire
succeeds, existing streaming try/catch gains
`finally { releaseSpawn(streamProvider) }` — slot releases on
stop-chunk completion, generator exhaustion, abort, or any
exception path. `releaseSpawn` fires at END of stream
consumption, not when spawn() returns.
**Tests** (test-features.mjs): 431 → 447 (+16):
Suite 18 — D38 — maxConcurrent runtime enforcement:
- 18a: PROVIDER_ERROR_CODES.CONCURRENCY_LIMIT exists
- 18b: evaluateHardTriggers true for CONCURRENCY_LIMIT
- 18c: AUTH_MISSING regression guard (D38 did NOT flip it to hard)
- 18d.1-18d.4: semaphore unit (increment / saturate-no-increment /
release / map-delete-at-zero)
- 18e: tryAcquireSpawn returns false without side-effect when at limit
- 18f: releaseSpawn throws on under-decrement
- 18g.1-18g.2: DEFAULT_MAX_CONCURRENT_SPAWNS applied for invalid
inputs (undefined / NaN / negative / non-integer)
- 18h: __resetSpawnCounters clears state
- 18i: HTTP integration — 5 concurrent requests to single-hop
chain w/ maxConcurrent=2 → peak in-flight exactly 2, 2 succeed,
3 fail
- 18j: counter releases after buffered request (sequential test)
- 18k: 2-hop chain — saturated primary advances to fallback
- 18l: streaming counter releases at END of stream (not at spawn)
**ADR amendments**
5. **docs/adr/0002-plugin-architecture.md** — Amendment 6 added.
Removes "Declarative hint only at v0.1" caveat from the
`maxConcurrent` description in the Provider contract section.
Adds implementation reference + design-choice rationale (4 points).
Lists all exported symbols.
6. **docs/adr/0004-fallback-engine.md** — Amendment 4 added.
CONCURRENCY_LIMIT added to v0.1 hard-trigger taxonomy. Documents
synthesis-vs-plugin-thrown distinction. Documents first-chunk
safety (acquire before any res.write). v1.x re-evaluation triggers
named.
**CHANGELOG**
7. **CHANGELOG.md** — Unreleased sentinel replaced with proper D38
entry. Per CLAUDE.md release_kit phase_rolling_mode, this lands
under Unreleased; promotion to ## v0.1.1 happens at the v0.1.1
release. The D37 phase_rolling_mode gate now correctly fires if
anyone tags v0.1.x with this content present without promotion —
intentional.
Pre-commit fold-ins (per evidence-first checkpoint #4):
- **Reviewer Suggestion #1 (test 18c name mismatch)**: 18c is named
"classifyTrigger returns hard for CONCURRENCY_LIMIT" but body tests
AUTH_MISSING regression. Renamed header comment to
"AUTH_MISSING regression guard" and clarified that
CONCURRENCY_LIMIT classification is covered by 18b.
- **Reviewer Suggestion #3 (activeSpawns diagnostic field)**: server
.mjs:532 set `concurrencyErr.activeSpawns = maxConcurrent` (the
limit). Technically correct (since acquire just failed, live
count == limit) but confuses future readers. Changed to query
`getActiveSpawnCount(hopProvider)` directly. Added import of the
new symbol to the lib/providers/index.mjs import block.
- **Reviewer Suggestion #5 (ADR overstatement)**: ADR 0002 Amendment 6
said getActiveSpawnCount is "exported for /health, diagnostics,
and tests" but /health integration is not wired at D38. Reworded to
"exported for diagnostics and tests" + "/health integration
deferred — when surfaced there will land at providers.status.<name>
.activeSpawns; not wired at D38." Avoids overstating current state.
Two reviewer suggestions not folded:
- Suggestion #2 (streaming test 18l comment about branch entry
conditions) — low priority; the test passes and the branch is
taken (verified by reviewer). Future polish if confusion arises.
- Suggestion #4 (additional test for streaming-saturation → chain-
exhausted) — code path is straightforward and intentional; 18i
covers the buffered-path version directly. Defer.
Authority:
- ADR 0002 Amendment 1 (declarative-only caveat) — superseded by
Amendment 6
- ADR 0002 Amendment 6 (this commit) — runtime enforcement landed
- ADR 0004 Amendment 4 (this commit) — CONCURRENCY_LIMIT added to
v0.1 hard-trigger taxonomy
- GitHub issue #1 — closed by this commit
- CC 开发铁律 v1.6 § 10.x — independent fresh-context reviewer
- CLAUDE.md release_kit_overlay phase_rolling_mode — no version
bump; Unreleased entry written
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE. Critical depth checks:
- Atomicity in tryAcquireSpawn (lines 285-290 read+set with no
intervening await) — confirmed; module-level invariant comment
warns future maintainers
- Release on every exit path: buffered (outer finally wraps inner
try/catch covering D16 salvage return + normal return + re-throw);
streaming (try/finally covers stop-chunk return + loop exhaustion +
catch paths). No double-release path identified.
- Streaming release timing: fires in finally after res.end() at all
exit paths, never before stream consumption completes
- Counter leak: every code path traced — no orphan acquire identified
- 447/447 tests pass in reviewer's independent npm test run
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e96752a528 |
docs+fix+test: D36 — pre-Phase-2 batch #2 (issues #2 #5 #6 #13 #14 #15)
Second batch of pre-Phase-2 cleanup. 6 GitHub issues closed in one cohesive docs/governance commit with 1 small code addition (#2 debug log line in server.mjs) and 1 transcript artifact (#15 new file). Changes (7 files modified, 1 file created, +470 / -13): **Code changes** 1. **#2 — cache_control partial-noop debug log** (server.mjs) ADR 0005 § D2 says: "for non-Anthropic targets, the bypass markers are noop'd (logged once per request at debug level so users can see they were ignored)". Pre-D36 logEvent fired only when an Anthropic hop actually bypassed; non-Anthropic hops with markers present were silently noop'd. New 12-line block in handleChatCompletions right after hasCacheControlMarkers is computed. Fires logEvent('debug', 'cache_control_partial_noop', { chain: [<provider names>], marker_count: <count> }) when: - hasCacheControlMarkers === true AND - chain.some(hop => hop.provider !== 'anthropic') Fires at most once per request (top-level if, not in a loop). No log when no markers, or when every chain hop is Anthropic. The existing cache_bypass debug log inside shouldBypassCacheForHop is untouched. marker_count sums body-side and IR-side extractCacheControlMarkers results. At v0.1 the IR term is structurally 0 (openAIToIR strips cache_control); inline comment marks this as a revisit point for the future ADR 0003 amendment that activates cache_control in the IR whitelist. **Documentation amendments** 2. **#5 — ADR 0002 vibe.mjs → mistral.mjs** (docs/adr/0002-plugin-architecture.md) § Decision filesystem layout: `vibe.mjs` corrected to `mistral.mjs` (file named after provider key per the convention anthropic.mjs/codex.mjs). The vibe.mjs entry was an early-draft naming choice that never landed. Amendment 5 prepended above Amendment 4 documenting the filename correction + explicit convention statement (file named after provider key, not CLI binary) for future contributors. 3. **#6 — mistral.mjs A5 flip + ALIGNMENT.md table update** (lib/providers/mistral.mjs + ALIGNMENT.md) Pre-D36: header A5 (model flag) status was UNPINNED-D-later-verifies but function body lines 376-380 said CONFIRMED-NOT-APPLICABLE (DeepWiki enumeration already confirmed `--model` does not exist on vibe CLI). Header now reflects CONFIRMED-NOT-APPLICABLE with DeepWiki citation (DOCS-4). ALIGNMENT.md Speculative-Candidate table mistral row: A5 removed from UNPINNED list; A4, A6, A7, A8 preserved with parenthetical descriptions intact. No code change to function body (already correct). 4. **#13 — /v1/models alias entries — ALIGNMENT.md + spec-pin governance** (ALIGNMENT.md + docs/openai-spec-pin.md) Round-6 F10 flagged D27 F15's alias entries on /v1/models as borderline Rule 2(b) violation (OpenAI spec does not enumerate aliases as separate entries). Option C selected: keep current behavior, document the controlled deviation. - ALIGNMENT.md: new "Controlled deviations (entry-surface scope)" subsection under "Class-specific Exceptions". Entry 1 documents the /v1/models alias deviation with rationale (D27 F15 onboarding), formal contract reference, field constraints (owned_by matches canonical, created matches canonical, no invented fields), SPOT reference (getAliasMap()), and re- evaluation trigger. - docs/openai-spec-pin.md: new alias-surfacing subsection under GET /v1/models with full 4-field contract table (id/object/ created/owned_by), rationale, sourcing explanation, forward path. - server.mjs handleModels: NO CHANGE — behavior preserved. 5. **#15 — Anthropic v2.1.89 transcript artifact** (docs/provider-audits/anthropic.md NEW; ALIGNMENT.md + lib/providers/anthropic.mjs cross-references) Round-6 F12: ALIGNMENT.md anthropic row pin (v2.1.89, observed at D4) cited the plugin header; plugin header cited the observation date but no transcript. Circular per Rule 1 ("observed behaviour, transcript attached"). New file docs/provider-audits/anthropic.md (single living artifact, not version-specific): - Date of capture: 2026-05-24 - Observed `claude --version`: 2.1.132 (Claude Code) — captured today on the project maintainer's primary workstation - Plugin-pinned version: @anthropic-ai/claude-code v2.1.89 (from D4 implementation pin in ALIGNMENT.md Provider Authority Pins) - Version drift: honestly documented — pin is v2.1.89, live is v2.1.132, drift within tolerance, re-audit triggers named - Sample invocation: `claude -p --output-format text --no-session- persistence --model <model> [--debug]` - Flag-surface table: 5 OLP-consumed flags verbatim from `claude -p --help` (-p / --output-format / --no-session- persistence / --model / --debug) - Citation cross-references back to ALIGNMENT.md + plugin header ALIGNMENT.md anthropic row: appended "transcript artifact: docs/ provider-audits/anthropic.md (captured 2026-05-24)" — closes the circular citation. lib/providers/anthropic.mjs header: 5-line pointer to the artifact with version numbers stated explicitly. **Tests** (test-features.mjs): 424 → 431 (+7): - #2 partial-noop log ×3: - Suite 9f case 1: markers + mixed chain → fires once at level=debug - Suite 9f case 2: no markers → suppressed - Suite 9f case 3: anthropic-only chain → suppressed - #14 cache_control slot determinism ×4: - #14a: markers-present IR produces different key from no-markers IR - #14b: same IR computed twice yields identical key - #14c: two independently-constructed IRs with identical payloads yield same key - #14d: both top-level and content-array-nested markers affect the key All 4 tests call computeCacheKey directly on hand-built IRs (bypassing openAIToIR which strips markers at v0.1). Per ALIGNMENT.md Rule 2 (No Invention), no sortMarkers helper added — the slot is dead-code at v0.1 and shipping a helper without a caller authority would be invention. Test comment documents the forward-activation contract. Pre-commit fold-in (per evidence-first checkpoint #4): - **D36 reviewer flagged marker_count latent double-count risk** (Suggestion #1, non-blocking). The sum at server.mjs:435-437 is safe at v0.1 (IR term structurally 0) but will 2× when a future ADR 0003 amendment activates cache_control in the IR whitelist. Folded in a 4-line comment marking the revisit point. Two other non-blocking reviewer suggestions not folded: - Test count brief-vs-deliverable discrepancy (4 not 3 for #14) is informational — the 4-test variant is strictly better (covers content-array nesting which is a real extractCacheControlMarkers contract path). - Recapture-procedure git-add reminder in anthropic.md is low-priority procedure documentation. Authority: - ADR 0005 § D2 — cache_control partial-noop debug log requirement (#2) - ADR 0002 § Decision filesystem layout (Amendment 5) — plugin file naming convention (#5) - DeepWiki vibe CLI flag enumeration — A5 not applicable (#6) - ALIGNMENT.md Rule 2(b) + docs/openai-spec-pin.md GET /v1/models — alias controlled deviation (#13) - ADR 0005 cache key stability invariant + ADR 0003 forward-compat — cache_control slot determinism contract (#14) - ALIGNMENT.md Rule 5 (observed behaviour, transcript attached) + Provider Authority Pins anthropic row — transcript artifact (#15) - CC 开发铁律 v1.6 § 10.x — independent fresh-context reviewer - CLAUDE.md release_kit_overlay phase_rolling_mode — D36 under "Unreleased" against Phase 2; no version bump Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent of drafter): APPROVE. Critical depth checks: - #2: gate condition fires only on (markers AND non-anthropic-hop); pre-existing cache_bypass log inside shouldBypassCacheForHop untouched - #5: lib/providers/ directory verified — mistral.mjs exists, vibe.mjs does not exist - #6: mistral.mjs spawn-site body comment (lines 376-380) already CONFIRMED-NOT-APPLICABLE pre-D36 and unchanged in this diff - #13: server.mjs handleModels unchanged (verified via grep) - #14: all 4 tests pass against current code; no sortMarkers helper shipped (Rule 2 No Invention honored) - #15: live `claude --version` independently run by reviewer → matches artifact (2.1.132); all 5 OLP-consumed flags independently verified present in `claude -p --help` - Hygiene: 0 hits for personal markers, home paths, OAuth tokens, internal IPs across all 8 files - 431/431 tests pass in reviewer's independent npm test run Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
2600185edb |
fix+test: D35 — pre-Phase-2 batch #1 (issues #4 #9 #10 #11 #12)
First batch of pre-Phase-2 cleanup work. 5 GitHub issues closed in
one cohesive commit covering streaming-path correctness, IR
validator hardening, and CI path-trigger hygiene.
Changes (4 files, +302 / -5):
**Code fixes**
1. **#9 — Streaming empty-then-clean-exit headers** (server.mjs)
Pre-D35: when a provider's streaming spawn finished cleanly with
zero chunks (e.g. spec-degenerate stop with no content), the
response went out the SSE_DONE / res.end path without ever calling
writeHead. Result: client saw stream open + close with no headers,
no status code path applied. Now: zero-chunk branch guards
`!res.headersSent` and emits Content-Type + Cache-Control +
Connection + X-Accel-Buffering + all 5 X-OLP-* headers via
olpHeaders (provider attempted, cache miss) before writing the
terminator. Zero-chunk path correctly does NOT cache (cache write
remains gated on irChunk.type === 'stop').
2. **#10 — Streaming post-first-chunk error truncation marker**
(server.mjs, two sibling sites)
Pre-D35: if a provider yielded an error AFTER first content chunk
was emitted, the SSE stream was abandoned with raw socket close.
Client SDKs that wait for finish_reason hung. Now:
- Catch-block firstChunkEmitted=true path: emit synthetic
`{type:'stop', finish_reason:'length'}` via irChunkToOpenAISSE,
write SSE_DONE, end. Per ADR 0004 § Fallback safety: post-first-
chunk truncation surfaces as `length` finish, not a hang.
- Sibling fix in error-chunk path (provider yields `type:'error'`
chunk AFTER first content chunk): same recovery (marker + DONE +
end). Scope-creep acknowledged but identical semantic; clean to
fix together. Comments cross-reference D26 F19 and D35 #10.
3. **#11 — validateIRRequest irVersion strict check** (lib/ir/types.mjs)
ADR 0003 IR contract pins irVersion to '1.0'. Validator pre-D35
accepted ANY value (including no value, undefined, '2.0',
numeric 1.0). Now: `obj.irVersion !== undefined && obj.irVersion
!== '1.0'` → rejection. Strict string match. `undefined` still
accepted (pre-D35 IRs without the field remain valid — back-compat
with sites that haven't yet been migrated to emit it). Error
message uses JSON.stringify for safe rendering.
4. **#12 — alignment.yml scripts/** trigger removal**
(.github/workflows/alignment.yml)
Pre-D35 push.paths and pull_request.paths listed scripts/**. The
scripts/ directory does not currently exist (per AGENTS.md note:
scripts/migrate-from-ocp.mjs is planned for Phase 7). A path
filter referencing a non-existent directory has no effect on
trigger evaluation BUT misleads readers about the workflow's
intent. Removed from both push.paths and pull_request.paths. When
scripts/ lands in Phase 7, the trigger should be re-added at the
same time (see release_kit_overlay.bootstrap_quirk_policy).
**Verification — #4 (uniform X-OLP-Latency-Ms across error paths)**
#4 was found to already be correct via D32. Re-audit of all 7
in-handler sendError sites in handleChatCompletions confirmed all
attach a 5-header set via olpHeaders or olpErrorHeaders:
- 360-361 (415 wrong Content-Type) → olpErrorHeaders
- 368-369 (400 bad JSON) → olpErrorHeaders
- 378-379 (400 BadRequestError IR translation) → olpErrorHeaders
- 402-407 (503 no chain) → olpErrorHeaders
- 617-618 (503 provider disappeared) → olpErrorHeaders
- 760-761 (502 streaming pre-first-chunk error) → olpHeaders
- 778-779 (500 fallback engine error) → olpErrorHeaders
The 404 (line 922) and outer 500 (line 926) are router-level paths
without startMs context and correctly lack OLP headers. D35 adds
the #4-audit regression test pinning the 5-header invariant on the
503 no-provider response so future drift is caught immediately.
**Tests** (test-features.mjs): 416 → 424 (+8):
- #4-audit ×1 (5-header invariant on 503 no-provider sendError)
- #9 ×1 (zero-chunk streaming → 200 + Content-Type=text/event-stream
+ 5 X-OLP-* headers + [DONE])
- #10 ×1 (catch-throw after first chunk → marker + length finish + DONE)
- #10b ×1 (provider error chunk after first chunk → same recovery)
- #11a ×1 (irVersion undefined accepted)
- #11b ×1 (irVersion '1.0' accepted)
- #11c ×1 (irVersion '2.0' rejected)
- #11d ×1 (irVersion numeric 1.0 rejected)
Pre-commit fold-in (per evidence-first checkpoint #4):
- **D35 reviewer flagged JSDoc/validator drift on irVersion**
(Suggestion #1, non-blocking). The @property typedef at
lib/ir/types.mjs:43 said `{string} irVersion - always IR_VERSION`
but the validator at lines 185-186 accepts `undefined`. Future
reader who scans the @property alone sees contradiction without
the rationale comment 140 lines below. Folded: typedef marked
`[irVersion]` (optional) and description updated to "optional;
when present must equal IR_VERSION ('1.0'). Pre-D35 IRs lack
this field and remain valid."
Two other non-blocking reviewer suggestions not folded (out of
scope for D35; tracked as future polish):
- Distinct event names for the two streaming_error_after_first_chunk
log sites (provider-emitted string vs JS exception message).
- Phase 7 TODO: re-add scripts/** trigger to alignment.yml when
scripts/migrate-from-ocp.mjs lands.
Authority:
- ADR 0004 § Fallback safety — post-first-chunk truncation surfaces
as `length` finish (#10 + #10b)
- ADR 0003 § IR contract — irVersion pinned to '1.0' (#11)
- AGENTS.md § Implementation status — scripts/ planned for Phase 7
(#12)
- CLAUDE.md release_kit_overlay phase_rolling_mode — D35 lands
under "Unreleased" against Phase 2; no version bump
- CC 开发铁律 v1.6 § 10.x — independent fresh-context reviewer
required for code change
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE_WITH_MINOR. Critical depth checks:
- Verified all 7 sendError sites in handleChatCompletions attach
5-header set (cited line numbers reconciled with current state)
- Verified writeHead block guarded by !res.headersSent; correctly
placed AFTER optional truncation marker, BEFORE SSE_DONE
- Verified irVersion validator strict-equality semantics across all
4 cases (undefined / '1.0' / '2.0' / numeric 1.0)
- Verified scripts/** removed from both push and pull_request paths
- Verified hygiene: 0 hits for personal markers, home paths, OAuth
tokens, internal IPs
- 424/424 tests pass independently in reviewer's run
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
60570ef074 |
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>
|
||
|
|
f784fdb947 |
fix+docs: D33 — round-5 cleanup batch (F1/F3/F5/F8/F9/F10/F11/F12)
cold-audit catch from 2026-05-24 (round 5)
Round-5 cold-audit cleanup batch. 8 items + 1 release-discipline
reconciliation. Largest batch by line count (582+/39-) but every item
is small-and-focused. 3 P2 items (F1/F3/F5 of which F3 + F1 are real
correctness/observability fixes; F5 backfills /health to spec).
Changes (10 files, +583/-39):
**P2 fixes**
1. **F1 — ALIGNMENT.md mistral authority pin self-contradicted plugin**
(ALIGNMENT.md): row cited `vibe --prompt --output json` but mistral.mjs
uses `--output streaming` (the plugin header at lines 360-369 even
justifies WHY: `--output json` emits single blob, breaks NDJSON
line-buffered parser). Constitution self-contradicting itself —
missed across 4 prior rounds. Pin updated to `--output streaming`
with DOCS-1 reference.
2. **F3 — Deterministic function_call synth ID**
(lib/ir/openai-to-ir.mjs): deprecated `function_call` translation
produced `id: \`fc-${Date.now()}\`` → ID flows into normalized
tool_calls → cache key SHA-256. Two identical requests separated
by ≥1ms → different cache keys → cache always misses for
`function_call` request shape. Violates ADR 0005 invariant
"same inputs → same key, no random, no timestamp."
Fixed: id is now `fc-<16-hex>` from SHA-256 of `${name}\0${arguments}`.
NUL separator prevents the (name='ab',args='c') vs (name='a',args='bc')
collision. 2^64 collision resistance is more than sufficient for
tool_call ID disambiguation (per-request semantic key, not crypto
primitive).
**P3 fixes**
3. **F5 — /health invokes per-plugin healthCheck()** (server.mjs +
docs/openai-spec-pin.md): ADR 0002 says "healthCheck — startup AND
/health endpoint use this." Pre-D33 /health returned only
{enabled, available} counts. Now async, iterates loadedProviders,
awaits each plugin's healthCheck() in try/catch. Returns
`providers: {enabled, available, status: {<name>: {ok, latencyMs?, error?}}}`.
4. **F8 — X-OLP-Cache reports fallback-hop cache hits** (server.mjs):
pre-D33 cacheStatus computed from `preCheckHit && fallbackHops === 0`
— only counted primary-hop cache hits. When fallback fires + the
fallback hop's getOrCompute returns from cache, header reported
`miss` despite no spawn happening.
Fixed: peek BEFORE getOrCompute inside executeHopFn, set
`lastHopWasCached` closure variable on every hop (last-write-wins
= serving hop's state). cacheStatus combines
`lastHopWasCached || (preCheckHit && fallbackHops === 0)`.
F8 chose option (b) peek-then-getOrCompute over option (a)
getOrCompute API change because option (a) would break ~15 test
callsites for marginal benefit. Accepted race window same as
existing preCheckHit pattern.
5. **F9 — validateProvider hints error message updated** (lib/providers/
base.mjs): pre-D33 message listed cacheable as missing and
maxSpawnTimeMs as required. Now: `'hints must be an object with
{ requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional
{ maxSpawnTimeMs, cacheable }'`.
6. **F10 — Dead cache-write branch removed** (server.mjs): the
`if (hasStopChunk)` check in the streaming stop-less exhaustion
branch was unreachable (the stop-chunk completion path returns
earlier inside the for-await loop). Removed the dead code + added
a comment documenting the invariant.
**Governance/policy**
7. **F11 — Phase rolling mode policy formalized** (CLAUDE.md +
CHANGELOG.md): 22+ D-day commits accumulated under "Unreleased"
without per-D version bumps — Iron Rule 5 (release-kit bump-before-
push) appeared to be silently violated. Reality: per-D bumps would
produce 30+ noise tags during Phase 1. F11 formalizes the policy:
intra-Phase D-day commits accumulate under Unreleased; bump+tag
fires explicitly at Phase close (maintainer-triggered, not
automated). CLAUDE.md release_kit overlay gains `phase_rolling_mode`
block documenting the exception with self-pointer ("if Rule 5
appears silently violated, check this section first"). CHANGELOG
"Unreleased" gets a notice at top.
**No version bump, no git tag in D33** — policy formalization only.
8. **F12 — /v1/models created is stable per-model timestamp**
(models-registry.json + lib/providers/index.mjs + server.mjs +
docs/openai-spec-pin.md): pre-D33 used Math.floor(Date.now()/1000)
per request — violates OpenAI spec which treats `created` as
per-model attribute. Clients caching models by created would see
spurious updates on every poll.
Fixed: models-registry.json gains `bootstrapCreated: 1778630400`
top-level constant + per-model `created` fields where known
(anthropic claude-{opus,sonnet,haiku} with estimated release dates;
devstral models from "25-12" suffix; codex models pinned to
bootstrap pending verified release dates). handleModels uses
`getModelCreated(modelId)` helper from lib/providers/index.mjs.
Aliases share canonical's timestamp.
**Tests** (test-features.mjs): 401 → 414 (+13):
- F3 ×3 (same input → same id → same cache key; different name → different)
- F5 ×4 (empty/single/multi/throwing-plugin /health shapes)
- F8 ×1 (2-hop primary-fail + secondary-cache-hit → X-OLP-Cache: hit)
- F12 ×5 (stability/fallback/alias-equals-canonical)
Pre-commit fold-in (per evidence-first checkpoint #4):
- **D33 reviewer flagged F3 empty-args asymmetry** (Concern #1): hash
input used `?? ''` (empty stays) but emitted IR field used
`|| '{}'` (empty becomes '{}'). Consequence: `arguments: ''` and
`arguments: '{}'` emit identical IR but compute different ids →
different cache keys for semantically-identical requests. The exact
cache-stability bug F3 was supposed to fix.
Folded in: canonicalize empty-args to '{}' BEFORE hashing. Hash
input now matches IR emission exactly. Same line change resolves
the asymmetry.
Authority:
- ALIGNMENT.md self-amendment (F1 pin correction)
- ADR 0005 invariant "same inputs → same key, no random, no timestamp"
(F3 restoration)
- ADR 0002 § Provider contract "/health uses healthCheck" (F5)
- ADR 0004 § Observability headers (F8 X-OLP-Cache correctness)
- ADR 0005 § Cache write conditions item 1 (F10 truncation-not-cached
invariant explicit)
- Iron Rule 5 (F11 release-kit reconciliation)
- OpenAI /v1/models spec — `created` per-model stable (F12)
- CC 开发铁律 v1.6 § 10.x — Round-5 Cold Audit caught all 8
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Verified:
- F1 plugin cross-reference (mistral.mjs:360-369) accurately documents
the rationale
- F3 collision resistance + NUL separator + restored cache invariant
- F5 all 4 cases (empty/single/multi/throwing) work
- F8 closure semantics across multi-hop chains (verified hop-fail +
fallback-hit case)
- F10 dead code removal preserves the stop-chunk completion path
- F11 phase_rolling_mode policy honest about what happened and what
the going-forward rule is
- F12 stability across consecutive /v1/models calls; alias-canonical
parity
- 414/414 tests pass
3 remaining non-blocking suggestions (F3-vs-modern-tool_calls path
canonicalization symmetry; F12 codex models explicit-vs-fallback
writeup mismatch; F8 servingHopWasCached naming) tracked as future
polish; not folded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
30de965e8e |
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 |
||
|
|
de9f3ca7c9 |
test: D29 — eliminate port-collision flake class (16 sites, listen(0))
cold-audit catch from 2026-05-24 (round 3 — D27 flake follow-up)
Pre-existing fragility from D18-era test design: Suite 17 + several
other test sites used overlapping random port ranges (e.g., 25456-25855
and 25460-25859 in Suite 17) with `Math.floor(Math.random() * 400)`
allocation. Sequential close-then-open on Linux Node 20 with TIME_WAIT
timing caused intermittent ECONNRESET on subsequent fetches.
Concrete failure: D27 first CI run failed Suite 17 test 17b with
`socket hang up / ECONNRESET` 1.3ms into the fetch. CI re-run on the
same commit was green — confirming flake, not logic bug. D27's code
changes were unrelated; the flake-prone pattern just happened to
collide on that run.
Fix: switch from random-port-in-fixed-range to OS-assigned port via
`listen(0, ...)`. Each test gets a unique OS-assigned port; collision
is structurally impossible.
Pattern:
```js
const s = createXX();
await new Promise((resolve, reject) => {
s.listen(0, '127.0.0.1', resolve);
s.once('error', reject);
});
const p = s.address().port;
// ... use p in fetch
```
Changes (test-features.mjs +32 / -32):
16 sites updated, organized by Suite:
- Suite 14: 14c (no providers), 14d (anthropic enabled)
- Suite 15 `before()`: shared `port15` for 15a-15d (assigned post-listen
in before-hook)
- Suite 17 (D18-era): 17a, 17b, 17c, 17d (4 /v1/models tests) +
17e, 17f, 17g (3 error-header tests)
- F19 `before()`: shared `portF19` (assigned post-listen in before-hook)
- D27 F15 a/b/c/d/e (5 alias-surfacing tests)
Out-of-scope sites intentionally LEFT alone (test-features.mjs lines
1253, 1882, 2048, 2212, 3048, 4382): they use wider random ranges
(500-1000) AND already have EADDRINUSE retry logic, so collision risk
is structurally bounded. Not the same defect class.
Stability verification:
- 5 consecutive local `npm test` runs (sonnet) — all 400/400 green
- 3 additional consecutive runs (reviewer) — all 400/400 green
- Durations 596-620ms; no Suite 17/15/F19 flakes observed
Pure mechanical refactor:
- No assertion changes (`grep -cE "^[+-].*assert\." diff` returns 0)
- No production code modified — only test-features.mjs
- No behavior contract change — tests assert the same things
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified each of 16 sites uses correct order
(listen before address().port read), Suite 15 + F19 before-block
variables correctly assigned AFTER listen-promise resolves, out-of-scope
sites unchanged, hygiene clean. 3/3 local runs green.
Note for future test additions: prefer `listen(0, '127.0.0.1', resolve)
→ s.address().port` over any fixed-range random port allocation. The
OS-assigned approach makes port collision structurally impossible.
Authority:
- Node.js HTTP server documentation:
https://nodejs.org/api/net.html#serverlisten — port 0 OS assignment
- D27 CI failure on Suite 17 test 17b (workflow run 26353907900,
Tests step — first run failed, re-run green)
- CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit pattern continued:
D27's flake was a known evidence-first checkpoint violation
(per ~/.cc-rules/memory/feedback/evidence_first_under_speed_pressure.md
"host-environment trap" corollary). D29 closes the underlying
fragility class so the same flake can't reoccur on subsequent D-days.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
4a238c9cc4 |
feat(fallback)+chore(cache): D28 — per-hop log observability fields (round-3 F2)
cold-audit catch from 2026-05-24 (round 3)
Round-3 cold-audit Finding 2 (P2 observability gap). ADR 0004 §
Observability headers requires per-hop structured log events to carry
7 fields: timestamp, chain id, hop index, failed provider, trigger type,
IR request hash, and downstream provider that was tried next. Pre-D28
events only carried timestamp/hop/provider/model/error/code — operators
could not:
- correlate hops across one logical request (no chain_id)
- pivot per-prompt via fingerprint (no ir_request_hash)
- tell from one log line which bucket classified the error (no
trigger_type)
- see lookahead routing (no next_provider)
Changes (3 files, +431 / -3):
1. lib/cache/keys.mjs (+35):
- New export `computeIRRequestHash(ir)` returning a 16-char SHA-256
prefix over the IR fields that define request semantics
(messages-normalized, tools, temperature, max_tokens, top_p, stop,
tool_choice, response_format). Excludes provider/model/cache_control
so the hash is provider-agnostic — identical prompts across a
fallback chain produce identical fingerprints, enabling log
correlation. 16-char prefix gives 2^64 collision resistance —
astronomically safe for the use case while keeping log lines compact.
2. lib/fallback/engine.mjs (+86 / -3):
- New helper `classifyTrigger(error)` returning one of:
'hard' / 'auth_missing' / 'client_error' / 'non_trigger' / null.
ProviderError with HARD_TRIGGER_CODES → 'hard'; AUTH_MISSING → its
own bucket; HTTP 400/401/403/404/422 → 'client_error' (per ADR 0004
§ "No fallback for client-side errors"); HTTP 5xx or non-client 4xx
(e.g. 429/529) → 'hard'; other → 'non_trigger'. 'soft' is reserved
in JSDoc for forward-compat per D22 Amendment 2's deferral of soft
triggers to v1.x.
- `executeWithFallback` now generates `chainId` (crypto.randomBytes(8)
.toString('hex')) and `irRequestHash` (computeIRRequestHash(ir))
ONCE at entry — both stay constant across all hops in the chain.
- All 8 logEvent call sites augmented with the 4 new fields:
fallback_soft_trigger, fallback_hop_success, fallback_hop_error,
fallback_client_error_no_fallback, fallback_auth_missing_no_fallback,
fallback_hard_trigger, fallback_non_trigger_error,
fallback_chain_exhausted.
- `next_provider` is `chain[i + 1]?.provider ?? null` on advancement
events; null on terminal events (success / client_error /
auth_missing / chain_exhausted).
- chain_id NOT reused from generateRequestId() since that produces
OpenAI-response-scoped `chatcmpl-<base64url>` IDs; chain_id is
internal log correlation and uses a clean 16-char hex token.
3. test-features.mjs (+313): 24 new tests covering:
- computeIRRequestHash determinism + 16-char hex shape + 8 per-field
sensitivity tests (one per IR input field)
- Provider-agnosticism (same hash across different provider/model)
- chain_id uniqueness across calls + consistency across hops
- trigger_type for each branch (hard / auth_missing / client_error /
non_trigger / null)
- next_provider correctness (chain[i+1] on advance; null at
exhaustion)
Tests: 376 → 400 (+24). All pass on Node 20.
Pure observation-only change: no fallback decision logic touched.
`evaluateHardTriggers`, `evaluateSoftTriggers`, `HARD_TRIGGER_CODES`,
`CLIENT_ERROR_STATUSES` are only READ by `classifyTrigger`; the existing
decision branches are textually unchanged — only the trailing logEvent
object literals are expanded.
Authority:
- ADR 0004 § Observability headers (the 7-field requirement)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- ADR 0004 § "No fallback for client-side errors" — 401/403/404/422
bucket (used by classifyTrigger's CLIENT_ERROR_STATUSES branch)
- ADR 0005 Amendment 2 (D15) — cache key composition fields, mirrored
in computeIRRequestHash minus provider/model/cache_control
- D22 Amendment 2 (ADR 0004) — soft triggers deferred to v1.x;
'soft' reserved in JSDoc but unreturnable
- CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit caught this
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Traced all 8 classifyTrigger branches against
ADR 0004 wording; verified 401/403/404/422 correctly classified as
'client_error' (never 'hard') per the ordering of CLIENT_ERROR_STATUSES
check before the generic status>=400 fallthrough; confirmed observation-
only via diff inspection (only logEvent object literals expanded, no
control-flow changes); independently ran git-stash to verify pre-D28
baseline 376 + 24 = 400.
3 non-blocking suggestions noted (JSDoc 'soft' union vs unreachable
return; future cross-correlation of chain_id with chatcmpl response IDs;
log-line redundancy between fallback_hop_error + fallback_hard_trigger
for same hop) — all cosmetic, not folded.
Note on D27 CI: D27 first CI run failed 1/376 on a Suite 17 port-
collision flake (overlapping random port ranges across Suite 17 tests +
Linux TIME_WAIT timing). Re-run was green. The flake is pre-existing
from D18-era test design; tracked as D29 follow-up to switch Suite 17
to OS-assigned port 0. D28 doesn't touch Suite 17 and is unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c3ba751a8f |
feat: D27 — round-3 P3 batch (F8 IR validator + F10 ADR amend + F15 /v1/models aliases)
cold-audit catch from 2026-05-24 (round 3)
3 round-3 P3 items batched per IDR. Mixed surfaces but all single-severity
and conceptually independent.
Changes (5 files, +324 / -17):
1. lib/ir/types.mjs — F8 validator extension (+28):
- `response_format`: must be object with string `.type` (undefined/omitted
accepted; null, non-object, missing-type all rejected). Forward-compat:
accepts any string for type so future OpenAI additions (json_schema,
etc.) flow through without schema bump.
- `tool_choice`: string form 'auto'/'none'/'required' OR object form
`{type:'function', function:{name:string}}`. All other shapes rejected.
Pre-D27 the validator silently accepted any value; the Anthropic plugin's
`if (irRequest.response_format?.type === 'json_object')` would no-op on
a malformed string payload.
2. docs/adr/0005-cache-cross-provider.md — F10 Amendment 4 (+8):
- Documents the IR-vs-body detection ambiguity in § D2: the ADR text
reads as if detection happens on the IR, but `openai-to-ir.mjs` strips
`cache_control` from messages per ADR 0003's whitelist policy, so
IR-side detection is structurally always empty at v1.0
- Documents the actual v1.0 detection mechanism (server.mjs side-channels
into the raw body)
- Documents the cache key `cache_control` slot's always-null status as
forward-compat (when a future ADR 0003 amendment adds cache_control
to IR, the slot will start carrying meaningful data without schema bump)
- Explicit "no code change" — F10 is docs-only
3. lib/providers/index.mjs — F15 alias map export (+11):
- `getAliasMap()` returns `new Map(_aliasMap)` — defensive copy preventing
caller mutation of the module-private alias map
- JSDoc documents use case + defensive-copy intent
4. server.mjs — F15 /v1/models alias surfacing (+25/-7):
- `handleModels` now emits TWO loops: canonical entries first (preserves
existing client expectations), then alias entries via `getAliasMap()`
- Each alias entry has the same 4 OpenAI-spec fields (id/object/created/
owned_by) — no invented fields per ALIGNMENT Rule 2(b)
- Disabled-provider alias non-leak: `loadedProviders.has(providerName)`
gate skips aliases whose target provider is not currently enabled
- createdTs reused (same per-request timestamp across all entries)
- JSDoc updated to document new ordering + F15 origin
5. test-features.mjs — +269 / +18 new tests:
- F8: 13 tests covering response_format (object/string/non-object/missing-
type) + tool_choice (string variants/object variants/wrong type/no name)
- F15: 5 tests covering canonical-first ordering, alias presence/count,
disabled-provider non-leak (with anthropic-only enabled, mistral and
openai aliases must NOT appear), Rule 2(b) shape conformance
Tests: 358 → 376 (+18). All pass on Node 20.
Reviewer notes (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Critical checks verified:
- F8: all 9+ tool_choice rejection axes traced through code (including
partial-function-object edge cases). The code handles them correctly even
where tests don't exercise (non-blocking gap).
- F10: amendment substantively correct. One wording-precision note: the
amendment says "body only" but the actual code is an OR-disjunction
(hasCacheControl(ir) || extractCacheControlMarkers(body.messages)).
Functionally identical at v1.0 because IR strips markers, so first
disjunct is always false. Forward-compat by construction.
- F15: disabled-provider non-leak verified by manual trace with
anthropic-only enabled — mistral/openai aliases correctly filtered out
by `loadedProviders.has(providerName)` gate. Test 17e explicitly
asserts this.
- F15+D17 round-trip: client GET /v1/models → sees alias entry → POSTs
with alias → getProviderForModel resolves via same _aliasMap → cache
key uses canonical → response works. Both surfaces read the same Map
(SPOT).
- F15+D23 cacheable interaction: cacheable opt-out doesn't suppress
alias surfacing in /v1/models — cacheable is about cache-write behavior
while discovery should still surface enabled providers. Intentional.
Authority:
- ADR 0003 § Optional fields (response_format + tool_choice IR shape)
- OpenAI Chat Completions API spec (the field semantics)
https://platform.openai.com/docs/api-reference/chat/create
- ADR 0005 § D2 + Amendment 4 (F10's own amendment landing here)
- ADR 0002 § Loading model + D17 getProviderForModel SPOT (F15's alias
origin)
- ALIGNMENT.md Rule 2(b) — no invented OpenAI fields
- CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit caught all 3
Follow-up items (reviewer's non-blocking suggestions, NOT in this PR):
- F8 micro-tests for null/boolean/partial-function inputs (code handles;
test gap only)
- F10 wording precision on OR-disjunction
- Nested-describe wrapper quirk in test-features.mjs (pre-existing
structural issue; D26/D27 describes are children of Suite 16 wrapper).
Cleanup in a future hygiene pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a281d3e424 |
fix: D26 — round-3 small batch (F16+F17+F18+F19)
cold-audit catch from 2026-05-24 (round 3)
4 small fixes batched per Iron Rule 11 IDR cleanup-batch convention.
None exceeds ~15 lines; mixed surfaces (server.mjs / 2 plugins /
plugin header / tests) but all P3-class and related by being honesty
fixes to claims the ADR/spec made but code didn't honor.
Changes (5 files, +546 / -13):
1. server.mjs — TWO changes:
- **F16 (startup warning)**: `logEvent` function moved earlier in
the file (was at line ~124, now at line ~56) so it's defined
before `_startupConfig` is loaded. After loading, check
`_startupConfig.soft_triggers` — if non-empty, emit
`logEvent('warn', 'soft_triggers_deferred_v1x', ...)` with the
configured provider names + a descriptive message citing ADR
0004 Amendment 2. Wires the mitigation that ADR 0004 § Mitigations
(original pre-Amendment-2 text) requires: "Soft triggers configured
against such providers issue a startup warning so the user knows
the trigger will never fire." D22 deferred soft triggers but
didn't re-implement this mitigation.
- **F19 (streaming truncation marker)**: in the stop-less exhaustion
branch of the real-streaming code path (post-D25 F9 — the no-cache
fix), write a synthetic `{type:'stop', finish_reason:'length'}`
SSE chunk via `irChunkToOpenAISSE(...)` BEFORE `res.write(SSE_DONE)`.
Guarded on `streamedChunks.length > 0` (emitting truncation on
a zero-content response would mislead the client). Mirrors the
buffered D16 path which already synthesizes the same marker.
The synthetic marker is `res.write`-only — NEVER pushed to
`streamedChunks` — so the D25 F9 no-cache invariant
(cache decision sees the original chunks array unchanged) is
structurally preserved.
2. lib/providers/codex.mjs + mistral.mjs — F17 stderr propagation:
the SPAWN_FAILED throw from the error-chunk-in-NDJSON path now
includes `accumulatedStderr.slice(0, 200)` suffix when non-empty.
Comment cites ADR 0004 § Chain advancement step 4 ("preserve the
client's ability to debug — the first failure is the load-bearing
signal").
**anthropic.mjs intentionally NOT modified**. Re-analysis confirmed
Anthropic's SPAWN_FAILED throws are from (a) process.on('error')
for binary-not-found (already includes OS-level err.message),
(b) SPAWN_TIMEOUT (separate code, D24 race fix), and (c) post-loop
exit-code (already includes `accumulatedStderr.slice(0, 300)`).
Anthropic uses `--output-format text`, so it has no NDJSON
error-chunk parsing path. The cold-audit was correct that the
error-chunk class only affects codex + mistral.
3. lib/providers/anthropic.mjs — F18 plugin header self-contained
D4 observation note. Pre-D26: ALIGNMENT.md anthropic Authority pin
row cited the plugin header for the OLP-side observation, and the
plugin header cited ALIGNMENT.md back — circular citation, no
party documented a fresh observation. Fix: plugin header now
carries the actual observation ("@anthropic-ai/claude-code v2.1.89
confirmed present at D4 implementation per ALIGNMENT.md Rule 5").
ALIGNMENT.md side unchanged (still points to plugin header — but
now points to a real artifact, not back to itself).
4. test-features.mjs — 9 new tests in 3 describe blocks:
- F16 ×3: soft_triggers non-empty → warn fires; empty → no warn;
undefined → no warn. Test uses inline simulation of the startup
code path (ESM module-eval can't be re-triggered per test process
without isolation gymnastics; inline simulation exercises the
same `Object.keys + length > 0 + logEvent('warn', ...)` shape).
- F17 ×3: codex with stderr → stderr appears in throw message;
codex without stderr → no suffix; mistral with stderr → suffix
- F19 ×3: partial-content + stop-less exhaustion → finish_reason='length'
marker before [DONE]; zero-content + stop-less exhaustion →
no marker (just [DONE]); D25 F9 invariant preserved (second
identical request triggers fresh spawn, X-OLP-Cache: miss)
Tests: 349 → 358 (+9). All pass on Node 20.
Authority:
- F16 → ADR 0004 § Mitigations (original) + ADR 0004 Amendment 2 (D22)
- F17 → ADR 0004 § Chain advancement step 4
- F18 → ALIGNMENT.md Rule 1 (Cite First) + Rule 5 (Cite in Commits)
- F19 → OpenAI Chat Completions streaming spec finish_reason enum
https://platform.openai.com/docs/api-reference/chat/streaming
- CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit caught all 4
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified critical concerns: (a) logEvent move is
net-zero (1 removed + 1 added; no duplicate); (b) F17 anthropic
non-application is justified by code-reading 3 SPAWN_FAILED sites in
anthropic.mjs (none is the NDJSON-error-chunk class); (c) F18 citation
chain no longer circular (verified BOTH endpoints); (d) F19 critical
no-cache invariant preserved (truncMarker is res.write-only, never
enters streamedChunks; verified both by code-path analysis and by F19
test 3 behavioral assertion).
3 non-blocking suggestions noted (F18 copy redundancy; F16 test linking
comment; F17 stderr slice-depth alignment) — all cosmetic, not folded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cd391b13ba |
chore: D25 — round-2 P3 batch (F5/F6/F7/F9/F10/F11/F13 + D22 follow-up)
cold-audit catch from 2026-05-24
Final round-2 cold-audit cleanup batch. 8 small P3 items, batched per
Iron Rule 11 IDR cleanup-batch convention (precedent: D19 / D20). No
item exceeds ~35 lines; only F9 is a code change, the rest are docs +
registry updates.
Changes (7 files, +140 / -10):
1. ALIGNMENT.md (+8 / -2)
- **F7**: Authority pin rows for anthropic / codex / mistral updated
from "TBD on Phase-1 spawn" to the actual citations cited in each
plugin's header:
· anthropic — @anthropic-ai/claude-code v2.1.89 (OCP fork audit pin)
· openai — https://developers.openai.com/codex/cli/reference + features URL
· mistral — https://docs.mistral.ai/mistral-vibe/terminal/quickstart + config URL
Plugins remain Candidate (per Provider Inventory) — D25 just removes the
`TBD` marker; Enabled transition still requires Phase audit.
- **F6**: New 3rd entry in § One-shot Triggered Audits — "OpenAI Codex ToS
formal pin (trigger: Phase 2 E2E enable for Codex, OR 2026-12-31)".
Closes the cross-reference from ADR 0006 § Decision table.
2. README.md (+4 / -2)
- **F5**: Cache-key bullet (Architecture section) replaced the stale
7-tuple with a link to ADR 0005 § Cache key composition + the
post-D15 11-field tuple inlined.
- **D22 follow-up**: "328-test suite" → "Comprehensive test suite
covering IR, cache, fallback, and integration paths" (version-less
to prevent re-drift on every D-day).
3. docs/adr/0002-plugin-architecture.md (+4 / -2) — **F11**: Amendment 1
wording corrected. The original Authority line + maxSpawnTimeMs
description said "fallback engine's spawn-timeout enforcement loop"
— but the enforcement actually lives in each provider plugin's
`_spawnAndStream` (setTimeout + proc.kill + reject pattern). The
fallback engine merely treats SPAWN_TIMEOUT as a hard trigger per
ADR 0004 § Trigger taxonomy bullet 4. Both wording sites updated.
4. docs/adr/0005-cache-cross-provider.md (+1) — **F13**: Amendment 2
gains a "Note on null-coalescing collisions" paragraph documenting
that the `?? null` serialization treats undefined / null / [] as
equivalent cache keys for array-typed fields. Intentional — both
`tools: []` and `tools` omitted semantically mean "no tools." If a
future provider distinguishes empty-array vs absent, the serialization
needs revision.
5. models-registry.json (+35) — **F10**: 5 candidate entries added for
the providers ALIGNMENT.md names but registry omitted. All five with
`candidate: true`, `models: []`, tier per ALIGNMENT.md inventory:
· grok / kimi → Tier C
· minimax / glm / qwen → Tier B
Closes the release_kit overlay's "Supported Providers from
models-registry.json" claim. alignment.yml KNOWN_PROVIDERS validation
array already includes all 8 names, so registry → workflow validation
continues to pass.
6. server.mjs (+20 / -6) — **F9**: Streaming success path now distinguishes
stop-terminated vs exhausted-without-stop. Pre-D25 code unconditionally
cached after the for-await loop ended, treating any exhaustion as
"completed." Now: only cache if `lastChunk?.type === 'stop'`. If the
generator exhausts without emitting stop (truncation), log
`streaming_no_stop_chunk` warn event and do NOT persist. Mirrors
D16's buffered-path semantics ("response completed successfully (no
truncation, no error mid-stream)") with the simpler skip-write
pattern (streaming path doesn't use getOrCompute → no singleflight
eviction needed). D23's cacheableForFirstHop guard preserved as the
outer condition.
7. test-features.mjs (+78) — F9 test 15e in Suite 15:
- Mock provider whose spawn yields delta chunks then implicit-returns
without stop (provider-injection pattern same as 15d — the real
plugins synthesize a stop on clean proc exit so __setSpawnImpl can't
simulate this case)
- Asserts response succeeds AND second identical request triggers
fresh spawn (proves no caching happened) AND X-OLP-Cache: miss on
both responses
Tests: 348 → 349 (+1 from 15e). All pass on Node 20.
Authority:
- F5 → ADR 0005 § Cache key composition (post-D15 Amendment 2)
- F6 → ALIGNMENT.md self + ADR 0006 self-reference
- F7 → plugin headers (verified during D25 implementation)
- F9 → ADR 0005 § Cache write conditions item 1 + D16 truncation precedent
- F10 → ALIGNMENT.md § Provider Inventory tier classification
- F11 → ADR 0004 § Trigger taxonomy bullet 4 (the actual SPAWN_TIMEOUT
hard-trigger documentation)
- F13 → ADR 0005 Amendment 2 (extends with the null-coalescing note)
- D22 fu → no spec authority; version-less framing prevents future drift
- CC 开发铁律 v1.6 § 10.x — Round-2 Cold Audit caught all 8 items
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Independently verified F7 citations against
plugin headers (anthropic.mjs:5-6, codex.mjs:23/31, mistral.mjs:26/32);
F10 tier classifications against ALIGNMENT.md § Provider Inventory;
F6 cross-reference now self-consistent (ADR 0006 → ALIGNMENT.md);
alignment.yml workflow validation passes; F9 truncation semantics
mirror D16 buffered-path; test 15e correctly uses provider-injection
since real plugin synthesizes stop on clean exit.
Three non-blocking suggestions noted (README cache-key bullet slightly
verbose with both link + inline list; F9 could optionally synthesize
a finish_reason: 'length' stop chunk for client-visible truncation
observability; test 15e single-delta variant could be extended to
multi-delta). None folded in — all genuine polish, not correctness
gaps.
---
**Round-2 cold-audit cleanup complete.** 5 D-days (D21-D25 minus the
already-completed D24) closed all 13 round-2 findings (P2: F1/F2/F3/F4
in D21/D22/D23/D24; P3: F5-F13 distributed across D25 + earlier issues
#2/#3). v0.1 is one cold-audit-round-3 away from being ready to tag.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
7ef5510837 |
feat(cache): D23 — implement hints.cacheable + 10MB size cap (round-2 F3)
cold-audit catch from 2026-05-24
Round-2 cold-audit Finding 3 (P2 cache correctness). ADR 0005 § "Cache
write conditions" items 3 and 4 were documented but never wired in
code:
- Item 3: "The provider's hints.cacheable flag is not false"
- Item 4: "The response is below a size cap (default 10 MB; configurable)"
Grep verified zero matches for `cacheable` / `10485760` / size-cap
patterns in lib/ or server.mjs pre-D23.
Changes (9 files, +391 / -12):
1. docs/adr/0002-plugin-architecture.md — Amendment 3 adds `cacheable`
to the Provider contract hints list (after D11's Amendment 1 added
maxSpawnTimeMs). Authority chain cites ADR 0005 § Cache write
conditions item 3 as the field's origin.
2. docs/adr/0005-cache-cross-provider.md — Amendment 3 documents the
D23 implementation of items 3 + 4 + the D16-interaction edge case
(truncated > 10MB → no-op eviction, structurally bounded since
responses > 10MB are anomalous by ADR's own rationale).
3. lib/providers/base.mjs — ProviderHints typedef gains
`[cacheable]` (optional boolean); validateProvider rejects non-
boolean non-undefined values. Omission accepted (default = true).
4. 3 plugins (anthropic / codex / mistral) each declare
`cacheable: true` explicitly with citation comment.
5. lib/cache/store.mjs — CacheStore constructor accepts
`maxEntryBytes` (default 10 * 1024 * 1024 = 10_485_760) +
injectable `_warnFn`. `set()` computes
`Buffer.byteLength(JSON.stringify(value))`; if exceeded, warns via
`_warnFn` and returns undefined (no persistence). `getOrCompute`
still returns the computed value to caller — cache write skipped
but caller gets data; subsequent identical requests re-spawn.
6. server.mjs — 4 sites coordinated for cacheable opt-out:
- `executeHopFn`: cacheable check before D13 shouldBypassCacheForHop
(permanent provider policy precedes per-request bypass condition)
- `cacheStore.peek` gate at line ~504: `cacheableForFirstHop`
short-circuit
- Real-streaming branch entry condition at line ~522:
`cacheableForFirstHop` added (so cacheable: false + stream falls
through to buffered path which honors the opt-out via executeHopFn)
- Both `cacheStore.set` sites in streaming branch wrapped in
`if (cacheableForFirstHop)` defensive guards (post-D23
restructure these are unreachable for cacheable: false, but the
guards make intent explicit and survive future refactors)
7. test-features.mjs — 13 new tests:
- 5 validator tests (Suite 4): explicit true/false, omitted, string
rejected, number rejected
- 5 size-cap unit tests (Suite 9): default 10MB, custom override,
oversize skip + warn capture, within-limit normal persistence,
getOrCompute oversize returns-but-doesn't-cache + re-spawn
- 3 cacheable integration tests (Suite 9e): non-streaming opt-out,
streaming opt-out (the regression case that pre-fold-in failed),
X-OLP-Cache header consistency on both paths
Tests: 335 → 348 (+13). All pass on Node 20.
Pre-commit fold-in (per evidence-first checkpoint #4):
- **D23 reviewer flagged 2 blocking issues**: (1) the cacheable opt-out
in initial implementation was only in `executeHopFn` (buffered path);
the D10 real-streaming branch in server.mjs bypassed the check
entirely — calling streamPlugin.spawn() directly and writing to
cacheStore.set() at 2 sites without consulting cacheable. (2) Suite
9e integration tests didn't cover stream: true so the leak wasn't
caught.
Both diff-review and the implementer focused on `executeHopFn`
because that's where the cold-audit reviewer pointed for Finding 3.
Same class of "narrow attention" miss as several earlier D-days.
Fold-in: compute `cacheableForFirstHop` once at request entry; add
`!cacheableForFirstHop` short-circuit to peek gate; add
`cacheableForFirstHop` to streaming-branch entry condition (forces
fall-through to buffered path which has the opt-out); add defensive
guards on both `cacheStore.set` call sites. Added a 3rd Suite 9e
test covering stream: true + cacheable: false (which pre-fold-in
would have failed by serving the second request from cache).
This is now the FOURTH D-day where a doc-vs-code or path-coverage
gap was caught by the reviewer rather than the implementer. The
v1.6 § 10.x diff-review discipline continues to pay off.
Default behavior unchanged for 3 shipped plugins (all explicitly
`cacheable: true` → cache path identical to pre-D23).
Authority:
- ADR 0002 Amendment 3 (in-place) — establishes cacheable in contract
- ADR 0005 Amendment 3 (in-place) — documents implementation of items
3 + 4
- ADR 0005 § Cache write conditions items 3 + 4 — the original
authority for both rules
- CC 开发铁律 v1.6 § 10.x — Round-2 Cold Audit caught the missing
implementation; diff-review Mode A caught the streaming-path gap
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): REQUEST_CHANGES on initial, APPROVE after fold-in (implicit
— fold-in followed the exact recommendation). Verified:
- ADR amendment placement + structure
- Validator typedef + checks
- Size cap implementation in CacheStore + inflight slot release on
oversize-skip
- All 4 interaction cases (cacheable × cache_control × D16
× ordering) coherent post-fold-in
- 13 new tests including the regression test that would have failed
on pre-fold-in code
Follow-up items (reviewer's non-blocking notes, NOT in this PR):
- ADR 0005 Amendment 3 could add one sentence on the prior-write-also-
oversize case (file as docs polish)
- Consider extracting `shouldUseCacheForHop(hopProvider, ir)` helper
combining D13 + D23 logic — reduces miss-risk for next reviewer
- Test 30 could add `assert.equal(store._inflight.size, 0)` as
inflight-slot leak regression guard
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f8348adb3b |
fix(providers): D24 — close spawn-timeout race when timer fires during yield-suspension (round-2 F4)
cold-audit catch from 2026-05-24
Round-2 cold-audit Finding 4 (P2 concurrency bug). All 3 provider plugins
(anthropic / codex / mistral) had a race in spawn-timeout enforcement:
The timer's rejection branch is `if (rejectNext) { rejectNext(SPAWN_TIMEOUT) }`.
`rejectNext` is only set while the drain loop is AWAITING an empty-queue
promise. If the timer fires while the generator is suspended at a `yield`
(rejectNext === null because we're not currently awaiting), the rejection
is silently skipped. The loop then drains queued items, sees `close`
(from the SIGTERM), breaks normally. Post-loop guards on `!spawnTimedOut`
both skip: SPAWN_FAILED throw + yield-stop emit. Generator returns
normally with N partial chunks. Consumer (collectAllChunks) sees a
"successful" return. Fallback never advances. Truncated response gets
cached.
This violates BOTH:
- ADR 0004 § Trigger taxonomy bullet 4 (hard trigger never fires)
- ADR 0005 § Cache write conditions item 1 (truncated response cached)
The bug escaped FOUR reviewers: D10 diff-review, round-1 cold-audit, D11
reviewer, D14 reviewer. Round-2 cold-audit caught it because it was
unprimed and walked the timer/drain-loop race manually.
Fix (3 plugins, parallel race):
Each plugin gains an unconditional post-loop check, placed AFTER the
existing `try { drain loop } finally { clearTimeout(timer) }` block,
but BEFORE the existing `!spawnTimedOut`-gated guards:
```js
if (spawnTimedOut) {
throw new ProviderError(
`<cli-name> spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
);
}
```
This closes the race surface: the inner-loop guard (unchanged, lines
~319/461/583) handles the rejectNext-set path; the new post-loop guard
handles the rejectNext-null path. Together they cover both halves —
SPAWN_TIMEOUT now reliably surfaces as a hard trigger regardless of
which path the timer fire took.
Changes (4 files, +154 / -0):
- lib/providers/anthropic.mjs: post-loop guard at lines 360-365 (after
clearTimeout at line 348-350, before existing SPAWN_FAILED guard at 368)
- lib/providers/codex.mjs: post-loop guard at lines 521-526 (parallel)
- lib/providers/mistral.mjs: post-loop guard at lines 643-648 (parallel)
- test-features.mjs: new Suite 16 test 16e — race reproduction (109 lines)
Test 16e race reproduction (deterministic):
Exploits Node event-loop ordering. Mock spawn schedules data1 + data2 +
close via a single setImmediate. setImmediate runs before timers in the
same tick, so all 3 events queue before the timer can fire. Generator
yields data1 then suspends at yield with rejectNext null. Consumer
pauses 25ms (> 10ms timer). Timer fires during yield-suspension:
spawnTimedOut=true, rejectNext null → branch skipped, SIGTERM sent.
Consumer resumes, drain processes data2 + close, breaks normally.
Post-loop: D24 guard catches spawnTimedOut → throws SPAWN_TIMEOUT.
Pre-D24 behavior: generator returns normally (truncated, silently
cacheable). Test would FAIL.
Post-D24: SPAWN_TIMEOUT throws → test PASSES.
Reviewer empirically validated the test's diagnostic power by stripping
D24 from anthropic.mjs and observing exactly 16e fail (only 16e — 16a
through 16d still pass on the orthogonal rejectNext-set path), then
restored.
Timing flake risk: very low. Race depends only on relative ordering of
two setTimeout callbacks (10ms vs 25ms) which Node guarantees regardless
of absolute drift. Reviewer ran 7 npm test passes; 16e timing spread
26.43-27.70ms (1.3ms variance).
Tests: 334 → 335 (+1). 335/335 pass on Node 20.
Out of scope (filed in issue #3):
- SPAWN_TIMEOUT salvage parity: this fix discards partial chunks (matches
current SPAWN_TIMEOUT semantics, doesn't introduce asymmetry vs
SPAWN_FAILED-no-chunks). Whether to add SPAWN_TIMEOUT-with-usable-chunks
salvage (analogous to D16's SPAWN_FAILED salvage) is a separate design
decision tracked in issue #3.
Authority:
- ADR 0004 § Trigger taxonomy — Hard triggers bullet 4: "Provider CLI
spawn timeout (configurable per-provider via hints.maxSpawnTimeMs)"
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- ADR 0005 § Cache write conditions item 1 (no truncation)
- engine.mjs HARD_TRIGGER_CODES['SPAWN_TIMEOUT'] = true (D10 P1.3,
unchanged)
- CC 开发铁律 v1.6 § 10.x — Round-2 Cold Audit caught this
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified guard position in all 3 plugins; read
push() to confirm rejectNext-null mechanism; empirically validated test
16e's diagnostic specificity (strip-revert-restore); 7 test runs
confirmed timing stability. No blocking issues.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1466d3a082 |
fix(providers): D21 — validateProvider enforces maxSpawnTimeMs contract field (round-2 F1)
cold-audit catch from 2026-05-24
Round-2 cold-audit Finding 1 (P2 contract drift). ADR 0002 Amendment 1
(D11, commit
|
||
|
|
ed82e65859 |
chore: D19 — cleanup batch (Findings 8 + 14 + 15 + D17 dead import)
cold-audit catch from 2026-05-23
Batched 4 small P3 mechanical cleanups per Iron Rule 11 IDR cleanup-batch
convention (all P3, all small, no semantic feature changes beyond defensive
validation).
Changes (7 files):
1. lib/ir/ir-to-openai.mjs (+30 / -3) — Finding 8 defensive validator:
- Added OPENAI_FINISH_REASON_ENUM Set with the 6 spec-allowed values
(stop / length / tool_calls / content_filter / function_call / null)
- Added normalizeFinishReason(value) helper that returns value unchanged
if in enum, else 'stop'
- Routed both irChunkToOpenAISSE (streaming path) and
irResponseToOpenAINonStream (non-stream path) through the helper
- Bonus tightening (in-scope, same finish_reason concept):
irResponseToOpenAINonStream's gate changed from `if (chunk.finish_reason)`
(truthy check) to `if (chunk.finish_reason !== undefined)` so an
explicit null (valid spec value meaning "still in progress") is no
longer silently dropped by the truthy guard
- Note: undefined → null collapse via `?? null` is unreachable in the
current codebase (all provider plugins explicitly set finish_reason
on stop chunks); defensive only against a future plugin that omits
the field — documented inline
2. .github/workflows/alignment.yml (-16) — Finding 14 dead CI cleanup:
- Removed `setup.mjs` from path triggers (push + pull_request) — the
file does not exist in the repo
- Removed the dead `KNOWN_PROVIDERS=(...)` bash array from job 1 and
its comment block — no later step iterated over it, so the array
was abandoned
- LEFT untouched: the Node.js inline KNOWN_PROVIDERS array in the
models-registry validation job — that one is actively consumed by
the schema validation script
3. lib/providers/anthropic.mjs / codex.mjs / mistral.mjs (3 × 1 line) —
Finding 15: removed unused `PROVIDER_ERROR_CODES` from import lines.
Each line went from `import { ProviderError, PROVIDER_ERROR_CODES } from
'./base.mjs';` to `import { ProviderError } from './base.mjs';`. The
constant remains exported from base.mjs (its declaration site, where
it IS used for validation).
4. server.mjs (1 line) — D17 reviewer's observation: removed unused
`getProviderForModel` from the import line. The function is only
called by lib/fallback/engine.mjs which imports it directly from
lib/providers/index.mjs. server.mjs's import was dead (the routing
SPOT lives in engine.mjs after D17 — server.mjs uses buildDefaultChain
exclusively).
5. test-features.mjs (+44) — Suite 3 (irChunkToOpenAISSE format) extended
with 4 new finish_reason normalization tests:
- Test 1: non-spec streaming finish_reason ('timeout', 'overloaded',
'cancelled') → mapped to 'stop'
- Test 2: spec-enum streaming finish_reason (all 6 incl. null) preserved
- Test 3: non-spec non-stream finish_reason → mapped to 'stop'
- Test 4: spec-enum non-stream finish_reason preserved (null
intentionally omitted — documented inline)
Tests: 324 → 328 (+4). All pass on Node 20.
Pre-commit fold-ins (per evidence-first checkpoint #4):
- **D19 reviewer suggestion #1**: added inline comment to
normalizeFinishReason explaining the unreachable `undefined → null`
branch (defensive only, no current plugin omits the field). Cheap
future-reader clarity.
- **D19 reviewer suggestion #2**: added inline comment to Test 4
explaining why null is intentionally omitted from the spec-enum list
(non-stream `!== undefined` gate enters with null and overwrites
default 'stop' to null — semantically odd but spec-valid).
Reviewer suggestion #3 (consider stricter `undefined → 'stop'` on
streaming-stop path vs `null → null` on delta path) explicitly marked
out of scope by reviewer — would require call-site context awareness;
filed mentally as potential future work, not tracked as an issue
since no current path triggers it.
Authority:
- ALIGNMENT.md Rule 2(b) — only spec-defined fields in OpenAI responses
- OpenAI Chat Completions spec finish_reason enum
https://platform.openai.com/docs/api-reference/chat/object#finish_reason
- CC 开发铁律 v1.6 § 10.x — Cold Audit Findings 8 / 14 / 15
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified the unreachable `undefined → null` branch
claim by grep-checking all 3 provider plugins (none emit undefined);
verified the two KNOWN_PROVIDERS arrays were correctly distinguished
(only the dead bash one removed); ran npm test independently to confirm
328/328.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
82ff00784d |
feat(server)+fix(obs): D18 — populate /v1/models + standard X-OLP-* on error paths (Findings 10 + 11)
cold-audit catch from 2026-05-23
Cold-audit Findings 10 + 11 (both P3, server + observability). F10:
/v1/models returned {data: []} unconditionally; README claimed it
lists from models-registry.json — a client calling /v1/models to
discover what OLP serves would conclude OLP has no models. F11: error
responses (chain-exhausted, pre-routing 4xx) omitted the standard
5-header set; ADR 0004 § Observability requires every response to
carry X-OLP-{Provider-Used, Model-Used, Fallback-Hops, Cache, Latency-Ms}.
Changes (2 files, +427 / -9):
1. server.mjs handleModels: populate from loaded providers × canonical
model IDs. Each entry has exactly {id, object: 'model', created,
owned_by} — no invented fields (Rule 2(b)). `created` is computed
once per request as Math.floor(Date.now()/1000). Iteration order is
insertion-order of loadedProviders Map × insertion-order of each
provider's models[]. Empty case (no providers enabled) returns
{object: 'list', data: []} via natural empty iteration. Aliases are
NOT included — per D17 SPOT, models[] is canonical-only; the
/v1/models endpoint surfaces canonical IDs only.
2. server.mjs sendError extended with optional 5th arg extraHeaders.
Non-breaking — existing call sites (10 of them) use the default {}.
Three pre-routing call sites inside handleChatCompletions now pass
X-OLP-Latency-Ms (computed at-error-time as Date.now() - startMs):
- 415 Content-Type mismatch
- 400 invalid JSON body
- 400 IR parse error
The 404 (route not found) and 500 (top-level catch) paths are
outside handleChatCompletions and have no startMs — they remain
without latency rather than synthesize a value.
3. server.mjs chain-exhausted error path: replaced minimal-header
block with olpHeaders({...}) call producing the full standard
5-tuple. X-OLP-Fallback-Exhausted is preserved as an additional
flag layered on top. The 5 header values reflect engine state per
ADR 0004 step 4 ("preserve A's identity — return FIRST hop's
provider/model and original error to user"): providerUsed =
chain[0].provider, modelUsed = chain[0].model, fallbackHops =
attempted count, cacheStatus = 'miss'.
4. test-features.mjs Suite 17 — 7 new tests:
- 17a: /v1/models with anthropic enabled → 3 entries, all
owned_by='anthropic', no aliases in response
- 17b: /v1/models with no providers → {object:'list', data:[]}
- 17c: /v1/models entries have only {id, object, created, owned_by}
(no invented fields — Rule 2(b) assertion)
- 17d: /v1/models with all 3 providers → canonical IDs present,
aliases absent (sonnet/devstral not in response)
- 17e: chain-exhausted response has all 5 standard X-OLP-* headers
present + X-OLP-Fallback-Exhausted
- 17f: 2-hop chain both fail → X-OLP-Fallback-Hops: '2' value check
- 17g: pre-routing 400 invalid JSON has X-OLP-Latency-Ms (non-negative
integer); X-OLP-Provider-Used absent (no provider context — honest)
Tests: 317 → 324 (+7). All pass on Node 20.
Pre-commit fold-in (per evidence-first checkpoint #4):
- **D18 reviewer flagged Concern #1**: original inline comment described
providerUsed as "last-attempted provider name." This was wrong —
lib/fallback/engine.mjs returns chain[0].provider on chain-exhausted
(per ADR 0004 step 4 "preserve A's identity"), which is the FIRST
hop, not the last. The "last-attempted" framing came from my own
dispatch brief and the implementer accurately reflected the brief.
Folded in: corrected the comment to honestly describe what the
engine returns. Same class of doc-vs-reality drift as D11 / D16 /
D17 — the cold-audit + diff-review combination is catching these
consistently across the P3 batch.
Authority:
- ADR 0002 § Loading model — `models: string[]` enumeration
- ADR 0004 § Observability headers — the 5-header standard set
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- ADR 0004 step 4 — "preserve A's identity" on chain exhaustion
- OpenAI Chat Completions spec /v1/models response shape
https://platform.openai.com/docs/api-reference/models/list
- ALIGNMENT.md Rule 2(b) — only spec-defined fields in OpenAI responses
- CC 开发铁律 v1.6 § 10.x — Cold Audit Findings 10 + 11
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Verified all 5 X-OLP-* headers fire
on chain-exhausted path; verified Math.floor(Date.now()/1000) computed
once per request (not per-model); verified aliases excluded; verified
404 + 500 paths honestly omit latency rather than synthesize. Caught
the providerUsed-comment drift which was folded in.
Follow-up items (reviewer's non-blocking observations, NOT in this PR):
- 4 other error sites inside handleChatCompletions have startMs
available but don't emit X-OLP-Latency-Ms (503 no_enabled_provider,
503 provider-not-enabled-streaming, 502 streaming post-error, 500
fallback programming error). The "Latency-Ms-when-startMs-is-available"
rule should be uniform; file as 11b or future cleanup
- Tests 17e/17f could strengthen by asserting specific values not just
presence (e.g., x-olp-provider-used === 'anthropic'); already done
for fallback-hops in 17f
- Test 17e/17f setup duplication — extractable helper for cosmetic
cleanup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cb86807009 |
refactor(routing): D17 — alias-aware getProviderForModel as routing SPOT (Findings 12 + 13)
cold-audit catch from 2026-05-23
Cold-audit Findings 12 + 13 (both P3, both routing layer). F12: mistral
plugin's models[] included canonical IDs + aliases while anthropic/codex
were canonical-only — inconsistent routing surface (request "sonnet"
returned 503 from anthropic but request "devstral" worked for mistral).
F13: getProviderForModel imported but never called; buildDefaultChain
duplicated the lookup loop — two SPOT-candidate code paths.
Per cold-audit reviewer's option (a): standardize models[] on canonical-
only across all 3 plugins; getProviderForModel becomes alias-aware via
models-registry.json; buildDefaultChain uses getProviderForModel as SPOT.
Changes (4 files, +242 / -50):
1. lib/providers/index.mjs:
- At module load, builds `_aliasMap: Map<aliasString, {providerName,
canonicalModel}>` by walking models-registry.json's providers[*].aliases
- getProviderForModel signature extended: returns `{provider, name,
canonicalModel}` (was `{provider, name}`). The canonicalModel field is
the resolved canonical ID for callers that need to use it downstream
(cache key, observability headers, log events)
- 3-step resolution order: (1) alias map lookup → if hit AND provider
loaded → return with canonicalModel; (2) direct canonical scan → return
with canonicalModel = modelString; (3) null
- "Alias known but provider not loaded" case correctly falls through
to step 2 (which will also miss for an alias string) → returns null
2. lib/providers/mistral.mjs:
- _registryModels stripped to canonical-only: removed the
`...Object.keys(_registryEntry.aliases ?? {})` spread
- mistral.models[] now matches anthropic/codex shape (canonical IDs only)
- Comment block updated to point future readers at getProviderForModel
as the SPOT
3. lib/fallback/engine.mjs:
- buildDefaultChain's inline `for ([name, provider] of loadedProviders)`
scan loop replaced with a single getProviderForModel() call
- Chain hop's `model` field is now `match.canonicalModel` (was `modelString`)
— so downstream consumers receive canonical
- Explicit-chain config path unchanged (kept its pre-existing behavior;
alias resolution in routing.chains config is a known limitation, tracked
as a future improvement)
4. test-features.mjs:
- 17 new tests in `D17 — alias-aware getProviderForModel` describe block:
anthropic alias coverage (sonnet/opus/haiku/claude), openai aliases
(codex/codex-spark/gpt5/gpt5-mini), mistral aliases (devstral/
devstral-2/devstral-small/devstral-small-2), canonical pass-through,
unknown model → null, alias-to-disabled-provider → null,
buildDefaultChain integration with alias resolution
- 3 existing mistral tests rewritten — they were asserting the pre-D17
inconsistent shape (aliases in mistral.models[]). Now assert the
post-D17 invariant (aliases NOT in models[]; routing via
getProviderForModel instead)
Tests: 300 → 317 (+17 new). All pass on Node 20.
Pre-commit fold-in (per evidence-first checkpoint #4 — fold-ins themselves
need second-pass review):
- **D17 reviewer flagged C10**: original engine.mjs comment said
"downstream (cache key, X-OLP-Model-Used, provider.spawn) receives the
canonical ID rather than the alias string". The provider.spawn portion
is incorrect — spawn reads `irRequest.model` (the user's original input),
which is NEVER rewritten to canonical. Each provider CLI accepts its own
aliases natively (claude accepts sonnet/opus/haiku; vibe accepts
devstral/devstral-2; codex accepts its model IDs), so runtime behavior
is fine, but the comment overstated what D17 actually changes.
Folded in: corrected comment to honestly describe the canonical flow
(cache key + X-OLP-Model-Used + logs receive canonical; spawn continues
to receive irRequest.model). Same class of doc-code drift fix as D11's
B1 (false maxConcurrent enforcement claim) and D16's "bypasses
getOrCompute" drift — the discipline is maturing across D-days.
Authority:
- ADR 0002 § Provider contract (`models: string[]` is "models this provider
serves")
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- models-registry.json — single source of truth for (provider, model)
metadata + alias→canonical mappings per AGENTS.md SPOT policy
- AGENTS.md § Project-specific constraints — "models-registry.json is the
only place to add/edit (provider, model) metadata"
- CC 开发铁律 v1.6 § 10.x — Cold Audit Findings 12 + 13
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Highest-value verification: walked
chain[0].model from buildDefaultChain through all downstream consumers
(computeCacheKey, X-OLP-Model-Used, log events) to confirm canonical
flow; then verified provider.spawn paths in anthropic.mjs:231 and
codex.mjs:248 read irRequest.model (user input), confirming the comment
overstatement (now fixed). Verified no alias-canonical collision exists
in current registry (12 aliases vs 10 canonical IDs, zero intersection).
Verified empty-registry edge case + provider-with-model-not-in-registry
edge case.
Follow-up items (reviewer's non-blocking observations, NOT in this PR):
- server.mjs:32 dead import of getProviderForModel — defer to D19 cleanup
- explicit-chain config path doesn't run alias resolution (routing.chains
in ~/.olp/config.json) — pre-existing limitation, file as future issue
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
bafa6d1991 |
fix(fallback)+docs(adr-0004): D16 — honor "usable chunks streamed" qualifier on SPAWN_FAILED
cold-audit catch from 2026-05-23
Cold-audit Finding 17 (P2 fallback correctness). ADR 0004 § Trigger
taxonomy Hard triggers bullet 3 says "Provider CLI exit code ≠ 0
**with no usable response chunks streamed**" — the qualifier was
not honored. Pre-D16 code in `collectAllChunks` re-raised SPAWN_FAILED
unconditionally, discarding any partial chunks. Concrete failure:
provider yields 1000 chars of completion then exits non-zero (e.g.,
post-stream cleanup error) → chunks dropped, fallback to next provider,
user pays double spawn cost and loses the original provider's output.
Coordinated change across two layers, single commit per the
ADR-with-code pattern (D11 / D15 precedent):
1. docs/adr/0004-fallback-engine.md — Amendment 1 (top of doc, matching
D11/D15 placement convention):
- Documents the "usable chunks streamed" semantics precisely
- Behavior split: chunks.length > 0 + SPAWN_FAILED → synthesize stop
+ return (Case B); chunks.length === 0 + SPAWN_FAILED → re-throw
(Case A, hard trigger fires as before)
- finish_reason='length' rationale (4 reasons documented)
- Streaming-path note: ADR 0004 first-chunk rule already handles the
analogous case for D10's real-streaming branch; D16 applies to
buffered path only
- Cache behavior: write-through `getOrCompute` (preserves D4 singleflight
during truncation event) then evict via `set(ttlMs=0)` (so future
fresh callers re-spawn). `__truncated` non-enumerable marker
travels with the chunks array for follower visibility
2. server.mjs `collectAllChunks` salvage path:
- try/catch around the for-await loop
- On SPAWN_FAILED with chunks.length > 0: synthesize stop chunk
`{type:'stop', finish_reason:'length'}`, log warn event
`spawn_failed_after_usable_chunks`, mark chunks array with
non-enumerable `__truncated`, return (no re-throw)
- On SPAWN_FAILED with chunks.length === 0 OR any other error:
re-throw (preserves existing hard-trigger semantics)
3. server.mjs `executeHopFn` cache eviction:
- After `cacheStore.getOrCompute(...)` returns, check `result.__truncated`
- If truncated: `cacheStore.set(keyId, hopCacheKey, result, 0)` —
ttlMs=0 causes `_isAlive` to treat the entry as expired on next
read (verified in lib/cache/store.mjs)
4. test-features.mjs Suite 13 — 3 new tests:
- Case A regression: SPAWN_FAILED at iter 0 + 2-hop chain → openai
serves, X-OLP-Fallback-Hops: 1 (no behavior change)
- Case B 2-hop: 2 deltas + SPAWN_FAILED → anthropic serves with
synthesized stop, hops=0, finish_reason='length', content
concatenates, openai NOT called
- Case B single-hop: 1 delta + SPAWN_FAILED → HTTP 200 (not 502),
finish_reason='length', partial content visible
Tests: 297 → 300 (+3). All pass on Node 20.
Pre-commit fold-ins (per evidence-first checkpoint #4 — fold-ins
themselves need second-pass review):
- **Error-chunk-in-chunks fold-in (sonnet flagged)**: pre-D12 code
pushed error chunks BEFORE throwing. Post-D16's `chunks.length > 0`
check would incorrectly include an error chunk and trigger Case B
for a path that's actually Case A. Moved the `type === 'error'`
check BEFORE the push, restoring the invariant that the chunks
array contains only delta/stop chunks. Verified all 3 scenarios:
(1) error at iter 0 → throws before push → length=0 → Case A
(2) delta×2 + error at iter 3 → throws before push → length=2 → Case B
with delta×2 + synthesized stop (no error chunk leaks)
(3) delta + non-zero exit from outside loop → length=1 → Case B
- **ADR doc-code drift fold-in (D16 reviewer flagged)**: original
Amendment 1 text said the salvaged result "bypasses
cacheStore.getOrCompute and is returned directly, exactly as the
cache-bypass path does." This was factually wrong — the code
write-throughs via getOrCompute then evicts via ttlMs=0. The drift
was ironic: D16 was about removing doc-code drift in ADR 0004
bullet 3 itself, and the amendment was about to ship fresh drift.
Corrected to accurately describe the write-then-evict pattern and
the rationale (preserving D4 singleflight during truncation events).
Authority:
- ADR 0004 § Trigger taxonomy Hard triggers bullet 3 (the qualifier
this amendment makes load-bearing)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- ADR 0005 § Cache write conditions item 1 — "response completed
successfully (no truncation, no error mid-stream)"
- OpenAI Chat Completions finish_reason enum (stop|length|tool_calls|
content_filter|function_call|null)
https://platform.openai.com/docs/api-reference/chat/object
- ADR 0004 § Fallback safety — first-chunk rule (already governs the
analogous case in the real-streaming path)
- ALIGNMENT.md Rule 2(c) spirit — ADR amendment + code change land
in same merge (D11 / D15 precedent)
- CC 开发铁律 v1.6 § 10.x — Cold Audit caught this; diff-review
passes focused on first-chunk rule for streaming missed the
buffered path's truncation-vs-fallback decision point
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Folded the ADR doc-code drift minor
before commit. Walked all 3 error-chunk scenarios against actual code
to verify the pre-commit fold-in is correct. Analyzed the eviction
race window (sub-ms post-inflight pre-eviction window where a fresh
caller could hit the truncated cache before eviction lands) and
concluded it's structurally bounded — one-shot leak per truncation
event; subsequent callers re-spawn. Acceptable as v0.1.
Follow-up items (reviewer's non-blocking suggestions, NOT in this PR):
- 4th test asserting second identical request triggers fresh spawn
(defense-in-depth around the eviction; store.mjs ttlMs=0 semantics
are independently established)
- `cacheStore.delete()` API (cleaner than set-with-ttlMs=0 — leaves
no dead entry in the namespace map; future PR)
- `cache_evicted_truncated` log event for dashboard observability
- SPAWN_TIMEOUT salvage parity — same architectural argument as
SPAWN_FAILED (user paid for partial content); deferred as a
separate cold-audit finding for a future D-stage
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8ae77c3ae3 |
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>
|
||
|
|
a7085d9718 |
fix(server): D14 — defer res.writeHead until first chunk in real-streaming branch
cold-audit catch from 2026-05-23
Cold-audit Finding 1 (P2 correctness). Pre-D14 the D10 real-streaming
branch called `res.writeHead(200, ...)` unconditionally before any
provider chunk arrived. If `streamPlugin.spawn(...)` threw or yielded
an error chunk before the first content chunk, the catch block found
`firstChunkEmitted === false` AND `res.headersSent === true` (because
writeHead had already fired), so the `!res.headersSent` branch was
dead code and the path fell through to bare `res.end()`. Result:
HTTP 200 + Content-Type: text/event-stream + zero bytes of body + no
`[DONE]` terminator. The client saw a "successful" empty response.
Compare the buffered path: identical upstream failure returns
HTTP 502 + Content-Type: application/json + `{error: {message, type}}`
body via `sendError(res, 502, ...)`. Inconsistent client-visible
outcomes across paths, with the streaming variant being silently lossy.
Changes (server.mjs +14/-7, test-features.mjs +66):
1. Removed the unconditional pre-loop `res.writeHead(200, ...)` block.
Replaced with a D14 explanatory comment block.
2. Added a deferred writeHead inside the for-await loop, just before
the first `res.write`, guarded by `if (!res.headersSent)`:
```
if (!res.headersSent) {
res.writeHead(200, { Content-Type, Cache-Control, Connection,
X-Accel-Buffering, ...streamHeaders });
}
streamedChunks.push(irChunk);
res.write(irChunkToOpenAISSE(...));
firstChunkEmitted = true;
```
After the first iteration the guard becomes a no-op for subsequent
chunks. Order inside the loop body is fully synchronous (no await
between writeHead, write, and the flag assignment), so the
`firstChunkEmitted` and `res.headersSent` predicates become
equivalent in steady state.
3. The catch block was NOT touched. Its existing branching was correct
but the `!res.headersSent → sendError(502)` arm was dead code
pre-D14; post-D14 it becomes live. This makes the streaming path's
pre-first-chunk error behavior identical to the buffered path's.
Tests: 291 → 292 (+1):
- Test 15d "streaming early error → 502 JSON not 200 empty body":
Injects a mock anthropic provider via the `loadedProviders` test
seam; mock's `spawn` is an async generator that throws ProviderError
immediately (no yields). Asserts r.status === 502, Content-Type
includes application/json, body parses to `{error: {message, type}}`
with type === 'provider_error', and message contains the original
error string. This test would FAIL on pre-D14 code (would have seen
200 + empty SSE body).
Scope explicitly excludes:
- Finding #5 (streaming bypasses D4 singleflight) — deferred to D14.5
with ADR 0005 § D4 amendment pinning the tee-vs-buffer-replay design
choice. Verified no inflight map, no singleflight machinery, no
Promise tracking added.
- No ADR change. ADR 0004 § Fallback safety first-chunk rule already
governs the post-first-chunk behavior (truncated `res.end()`, no
fallback, no in-band error); D14 preserves that semantics exactly.
Authority:
- OpenAI Chat Completions streaming spec: a failed completion before
the first byte should be an HTTP error response, not a 200 with empty
body
https://platform.openai.com/docs/api-reference/chat/streaming
- ALIGNMENT.md Rule 2(b): no invented response shapes (200 with empty
body when error occurred is an implicit invention since OpenAI spec
does not define this as a success shape)
- ADR 0004 § Fallback safety — first-chunk rule (preserved unchanged)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Reviewer stashed the diff to trace pre-D14
control flow manually and confirmed the defect existed exactly as
described (writeHead unconditional → catch with firstChunkEmitted=false
and res.headersSent=true → bare res.end() → silent 200 + empty body).
Verified order inside loop body is correct, headers passed to writeHead
are unchanged, sendError signature matches, stop-as-first-chunk edge
case handled correctly, no singleflight introduced, hygiene clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f34b6905eb |
fix(cache): D13 — per-hop cache_control bypass evaluation (ADR 0005 § D2)
cold-audit catch from 2026-05-23
Cold-audit Finding 2 (P2 cache correctness). Pre-D13 server.mjs:325
computed `bypassCache` once per request globally and passed it unchanged
into every chain hop. Per ADR 0005 § D2, the bypass must only fire when
the active hop's provider is Anthropic. Pre-D13 Codex/Mistral hops in
fallback chains wrongly bypassed OLP's response cache whenever the
original body had a cache_control marker, defeating per-model cache.
Changes (server.mjs +54/-12, test-features.mjs +218):
1. Replaced the global `bypassCache` const with:
- `hasCacheControlMarkers` (request-level boolean, computed once)
- `shouldBypassCacheForHop(hopProviderName)` (helper, returns
hasCacheControlMarkers && hopProviderName === 'anthropic')
2. Refactored 4 call sites to use the helper:
- executeHopFn: bypassCacheForThisHop = shouldBypassCacheForHop(hopProvider)
— log now includes provider field for observability
- preCheckHit: gated on shouldBypassCacheForHop(chain[0].provider)
— first-hop scope (correct for the pre-loop peek)
- Real-streaming gate (D10): same bypassCacheForFirstHop — single-hop
streaming so first === serving
- cacheStatus header: shouldBypassCacheForHop(providerUsed) — reports
SERVING hop's bypass status. Semantic improvement: a fallback chain
anthropic→openai where openai serves now correctly reports
`X-OLP-Cache: miss` rather than the pre-D13 global `bypass`
No engine.mjs change required — executeWithFallback already passes
hopProvider as a string into executeHopFn.
No marker-strip step needed — verified that openai-to-ir.mjs's
translateMessage drops cache_control from the IR object (copies only
role/content/name/tool_call_id/tool_calls/function_call). Non-Anthropic
plugins never see the markers. Cache key over the IR is therefore
identical for "same prompt with markers" and "same prompt without
markers" on non-Anthropic hops — caching is safe and beneficial.
Tests: 288 → 291 (+3 in new Suite 9d):
- Test 31: openai + cache_control → X-OLP-Cache: miss (the fix's core
assertion)
- Test 32: anthropic + cache_control → X-OLP-Cache: bypass (regression
guard, preserves Anthropic behavior)
- Test 33: 2-hop chain anthropic→openai, anthropic hard-fails, openai
serves → X-OLP-Cache: miss + X-OLP-Provider-Used: openai
(per-hop correctness in fallback)
Authority:
- ADR 0005 § D2: "If the IR request contains Anthropic cache_control
markers AND the active provider in the current chain hop is Anthropic,
the OLP response cache is bypassed... If the active provider is not
Anthropic, the cache_control markers are stripped from the IR before
provider translation"
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0005-cache-cross-provider.md
- ADR 0004 § Observability headers (X-OLP-Cache semantics for the
serving hop)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE.
Key verification: reviewer read openai-to-ir.mjs::translateMessage
end-to-end to verify cache_control markers never propagate into IR
(the load-bearing claim — if markers DID propagate, D13 would have
been incomplete and needed a strip step). Confirmed: markers are
dropped at IR translation, no additional strip needed in D13.
Reviewer also walked 4 scenarios (anthropic-only no markers,
anthropic-only with markers, mixed chain anthropic-fail openai-serves,
mixed chain openai-first) against the post-D13 code; all match the
expected per-hop semantics.
Follow-up tracked separately: ADR 0005 § D2 mentions "logged once per
request at debug level" for non-Anthropic noop case; post-D13 the
log fires only for actual bypass (Anthropic + markers). Reviewer's
non-blocking suggestion to either add the log or amend the ADR will
be tracked as a GitHub issue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
4b1a9c8808 |
fix(ir): D12 — remove invented top-level error field on OpenAI response shapes
cold-audit catch from 2026-05-23
Cold-audit Finding 3 (P2 ALIGNMENT.md Rule 2(b) violation). The IR→OpenAI
translator was inventing a top-level `error: {message, type}` field on
chat.completion / chat.completion.chunk objects. OpenAI's spec defines
no such field — errors surface via HTTP 4xx/5xx with `{error: {...}}`
body, not as in-band SSE annotations. The pre-D12 code comments
acknowledged Rule 2(b) for `finish_reason` enum values then proceeded
to invent the top-level error field anyway.
Changes (3 files, +25 / -50 net):
1. lib/ir/ir-to-openai.mjs — both invention sites removed:
- irChunkToOpenAISSE: removed `if (irChunk.type === 'error')` branch
that emitted `{...spec fields, error: {message, type: 'provider_error'}}`
- irResponseToOpenAINonStream: removed `else if (chunk.type === 'error')`
branch + dead `errorChunk` local var + the `if (errorChunk)` block
that appended top-level `response.error = {...}`
- Added explanatory comments at both sites citing ALIGNMENT.md Rule 2(b)
+ OpenAI spec URLs so future readers see the rationale
2. server.mjs:581 — burst-replay loop break condition simplified:
- Pre: `if (irChunk.type === 'stop' || irChunk.type === 'error') break;`
- Post: `if (irChunk.type === 'stop') break;`
- Cache writes (server.mjs:456, :470) only append to streamedChunks
after the error-chunk guard fires, so cached chunks structurally
cannot contain type === 'error'. The removed clause was dead.
3. test-features.mjs — one test rewritten (test count unchanged 288 → 288):
- Old test asserted `payload.error.type === 'provider_error'` exists
(i.e., it tested the violation as if it were correct)
- New test asserts `payload.error === undefined` (the correct Rule 2(b)
invariant) and that object stays `chat.completion.chunk` with
finish_reason in the OpenAI enum
IR contract decision (verified by both implementer and reviewer):
- IR keeps `'error'` in IRResponseChunk typedef — providers legitimately
emit error chunks (e.g., anthropic.mjs)
- server.mjs intercepts error chunks BEFORE translation:
· collectAllChunks() throws ProviderError on type === 'error'
· Real-streaming branch throws ProviderError (no first chunk) or
truncates res.end() (after first chunk per ADR 0004 first-chunk rule)
- The translator is downstream of both guards; error chunks structurally
cannot reach it in normal operation
- Therefore the invention sites were dead-code paths handling an
impossible case — safe to remove
- Pass-through behavior on the impossible path: produces an empty SSE
delta `{choices: [{index: 0, delta: {}, finish_reason: null}]}`
(verified manually by reviewer); the error message text does NOT
leak to the wire
288/288 tests pass on Node 20.20.2.
Authority:
- ALIGNMENT.md Rule 2(b) — no invented OpenAI-spec fields
- OpenAI Chat Completions object spec
https://platform.openai.com/docs/api-reference/chat/object
https://platform.openai.com/docs/api-reference/chat/streaming
- ADR 0004 § Fallback safety — first-chunk rule (preserves the
truncation semantics for post-first-chunk errors)
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified error chunks never reach translator via
three independent grep passes (collectAllChunks guard, real-streaming
guard, no third caller exists), verified cache cannot contain error
chunks (streamedChunks.push is post-guard), verified Rule 2(b) compliance
by direct read of ALIGNMENT.md line 29 + OpenAI spec citations.
Three non-blocking suggestions (defensive log on impossible path;
defensive guard at server.mjs:587; comment placement nit) explicitly
"Not for D12" per the reviewer — preserved as separate cleanup
opportunities if ever needed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
2cfd0b194e |
feat(phase-1): D10 P1 hardening — providers.enabled wiring + real streaming + spawn timeout
Folds in three production-blocking defects from external Codex review
round-3 of Phase 1 (D5+D6+D8+D9 already merged). Without these, OLP
returns 503 on every request even when `npm start` succeeds, streams
arrive as a single buffered burst after the spawn completes, and hung
CLIs block the engine forever despite ADR 0004 declaring spawn-timeout
a hard trigger.
P1.1 providers.enabled config wiring (ADR 0002 § Disable model):
- loadFallbackConfigSync() returns tri-field {chains, soft_triggers,
providersEnabled}; server.mjs reads _startupConfig.providersEnabled
and passes to loadProviders() at startup
- Empty / missing config → 0 enabled providers → 503 no_enabled_provider
(matches v0.1 0-Enabled posture per ALIGNMENT.md § Provider Inventory)
- __setProvidersEnabled / __resetProvidersEnabled test seams; in-place
Map mutation preserves existing direct-mutation patterns in Suite 13
P1.2 Real SSE streaming on single-hop cache-miss (ADR 0003 entry adapter
pattern, OpenAI /v1/chat/completions stream=true contract):
- New handleChatCompletions branch when ir.stream === true && chain.length
=== 1 && !bypassCache && !preCheckHit
- for await (const irChunk of provider.spawn(...)) writes SSE per chunk
via res.write(irChunkToOpenAISSE(...)); accumulates streamedChunks for
cacheStore.set on stop
- First-chunk rule preserved: error-before-first-chunk → sendError(502);
error-after-first-chunk → truncated res.end(), no fallback
- Multi-hop chains (chain.length > 1) continue to use buffered
executeWithFallback to keep fallback safety semantics
P1.3 Spawn timeout hard trigger (ADR 0004 § Trigger taxonomy bullet 4):
- SPAWN_TIMEOUT added to PROVIDER_ERROR_CODES (lib/providers/base.mjs)
and HARD_TRIGGER_CODES (lib/fallback/engine.mjs)
- All three plugins (anthropic.mjs / codex.mjs / mistral.mjs) wrap drain
loop with setTimeout (default 600_000ms, configurable via
hints.maxSpawnTimeMs); on fire: proc.kill('SIGTERM') + reject pending
drain promise with ProviderError(..., 'SPAWN_TIMEOUT')
- Timer cleared in finally; resolveNext/rejectNext atomically nulled in
push() + timer-fire path to prevent late-fire double-settle
Tests 277 → 288 (+11). Suite 14 (4 providers.enabled), Suite 15
(3 streaming cache-miss real-time, including arrival-count >= 2
assertion that architecturally proves real streaming), Suite 16
(4 spawn timeout, including 2-hop chain advancement from timed-out
primary). 288/288 pass on Node 20.20.2 + Node 25.8.0.
Authorities:
- ADR 0002 § Disable model — config toggle, not plugin-removal
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- ADR 0003 § Translation direction model — entry adapter for await pattern
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0004 § Trigger taxonomy + § Fallback safety (first-chunk rule)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- OpenAI /v1/chat/completions stream=true (server-sent events,
data: {chunk} per delta, terminator data: [DONE])
https://platform.openai.com/docs/api-reference/chat/streaming
Reviewer (Iron Rule 10): fresh-context opus, independent of drafter.
Verdict: APPROVE_WITH_MINOR. Folded the one cheap minor before commit
(Suite 15a arrival-count assertion strengthened from chunks.length > 0
to arrivalTimestamps.length >= 2 — the prior assertion would have
admitted a buffered impl). Two remaining non-blocking notes deferred:
optional writeHead deferral (low value; single-hop guard makes pre-
content 200 + empty body safe), and version bump (Phase 1 ships as
v0.1.0 aggregate when D11–D16 land).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|