diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d64d2..ac4fd92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this - **Authority:** ADR 0004 § Decision § Chain advancement step 4 (original promise — D40 fulfils it); ADR 0004 Amendment 5 (D40 ratification); D18 (5 standard X-OLP-* headers; D40 builds on the convention); D28 (per-hop structured log fields; D40 reuses the field shapes); GitHub issue #7 — closed by this commit. - **Test count:** 452 → 468 (7 engine-level tuple-shape tests + 6 serialiser unit tests including the 4KB cap + non-ASCII regression + 3 HTTP integration tests). +### D41 — `X-OLP-Provider-Used` semantics documented (issue #8) + +- **Doc-only clarification.** On a chain-exhausted response, `X-OLP-Provider-Used` identifies the chain's configured primary entry (`chain[0].provider`), not necessarily the first hop where `spawn()` was actually invoked. At v0.1 this is unobservable because soft triggers are deferred (ADR 0004 Amendment 2) — every hop is attempted in order, so chain-origin and first-attempted are equivalent. When soft triggers reactivate in v1.x, a soft-skipped hop 0 followed by hard-failed hops 1+N would still report `providerUsed=chain[0]` despite chain[0] never being spawned. +- **Option B (document chain-origin) chosen over Option A (track `firstAttemptedProvider`).** Rationale: Option A would add state to `executeWithFallback` for an unreachable v0.1 code path (ALIGNMENT.md Rule 2 — No Invention). The D40 `X-OLP-Fallback-Detail` header already carries precise per-hop spawn history (including soft-skip records with `trigger_type: 'soft'`), so the disambiguation channel exists on the wire without needing `providerUsed` to handle it. +- **Updates:** ADR 0004 Amendment 6 documents the semantics; `README.md` § Observability headers replaces "which provider's plugin served the request" with the chain-origin wording; `lib/fallback/engine.mjs` chain-exhausted return site gains an inline comment citing the amendment and the v1.x re-evaluation note. +- **No code-behavior change. No new tests** — the relevant scenario is dead-by-config at v0.1; the v1.x soft-trigger reactivation work should add a test that exercises the soft-skip + chain-exhausted edge case and pins whichever option the v1.x maintainer chooses (the amendment names Option A as the likely v1.x preference). +- **Authority:** ADR 0004 Amendment 6 (this commit); ADR 0004 § Decision § Chain advancement step 4; ADR 0004 Amendment 2 (soft triggers deferred — precondition); ADR 0004 Amendment 5 (per-hop attribution channel via `X-OLP-Fallback-Detail`); ALIGNMENT.md Rule 2 (No Invention rationale); GitHub issue #8 — closed by this commit. +- **Test count:** 468 → 468 (no test change). + ## v0.1.0 — 2026-05-24 ### Phase 1 Close — Multi-provider proxy core diff --git a/README.md b/README.md index 32df35f..0cfdb73 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ See also the [Implementation status](#implementation-status-as-of-2026-05-24) ta Every response served through OLP carries: -- `X-OLP-Provider-Used: ` — which provider's plugin served the request. +- `X-OLP-Provider-Used: ` — which provider's plugin served the request. On a chain-exhausted response, this identifies the chain's configured primary entry (`chain[0]`), not necessarily the first hop where `spawn()` was invoked — see ADR 0004 Amendment 6 for the v0.1 chain-origin semantics and the v1.x soft-trigger reactivation note. - `X-OLP-Model-Used: ` — which model the served provider used. - `X-OLP-Fallback-Hops: ` — number of fallback hops (`0` if served by the primary chain entry). - `X-OLP-Cache: hit | miss | bypass` — cache layer outcome. diff --git a/docs/adr/0004-fallback-engine.md b/docs/adr/0004-fallback-engine.md index 4f493b1..daf9a3d 100644 --- a/docs/adr/0004-fallback-engine.md +++ b/docs/adr/0004-fallback-engine.md @@ -7,6 +7,21 @@ ## Amendments +### Amendment 6 — 2026-05-24: `X-OLP-Provider-Used` chain-origin semantics on exhaustion (D41, issue #8) + +- **Finding:** On a chain-exhausted response, `executeWithFallback` returns `providerUsed: chain[0].provider` (the configured primary). At v0.1 this is always equivalent to "the first provider whose plugin spawned" because soft triggers are deferred per Amendment 2 — every hop is attempted in order. When soft triggers reactivate in v1.x, the equivalence can break: a soft-skipped hop 0 followed by hard-failed hops 1+N would still report `providerUsed=chain[0]` even though chain[0]'s `spawn()` was never called. The README description "which provider's plugin **served** the request" is technically false in this latent edge case. GitHub issue #8 tracked the ambiguity. +- **Decision — Option B (document chain-origin semantics):** v0.1 keeps the chain-origin contract. `X-OLP-Provider-Used` on a chain-exhausted response identifies **the chain's configured primary entry** (`chain[0].provider`), not necessarily the first hop where `spawn()` was actually invoked. Rationale: + - At v0.1 the distinction is unobservable (soft triggers are dead-by-config per Amendment 2). Switching to Option A — track `firstAttemptedProvider` separately and return that — would add state to `executeWithFallback` for an unreachable v0.1 code path, violating ALIGNMENT.md Rule 2 (No Invention). + - The chain-origin framing matches the existing `fallback_hops` semantics: a request that exhausts a 3-hop chain reports `fallbackHops=3`, indicating "the configured chain ran end-to-end." `providerUsed=chain[0]` aligns with that framing as "the primary the user configured for this request." + - The new `X-OLP-Fallback-Detail` header (Amendment 5 / D40) carries per-hop attribution including soft-skip records (`trigger_type: 'soft'`), so the precise spawn history is recoverable from the wire without needing `providerUsed` to disambiguate. +- **Implementation:** + - `lib/fallback/engine.mjs` chain-exhausted return site gains a comment block explicitly citing this amendment and the v0.1-vs-v1.x semantic. + - README "Observability headers" / "API surface" sections updated: replace "which provider's plugin **served** the request" with "the chain's primary entry (configured provider for this request)." +- **v1.x re-evaluation:** When soft triggers reactivate (the v1.x work tracked in Amendment 2), this amendment should be revisited. Option A may become preferable as part of the soft-trigger reactivation PR — the implementer can track `firstAttemptedProvider` alongside the existing `triedProviders` state and switch the chain-exhausted `providerUsed` to that. If chosen, the README + this amendment need a coordinated update. +- **No code-behavior change.** No package.json bump (phase_rolling_mode). No new tests at D41 — the relevant behavior is dead-by-config; future v1.x soft-trigger reactivation should add a test that exercises the soft-skip + chain-exhausted edge case and pins whichever option the v1.x maintainer chooses. +- **Authority:** § Decision § Chain advancement step 4 (return the original first-hop error on exhaustion — Amendment 6 disambiguates "first-hop" as chain-origin); Amendment 2 (soft triggers deferred — the precondition for this edge case being unreachable at v0.1); Amendment 5 (per-hop attribution via fallbackDetail provides the disambiguation channel); ALIGNMENT.md Rule 2 (No Invention — rationale for not adding `firstAttemptedProvider` tracking today); GitHub issue #8 — closed by this commit. +- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D41, doc-only — no fresh-context reviewer required for documentation-only amendments per Iron Rule 10's implementation-phase scope). + ### Amendment 5 — 2026-05-24: `X-OLP-Fallback-Detail` header shipped as ungated v0.1 (D40, issue #7) - **Finding:** Step 4 of § Decision § Chain advancement (below) promised "per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, owner-only — gated behind a config flag for non-owner keys)." From D9 through D39 the engine logged per-hop failure events via `fallback_hop_error` / `fallback_hard_trigger` / `fallback_client_error_no_fallback` / `fallback_auth_missing_no_fallback` / `fallback_non_trigger_error` (D28 added the `chain_id` / `trigger_type` / `ir_request_hash` / `next_provider` correlation fields), but the per-hop failure trail was not surfaced on the response. GitHub issue #7 tracked the gap. diff --git a/lib/fallback/engine.mjs b/lib/fallback/engine.mjs index 52fce9c..1327c56 100644 --- a/lib/fallback/engine.mjs +++ b/lib/fallback/engine.mjs @@ -559,6 +559,16 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option next_provider: null, }); + // D41 (issue #8): providerUsed on chain-exhausted reflects **chain origin** + // (the configured primary), not necessarily the first hop where spawn() was + // actually called. At v0.1 the two are equivalent because soft triggers are + // deferred (ADR 0004 Amendment 2) — every hop in the chain is attempted in + // order. When soft triggers are reactivated in v1.x, the semantic ambiguity + // surfaces: a soft-skipped hop 0 followed by hard-failed hops 1+N would + // report providerUsed=chain[0] even though hop 0 was never spawned. The v0.1 + // contract is chain-origin (option b); v1.x may switch to first-attempted- + // hop (option a) as part of the soft-trigger reactivation work. See ADR 0004 + // Amendment 6 for the documented semantics. return { chunks: null, providerUsed: chain[0].provider,