Commit Graph
10 Commits
Author SHA1 Message Date
taodengandClaude Opus 4.7 b43b07afbf docs: D41 — X-OLP-Provider-Used chain-origin semantics (issue #8)
Round-4 cold-audit Finding 10 (filed as issue #8): on a chain-exhausted
response, X-OLP-Provider-Used returns chain[0].provider — but if hop 0
were soft-skipped (quota threshold exceeded), the header would attribute
a provider whose plugin's spawn() was never called. README's "which
provider's plugin served the request" is technically false in that
edge case.

At v0.1 the scenario is unreachable because soft triggers are deferred
(ADR 0004 Amendment 2) — evaluateSoftTriggers always returns false.
The ambiguity is latent and only activates when soft triggers
reactivate in v1.x.

**Option B chosen — document chain-origin semantics, no code change.**

Option A would track firstAttemptedProvider separately in
executeWithFallback and return that on chain exhaustion. Adding state
for an unreachable v0.1 code path would violate ALIGNMENT.md Rule 2
(No Invention). The D40 X-OLP-Fallback-Detail header (Amendment 5)
already carries per-hop spawn history including soft-skip records
(trigger_type: 'soft'), providing the disambiguation channel on the
wire without needing providerUsed to handle it.

Changes (4 files, +35 / -1):

1. **docs/adr/0004-fallback-engine.md** — Amendment 6 added above
   Amendment 5 in the amendments stack. Documents the chain-origin
   contract, names Option A as the likely v1.x preference, cites
   the Rule 2 rationale + the X-OLP-Fallback-Detail disambiguation
   channel.

2. **README.md** — Observability header description updated:
   "which provider's plugin served the request" gains a clarifying
   sentence about chain-origin semantics on exhausted responses,
   with a pointer to ADR 0004 Amendment 6.

3. **lib/fallback/engine.mjs** — Inline comment block at the
   chain-exhausted return site explicitly cites the amendment and
   captures the v0.1-vs-v1.x semantic. No behavior change.

4. **CHANGELOG.md** — D41 sub-entry under existing D38/D39/D40
   entries in Unreleased section.

No code-behavior change. No new tests — the relevant scenario is
dead-by-config at v0.1. v1.x soft-trigger reactivation work should
add a test exercising soft-skip + chain-exhausted that pins
whichever option (A or B) the v1.x maintainer chooses, and
coordinate the README + ADR Amendment 6 update if Option A is
adopted.

Authority:
- ADR 0004 Amendment 6 (this commit) — chain-origin semantics
- ADR 0004 § Decision § Chain advancement step 4 — original promise
- ADR 0004 Amendment 2 — soft triggers deferred (precondition)
- ADR 0004 Amendment 5 (D40) — per-hop attribution channel
- ALIGNMENT.md Rule 2 — No Invention rationale
- GitHub issue #8 — closed by this commit
- D32 round-4 cold-audit F10 — original filing
- CC 开发铁律 v1.6 § 10.x — Iron Rule 10's implementation-phase scope
  is unmet here (doc-only amendment); no fresh-context reviewer
  dispatched per the documented exception in this amendment's
  procedural mechanism

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:47:33 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 21:44:11 +10:00
taodengandClaude Opus 4.7 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 bafa6d1 non-blocking suggestions — batched here
- CC 开发铁律 v1.6 § 10.x — fresh-context opus reviewer independent
- CLAUDE.md release_kit_overlay phase_rolling_mode — under Unreleased

Reviewer (fresh-context opus, Iron Rule 10): APPROVE_WITH_MINOR.
Verified: delete() sync + call-site no-await correct; namespace
cleanup guarded on (had && ns.size === 0); SPAWN_TIMEOUT NOT in
salvage catch; ADR section 4-point rationale internally consistent
with code state; hygiene clean; 452/452 tests pass independently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:13:57 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 21:02:40 +10:00
taodengandClaude Opus 4.7 9db4266bc5 release(phase-1-close): v0.1.0 — multi-provider proxy core
Phase 1 close per CLAUDE.md release_kit.phase_rolling_mode policy.

Bumps package.json from 0.1.0-bootstrap → 0.1.0. Promotes CHANGELOG
"Unreleased" section to "## v0.1.0 — 2026-05-24" with full D10-D34
commit index summarizing the 25 D-day commits that landed on `main`
between 2026-05-23 and 2026-05-24.

Tag v0.1.0 will be pushed after this commit lands and CI is green.
release.yml auto-creates the GitHub Release from the CHANGELOG v0.1.0
section (extracted via awk pattern match on `## v0.1.0` heading).

**What v0.1.0 delivers:**
- OpenAI-compat HTTP entry surface (/v1/chat/completions, /v1/models,
  /health) with full X-OLP-* observability headers + Rule 2(b) no-
  invention compliance
- Plugin architecture for 3 Tier-D providers (anthropic / codex /
  mistral) shipping as Candidate-not-Enabled per ALIGNMENT.md
- Cache layer: D1 per-key isolation + D4 buffered-path singleflight +
  10MB entry size cap + cacheable opt-out + per-(provider, model)
  isolation (D5 streaming singleflight deferred to v1.x per ADR 0005
  Amendment 6)
- Fallback engine: hard-trigger taxonomy + first-chunk safety + spawn-
  timeout race fix + SPAWN_FAILED salvage of usable partial chunks +
  structured per-hop log observability (chain_id, trigger_type,
  ir_request_hash, next_provider)
- IR design + entry adapters honoring ADR 0003 deterministic
  serialization (including post-D33 F3 fix for deprecated function_call
  cache key stability)
- 416 unit + integration tests with Suite 17 port-collision flake fix
  (D29) so CI is stable

**Test growth**: 277 (pre-D10) → 416 (post-D34). +51%.

**Audit cycle**: 6 cold-audit rounds + per-D-day diff reviews per Iron
Rule v1.6 § 10.x dual-mode review discipline. 78+ findings raised;
~50 closed via implementation; ~28 deferred to GitHub issues #1-#17
for v1.x.

**Phase 1 → v0.1.0 release_kit checklist** (per CLAUDE.md):
- [x] All Phase 1 D-day deliverables landed on main (D10-D34)
- [x] CI green on the head commit
- [x] CHANGELOG "Unreleased" promoted to "## v0.1.0 — 2026-05-24"
- [x] package.json bumped from 0.1.0-bootstrap → 0.1.0
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] release.yml triggered + GitHub Release created (auto on tag push)

**Known limitations carried to v1.x** (full list in CHANGELOG.md
v0.1.0 § "Known limitations"):
- Streaming singleflight not implemented (issue #16)
- maxConcurrent runtime enforcement declarative-only (#1)
- X-OLP-Fallback-Detail header documented but never emitted (#7)
- Soft trigger quotaStatus polling not wired (per ADR 0004 Amendment 2)
- 14 other follow-up items: #2 #3 #4 #5 #6 #8 #9 #10 #11 #12 #13 #14 #15 #17

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 19:52:55 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 19:12:03 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 06:57:11 +10:00
taodengandClaude Opus 4.7 (noreply@anthropic.com) dff428f3d0 docs(governance): fold in codex round-2 review findings (6 issues)
External Codex CLI review pass 2 surfaced 6 substantive issues that
round 1 fold-in missed — the self-consistency trap recurred when fold-in
was scoped only to files codex explicitly named in round 1. This commit
closes round 2 in full.

1. ADR 0002 contradicted ALIGNMENT.md (P1, codex round 2 finding 1)
   ADR 0002 still said "three default-enabled (Anthropic, OpenAI Codex,
   Mistral Vibe)" while ALIGNMENT.md (post round 1) said v0.1 ships zero
   Enabled Providers. Accepted ADR contradicted constitution.
   Fix: ADR 0002 + ADR 0001 + docs/adr/README.md index rewritten to
   Candidate framing.

2. release.yml would publish stale v0.1.0-bootstrap notes (P1, round 2
   finding 2)
   The "Unreleased" amendments would have been silently dropped on tag
   push because release.yml extracts only the matching version section.
   Fix: CHANGELOG restructured so the amended state IS the v0.1.0-
   bootstrap section. Full review history (opus + 2 codex rounds)
   captured inline.

3. package.json advertised non-existent entrypoints (P2, round 2
   finding 3)
   main/scripts.test/scripts.start pointed to files that do not exist.
   Local npm test and npm start failed; CI masked.
   Fix: remove all three from package.json. They return in Phase 1
   alongside the real files. test.yml bootstrap-tolerance updated to
   also skip when scripts.test is absent.

4. models-registry.json missing despite SPOT claim (P2, round 2
   finding 4)
   Fix: minimal stub committed (version + empty providers map).
   alignment.yml validator now actually runs.

5. alignment.yml commit-citation soft check Bash subshell trap (P2,
   round 2 finding 5)
   git log ... while read ... WARN=1 — the while loop ran in a subshell
   because of the pipe, so WARN never propagated out. The post-loop
   check always reported "clean" even when warnings fired.
   Fix: process substitution done less than less than (git log ...).

6. Tier A "permanent" wording inconsistent across ADR 0006 + alignment.
   yml workflow text (P3, round 2 finding 6)
   Fix: unified to "Excluded by default with no routine reinstatement
   path; re-inclusion requires ADR 0006 supersession or amendment with
   new primary-source evidence."

Reviewer: OpenAI Codex CLI (external, fresh-context, pass 2). Iron Rule
10 satisfied — round 2 reviewer was not the implementer of round 1
fold-in.

Memory learning updated: the self-consistency trap recurs in the fold-in
step. Future fold-ins must grep the entire repo for the concept, not
only edit files the reviewer named. See learnings/ai_reviewer_self_
consistency_trap.md in cross-machine memory.

Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
2026-05-23 16:35:23 +10:00
taodengandClaude Opus 4.7 91223ee9ab docs(governance): fold in 6 codex review findings
External Codex CLI review surfaced 6 substantive findings beyond what
the internal opus reviewer caught at D1. All folded in this commit.
Files changed: ALIGNMENT.md, README.md, ADR 0001, ADR 0006, CHANGELOG.

1. Provider Inventory split: Candidate vs Enabled
   - Bootstrap previously listed anthropic/openai/mistral as Tier D
     default-enabled with Authority pins still "TBD at Phase N spawn".
     This violated Rule 1 (Cite First) and Rule 3 (Match Implementation).
   - v0.1 founding now ships 0 Enabled Providers. All 8 are Candidate.
     Enablement requires: authority pin filled + plugin landed + Phase
     audit passed.

2. Antigravity Tier A downgraded to "evidence-backed pending pin"
   - Secondary reports disagree on blast radius (piunikaweb 03-02 says
     AI-tier only; piunikaweb 02-23 + OpenClaw issue + VentureBeat say
     broader). Google FAQ language naming OpenClaw/OpenCode/Claude Code
     is cited from secondary sources only — primary URL not pinned.
   - Exclusion remains active by default; constitutional weight matches
     evidence. Primary-source pinning tracked as one-shot audit task
     with 90-day Tier-reconsideration trigger.

3. ADR 0001 supersession scope narrowed
   - Previous draft claimed OLP is "the structural shape ADR 0005
     endorsed," but ADR 0005's separate-repo recommendation came with
     "BYOK from day one" + "no cli.js spawn" qualifiers OLP rejects.
   - Supersession now narrowly scoped to "single-provider-sufficiency
     premise only"; BYOK + no-spawn parts of ADR 0005 explicitly NOT
     inherited.

4. Anthropic post-2026-06-15 one-shot audit scheduled
   - Annual 14 May audit would leave the Anthropic Tier re-eval almost
     a year late after the 2026-06-15 split.
   - Added one-shot audit for 2026-06-16 (or first billing-cycle close)
     verifying observed behaviour matches spec §2 assumptions.

5. Tier A "permanent" wording unified
   - ALIGNMENT.md and ADR 0006 disagreed (permanent vs amendable).
     Unified as "Excluded by default. Cannot be re-included unless
     ADR 0006 is superseded/amended with new primary-source evidence."

6. OpenAI Tier D wording softened
   - Discussion #8338 was framed as "maintainer confirmed permissive";
     actual quote is a maintainer posture statement with explicit "I'm
     an engineer, not a lawyer" caveat.
   - Now: "maintainer signal indicates low risk; formal ToS pin pending."

Reviewer: OpenAI Codex CLI (external, fresh-context). Iron Rule 10
satisfied — internal opus reviewer was not the source of these
findings; reviewer and maintainer are distinct.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:21:37 +10:00
taodengandClaude Opus 4.7 0041fb1017 chore: bootstrap OLP v0.1 — multi-provider LLM proxy
Initial release. OLP (Open LLM Proxy) is a personal- and family-scale
multi-provider LLM proxy that supersedes OCP (Open Claude Proxy).

Trigger: Anthropic's 2026-05-14 announcement (effective 2026-06-15)
moves `claude -p` / Agent SDK / third-party agent traffic out of the
Pro/Max subscription pool into a separate fixed monthly Agent SDK
Credit pool. OCP's foundational assumption ("subscription = unlimited
within rate limits") breaks for Anthropic on that date. Spreading
risk across multiple providers is the structural response.

Phase 0 lands:
- ALIGNMENT.md (constitution: 5 Rules, 3 Authorities, 4-tier Risk
  Framework, 8-provider inventory)
- AGENTS.md (multi-tool agent guidelines; inherits cc-rules)
- CLAUDE.md (Claude-Code session instructions + release_kit overlay)
- README.md (phase-aware skeleton)
- docs/adr/0001-0006 (Founding ADRs: project founding / plugin
  architecture / IR design / fallback engine / cross-provider cache /
  provider inclusion + risk-tier framework)
- .github/PULL_REQUEST_TEMPLATE.md (8-radio Change Type + per-type
  Authority Evidence + Iron Rule 10 reviewer checklist)
- .github/workflows/alignment.yml (blacklist + Antigravity exclusion
  enforcement + models-registry validator + commit-citation soft check)
- .github/workflows/release.yml (auto-release on tag with version
  match check per Iron Rule 5)
- .github/workflows/test.yml (Node 20/24 matrix, bootstrap-tolerant)
- package.json, .gitignore, LICENSE (MIT), CHANGELOG.md

Provider inventory at bootstrap:
  Tier D (default-enabled):       anthropic, openai, mistral
  Tier C (opt-in):                grok, kimi
  Tier B (opt-in + consent):      minimax, glm, qwen
  Tier A (permanently excluded):  google-antigravity

Supersedes OCP ADR 0005 (No Multi-Provider) per OLP ADR 0001. OCP
will enter maintenance mode when OLP v0.1 ships per Phase 7 plan.

Iron Rule 10 gate: fresh-context independent opus reviewer audited
all 15 governance files against OLP v0.1 spec + OCP precedent.
Verdict: APPROVE_WITH_MINOR. Two minor findings folded in:
  1. alignment.yml heredoc EOF moved to column 0 (was indented;
     bash parse failed silently on real blacklist hits, printing
     a cryptic "syntax error" instead of the structured ALIGNMENT
     GUARDRAIL FAILURE banner).
  2. AGENTS.md clarified that the SPOT discipline for
     models-registry.json will be codified by a Phase-1 ADR (OLP
     ADR 0003 is currently the IR design, not a SPOT codification;
     OCP's ADR 0003 is the precedent but OLP's registry shape
     differs and warrants its own ADR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 15:46:56 +10:00