16 Commits
Author SHA1 Message Date
e87b6b73ec release(phase-2-close): v0.2.0 — multi-key auth + audit + owner gating + keygen CLI (D43-A → D47) (#24)
Closes Phase 2. All 11 ADR 0007 § 10 acceptance criteria shipped + tested
in main across 6 D-day commits (D43-A doc cleanup, D43-B ADR 0007 ratify,
D44 lib/keys.mjs core, D45 server.mjs auth integration + lib/audit.mjs,
D46 owner gating /health + X-OLP-Fallback-Detail, D47 bin/olp-keys.mjs
keygen CLI). Test count 468 (v0.1.1) → 544 (v0.2.0).

Per CLAUDE.md release_kit.phase_close_trigger this PR is the explicit
maintainer-triggered close action.

CHANGES IN THIS COMMIT (release-kit machinery only — no code):

  package.json:
    - version 0.1.1 → 0.2.0

  CLAUDE.md release_kit.phase_rolling_mode:
    - current_phase: Phase 2 → Phase 3
    - current_pre_release_identifier: "0.2.0-phase2" → "0.3.0-phase3"

  CHANGELOG.md:
    - Unreleased promoted to "## v0.2.0 — 2026-05-25" with the D43-A
      → D47 entries intact (already accumulated under release_kit
      phase_rolling_mode discipline during Phase 2 D-days).
    - New Phase 2 release_kit checklist + ADR 0007 § 10 acceptance
      criteria final-ship table + known-limitations-beyond-v0.2.0.
    - New "## Unreleased\n\n(empty — Phase 3 entries land here once
      Phase 3 opens)" sentinel for the next phase. D37
      phase_rolling_mode gate will pass (sentinel-only Unreleased).

  README.md:
    - Status header v0.1.1 → v0.2.0; Phase 1+2 shipped; Phase 3 next.
    - Implementation status note dated post-v0.2.0; reflects Phase 2
      close.
    - Phase plan Phase 2 promoted to  Shipped; Phase 3 marked (next).

Test count 544 / 544 pass (npm test verified locally; no test or .mjs
file touched in this release commit).

NEXT STEPS (post-merge, auto-triggered):

  - git tag v0.2.0 + git push --tags
  - release.yml fires: phase_rolling_mode gate passes (Unreleased is
    sentinel-only) + GitHub Release auto-published from the CHANGELOG
    v0.2.0 section.

ACKNOWLEDGEMENTS:

  - Phase 2 was executed under the maintainer's standing autopilot
    grant (~/.cc-rules/memory/auto/standing_autopilot_phase_2.md,
    cc-rules bf0ed9a); D-day cadence: 6 implementation D-days +
    multiple opus-reviewer fold-ins, all in a single session.
  - ADR 0007 was authored via D43-B with maintainer text review on
    top of fresh-context opus review (4 findings: 1 P1 safety + 2 P2
    + 1 P3) folded in before ratification.

Authority:
  - CLAUDE.md release_kit overlay phase_rolling_mode (Iron Rule 5.5)
    governs this commit's shape; phase_close_trigger requires explicit
    maintainer action — the user issued "go" to trigger.
  - ADR 0007 (multi-key auth) — the Phase 2 design contract;
    acceptance criteria #1–#11 covered.
  - CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for
    every implementation phase + design ADR (executed on D44, D45,
    D46, D47, and D43-B with double-review).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:19:13 +10:00
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>
2026-05-25 14:54:05 +10:00
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>
2026-05-25 14:42:24 +10:00
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>
2026-05-25 14:28:45 +10:00
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>
2026-05-25 13:37:38 +10:00
d253c2b98d docs: D43-B — ADR 0007 multi-key auth design draft (design-only) (#19)
* docs: D43-B — ADR 0007 multi-key auth design draft (design-only, no code change)

Phase 2 mainline design ADR. Ratifies the storage / token / manifest /
atomic-write / owner-gating / bootstrap / Node-baseline decisions ahead
of D44+ implementation D-days.

Pure design doc — no .mjs / no tests / 4 files touched. Test count
468 → 468.

- docs/adr/0007-multi-key-auth.md (new, ~400 lines): 13 sections
  covering Context / Decision (Option 2 filesystem manifest + opaque
  token) / Storage layout / Manifest schema / Token format (olp_+32B
  base64url, SHA-256 hash) / Atomic write & audit append (manifest
  lifecycle-only atomic via tmpfile+fsync+rename; audit per-request
  append with warn+1-retry, no memory buffer at Phase 2) / Owner-vs-
  guest-vs-anonymous gating (config.json auth.allow_anonymous default
  false, no env auto-detection) / Audit ndjson schema (no PII) /
  Bootstrap & recovery (minimal keygen command surface +
  OLP_OWNER_TOKEN env override with stable __env_owner__ keyId) /
  Acceptance criteria (11 test surfaces) / Node baseline (Option 1
  SQLite port rejection rationale citing engines >=18 + CI 20/24 vs
  node:sqlite v22.5.0/RC) / Out of scope (Dashboard, quota
  enforcement, audit query, file locking deferred to Phase 3+) /
  Future forward (Option 3 hybrid migration trigger + preconditions).

- docs/adr/README.md index: added ADR 0007 row with one-paragraph
  summary covering storage choice + rejection rationale.

- docs/v1x-roadmap.md #2: marked PHASE 2 ACTIVE (no longer deferred);
  "Design ADR (NOT YET RATIFIED)" → "Design ADR (ratified) → ADR
  0007"; trigger updated to "already fired 2026-05-25"; code anchors
  pinned to exact line numbers (cache/store.mjs:77-79/:287, server
  .mjs:502/:531/:392/:1072/:1101).

- CHANGELOG.md Unreleased: D43-B entry per release_kit overlay
  phase_rolling_mode discipline.

Authority:
- Phase 2 kickoff handoff (~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md, cc-rules d9da966)
- OLP v0.1 spec § 4.5 (planning authority for ~/.olp/ layout)
- OCP keys.mjs (prior-art for opaque-key + per-key isolation model)
- Node node:sqlite docs (https://nodejs.org/api/sqlite.html — Option 1 rejection per ADR 0007 § 11)
- CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for design ADR per Iron Rule 10

ALIGNMENT.md scope check: this PR introduces a new ADR; per ALIGNMENT.md
Rule 1 (Cite First), the ADR itself contains the authority citations
its decisions rest on (v0.1 spec § 4.5, OCP keys.mjs, Node docs URL).
No provider plugin / entry surface / IR change in this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: D43-B fold-in — ADR 0007 reviewer findings (2 P2 + 3 P3, all polish)

Fresh-context opus reviewer (PR #19) returned APPROVE_WITH_MINOR with 2 P2
load-bearing-but-non-blocking findings + 3 P3 polish findings. All five
are accepted as suggested; design contract clarified without semantic
change.

- § 6.2 step 1 (P2 #1) — pin audit serialization to fire AFTER status_code
  is determined and latency_ms is measured. Makes acceptance criterion #2
  (anonymous-401 audit event records the 401 + latency) testable in the
  way the criterion was written.

- § 6.3.5 (P2 #2, new subsection) — explicit "Token validation MUST hit
  the manifest on every authenticated request (no in-process validation
  cache at Phase 2)" rule. The acceptance criterion #6 (post-revoke 401
  within the next request) was previously enforced only by the test; the
  rule now belongs to the design contract. Forward-path note documents
  when a Phase 3+ amendment may add a cache.

- § 6.1 atomic-write step 5 follow-up (P3 #3) — document the deliberate
  omission of directory fsync after rename. Single-process family-scale
  deployment accepts the tiny rename-loss window under abrupt host crash;
  future POSIX-strict deployments know where to add the step.

- § 9.4 (P3 #4) — declare token-collision between OLP_OWNER_TOKEN and
  a filesystem-stored key's plaintext as undefined behaviour. Operators
  MUST NOT reuse plaintext across both surfaces. Phase MAY add startup
  collision-detection later.

- § 10 criterion #4 (P3 #5) — rephrased to assert against the config-
  driven owner_only_endpoints predicate rather than a hardcoded trimmed
  payload shape. The test stays stable if an operator removes /health
  from owner_only_endpoints.

CHANGELOG D43-B entry: fold-in bullet added to summarize the 5 fixes.

Test count: 468 → 468 (npm test verified locally after fold-in).

Authority: PR #19 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: D43-B fold-in #2 — maintainer text-review findings (1 P1 + 1 P2 + 1 P3)

Maintainer (codex) did final text review of PR #19 against the Phase 2
ADR-ratification checklist. Returned 3 findings; all accepted as
suggested. Two are contract-level (P1 safety + P2 factual); one is
trivial (P3 line-count drift). No semantic change beyond what the
findings called out.

P1 — § 6.3 / § 6.4 / § 10 #7 — revoke-dominates-touch safety contract

  Original § 6.3 said touchLastUsed used the same atomic-write pattern
  as 6.1; § 6.4 said concurrent CLI revoke + touchLastUsed left "both
  states valid" with "observability-grade" failure mode. Codex correctly
  identified the bug: a stale manifest snapshot held by the touch path
  could overwrite a fresh revoke and silently clear revoked_at back to
  null, breaking acceptance criterion #6 (post-revoke 401 within next
  request) under concurrent CLI revoke + in-flight server request. The
  ADR was promising security-grade behavior on a path that was actually
  last-write-wins.

  Fix:
  - § 6.3 rewritten with explicit read-modify-write discipline: touch
    MUST re-read latest manifest from disk inside the per-key write-lock,
    NO-OP if revoked_at is non-null, otherwise merge last_used_at
    preserving all other fields including revoked_at.
  - § 6.4 reframed from "both states valid" to "revoke dominates touch"
    safety frame, citing § 6.3 as the load-bearing discipline. The
    CLI revoke writer always wins the dimension that matters; touch
    may lose its last_used_at update if it raced.
  - § 10 criterion #7 expanded to test all three orderings (revoke
    -> touch, touch -> revoke, interleaved) with the explicit MUST:
    revoked_at is non-null and equals the revoke writer's timestamp
    after any interleaving; FAIL if any path produces revoked_at: null.
  - Forward-path § 6.4 file-locking note updated to clarify §6.3
    already holds the contract single-process; flock adds defense-in-
    depth for rare multi-writer TOCTOU.

P2 — § 11 forward path step (1) — Node baseline version history corrected

  Original wording "Node v22.5.0+ for unflagged but RC; Node TBD for
  stable" was wrong. v22.5.0 added with --experimental-sqlite flag;
  v22.12 still required the flag; the module moved past flag-gating
  in v22.13.0 (LTS) / v23.4.0 (current); entered Release Candidate at
  v25.7.0 per current docs.

  Fix: § 11 forward path step (1) rewritten with accurate versions +
  two Node release-history URLs cited (https://nodejs.org/download/
  release/v22.12.0/docs/api/sqlite.html and https://nodejs.org/api/
  sqlite.html). Minimum non-flag-gated baseline is now stated as
  >=22.13.0 (LTS) / >=23.4.0 (current); stable baseline TBD pending
  Node v25.x+. The rejection-evidence paragraph earlier in § 11
  ("v22.12 still required --experimental-sqlite ... current docs mark
  RC") was already correct and is untouched.

P3 — CHANGELOG D43-B line-count corrected

  Entry said ADR was "~270 lines"; actual file is 420 lines after both
  fold-ins. Changed to "~420 lines after fold-ins". Phase 2 fold-in #2
  bullet enumerates the 3 fixes in this commit; fold-in #1 bullet
  retained for the opus reviewer round.

Test count: 468 / 468 (npm test verified locally after fold-in;
design-only doc changes, no test file touched).

Authority: PR #19 maintainer text review findings 2026-05-25; CLAUDE.md
release_kit overlay phase_rolling_mode — under Unreleased; Node SQLite
docs URLs cited in ADR § 11 forward path.

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>
2026-05-25 12:56:35 +10:00
68851fe3d7 docs: D43-A — Phase 2 doc alignment (no code change) (#18)
* docs: D43-A — Phase 2 doc alignment (no code change)

Phase 1 was closed at v0.1.1 (multi-provider proxy core + pre-Phase-2
cleanup, D35-D42). This commit aligns documentation surfaces to the
Phase 2 reality before D43-B (ADR 0007 multi-key auth design draft) lands.

Pure doc cleanup — no .mjs / no tests / 4 files touched.

- CLAUDE.md release_kit.phase_rolling_mode:
  * current_phase: Phase 1 → Phase 2
  * current_pre_release_identifier: "0.1.0-bootstrap" → "0.2.0-phase2"
- README.md:
  * Status header now reads "v0.1.1 shipped (2026-05-25); Phase 2 in progress"
  * Implementation Status dated 2026-05-25; intro paragraph reflects Phase 1
    close + Phase 2 active
  * lib/keys.mjs row: "📋 Planned (Phase 2)" → "📋 Phase 2 active per ADR 0007
    (drafting at D43-B)"
  * Known limitations "Multi-key auth not yet implemented" note updated
  * Phase plan rewritten end-to-end: the original v0.1 spec planned one
    plugin per phase, but actual execution bundled the three Tier-D plugins
    + cache + fallback into a single Phase 1 milestone (v0.1.0+v0.1.1).
    New plan: Phase 0  / Phase 1  / Phase 2 multi-key auth (current) /
    Phase 3 dashboard / Phase 4+ v1.x roadmap / Phase N tier-2 opt-in.
- AGENTS.md § Key files to know:
  * lib/keys.mjs marker updated
  * Implementation-status-note dated 2026-05-25; reflects v0.1.1 close +
    Phase 2 active scope
- CHANGELOG.md Unreleased: D43-A entry recording the alignment per
  CLAUDE.md release_kit overlay phase_rolling_mode discipline.

Authority: CLAUDE.md release_kit overlay phase_rolling_mode — under
Unreleased; Phase 2 kickoff handoff at
~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md (committed in
cc-rules d9da966); ADR 0007 forthcoming at D43-B.

Test count: 468 → 468 (npm test verified locally before commit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: D43-A fold-in — ALIGNMENT.md phase terminology note (reviewer P2)

Fresh-context sonnet reviewer (PR #18) flagged P2: ALIGNMENT.md uses
"Phase 2"/"Phase 3" at lines ~58/~143-144/~179 with the original
per-plugin enablement meaning (Phase 2 = Codex enable, Phase 3 = Mistral
enable), conflicting with the new README phase plan rewritten by D43-A
where Phase 2 means multi-key auth.

Reviewer's recommended minimal fix (B1): add a clarifying note in
ALIGNMENT.md § Provider Inventory header explaining the dual usage,
rather than amend the tables or audit trigger wording. This keeps D43-A
within "pure doc cleanup" scope.

- ALIGNMENT.md § Provider Inventory: one-paragraph "Note on phase
  terminology" inserted between the v0.1 zero-Enabled-Providers
  rationale and the Enabled Providers table. No Speculative-Candidate
  table change, no audit-trigger wording change, no governance-text
  change.
- CHANGELOG.md Unreleased D43-A entry: ALIGNMENT.md added to the file
  list with a one-line explanation referencing the reviewer-P2 fold-in.

Test count: 468 → 468 (npm test verified locally after fold-in; no test
file touched).

Authority: PR #18 fresh-context reviewer finding P2; 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>
2026-05-25 09:29:05 +10:00
taodengandClaude Opus 4.7 8fd8f86942 release(phase-1-cleanup): v0.1.1 — pre-Phase-2 batch (D35-D42)
Phase 1 cleanup release per CLAUDE.md release_kit.phase_rolling_mode
policy. Closes 16 of 17 pre-Phase-2 GitHub issues; #16 stays OPEN as
v1.x tracker with design ratified in ADR 0005 Amendment 8.

Bumps package.json from 0.1.0 → 0.1.1. Promotes CHANGELOG "Unreleased"
section to "## v0.1.1 — 2026-05-25" with full D35-D42 entries
(backfilled D35/D36/D37 entries per cold-audit round 7 Finding 1) and
final cleanup batch summary covering all 8 D-day commits that landed
on `main` between 2026-05-24 and 2026-05-25.

Tag v0.1.1 will be pushed after this commit lands and CI is green.
release.yml auto-creates the GitHub Release from the CHANGELOG v0.1.1
section (extracted via awk pattern match on `## v0.1.1` heading).
D37's phase_rolling_mode gate now passes because Unreleased is
sentinel-only after this commit.

**What v0.1.1 delivers (D35-D42):**

- D35 — streaming empty-stream headers (#9) + truncation marker (#10)
  + irVersion strict check (#11) + alignment.yml scripts/** removal
  (#12) + X-OLP-Latency-Ms uniform audit (#4)
- D36 — cache_control partial-noop log (#2) + ADR 0002 filename
  correction (#5) + mistral A5 flip (#6) + /v1/models alias governance
  (#13) + cache_control determinism test (#14) + anthropic v2.1.89
  transcript artifact (#15)
- D37 — release.yml phase_rolling_mode CI gate (#17)
- D38 — maxConcurrent runtime enforcement + new CONCURRENCY_LIMIT
  hard trigger (#1)
- D39 — CacheStore.delete + cache_evicted_truncated log + sticky-
  cache regression test + SPAWN_TIMEOUT asymmetry ADR (#3)
- D40 — X-OLP-Fallback-Detail debug header + per-hop tuple collection
  + 4KB cap + RFC 7230 non-ASCII escape (#7)
- D41 — X-OLP-Provider-Used chain-origin semantics documented (#8)
- D42 — streaming singleflight v1.x design ADR + multi-layer
  safeguards (issue #16; STAYS OPEN as v1.x tracker)

**Test growth:** 416 (v0.1.0) → 468 (v0.1.1). +52 tests across the
8 D-day batch. All 468 pass at the release-commit head.

**Cold-audit cycle:** 1 final round (round 7) over D35-D42 + v0.1.0
state. Result: PASS_WITH_MINOR with 4 findings (1×P3 + 3×P4).
- Finding 1 (P3 CHANGELOG missing D35/D36/D37) — FIXED in this commit
  via backfill.
- Finding 2 (P4 ADR 0005 Amendment 8 "Amendment 1/3/5" notation
  ambiguity) — FIXED in this commit (replaced with explicit
  § Decision body item-1 + Amendment 3 + Amendment 5 wording).
- Finding 3 (P4 ADR 0005 Amendment 8 Context paragraph cites line 811
  but actual branch is at lines 817-823) — FIXED in this commit
  (Context paragraph now points at lines 817-823 with the TODO anchor
  at ~810 as the navigable landmark).
- Finding 4 (P4 ADR 0002 missing Amendment 2 — pre-existing gap)
  — FIXED in this commit (added explanatory note under the
  Amendments heading explaining the gap is intentional and load-
  bearing).

No P1 or P2 findings.

**Phase 1 cleanup release_kit checklist** (per CLAUDE.md):
- [x] All 8 D-day deliverables landed on main (D35-D42)
- [x] CI green on every D-day commit + on this release commit's head
- [x] Cold-audit round 7 PASS_WITH_MINOR (0 P1/P2; 4 findings all
  fixed in this commit)
- [x] 16 of 17 pre-Phase-2 GitHub issues closed (#1-#15 and #17);
  #16 stays OPEN as v1.x tracker with design ratified
- [x] Issue #16 status comment posted (D42 commit b0c080d)
  referencing ADR 0005 Amendment 8 design ratification
- [x] CHANGELOG "Unreleased" promoted to "## v0.1.1 — 2026-05-25"
  with D35-D42 entries (including backfilled D35/D36/D37)
- [x] package.json bumped from 0.1.0 → 0.1.1
- [x] docs/v1x-roadmap.md created at D42 — 7 deferred items with
  code anchors + GitHub-issue cross-refs + concrete start triggers
- [x] Editorial findings 2/3/4 from round 7 folded into this commit
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] release.yml triggered + GitHub Release created (auto on tag
  push; D37 phase_rolling_mode gate will pass because Unreleased
  is now sentinel-only — gate dry-run verified locally)

**Known limitations carried to v1.x:** full list with code anchors
+ start triggers in docs/v1x-roadmap.md. The 7 items are:
1. Streaming-path singleflight (issue #16, ADR 0005 Amendment 8)
2. Multi-key auth (lib/keys.mjs)
3. Soft-trigger reactivation (ADR 0004 Amendment 2)
4. /health activeSpawns integration (ADR 0002 Amendment 6)
5. Provider-level cacheKeyFields mask (ADR 0005 Amendment 7)
6. Streaming-path SPAWN_FAILED salvage
7. D40 AUTH_MISSING tuple test coverage

Authority:
- All 8 D-day commit messages (D35-D42 on main)
- ADR 0002 Amendments 5+6 + Amendment-2-gap note (D36, D38, this
  commit's editorial fix)
- ADR 0004 Amendments 3+4+5+6 (D34, D38, D40, D41)
- ADR 0005 Amendments 6+7+8 (D34, D42) + this commit's Amendment 8
  editorial fixes
- ALIGNMENT.md controlled-deviations subsection (D36 #13)
- docs/v1x-roadmap.md (D42 — new file, this commit's checklist
  references it)
- docs/provider-audits/anthropic.md (D36 #15 — new file)
- CC 开发铁律 v1.6 § 10.x — every D-day had an independent
  fresh-context opus reviewer per Iron Rule 10; round 7 cold audit
  was a separate fresh-context full-pass auditor
- CLAUDE.md release_kit_overlay phase_rolling_mode — this is the
  Phase 1 cleanup release that the policy was written for

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 07:13:14 +10:00
taodengandClaude Opus 4.7 b0c080db13 docs: D42 — streaming singleflight design ADR + v1.x safeguards (issue #16)
ADR 0005 Amendment 6 (D34) deferred streaming-path D4 singleflight to
v1.x with the note "the design alone warrants a dedicated ADR." Round-6
cold-audit F13 (issue #16) raised the sibling TOCTOU window between
server.mjs's preCheckHit peek and the streaming-branch spawn. D42
fulfils Amendment 6's deferral note by ratifying the v1.x design as
ADR 0005 Amendment 8 — design only, no implementation.

**Design ratified (ADR 0005 Amendment 8, 14 sections):**

1. New `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory)`
   API with `{ stream, isFirst }` return shape. Mirrors the buffered
   path's `getOrCompute` to keep the cache API surface coherent.
2. `StreamingInflightEntry` shape — source iterator + AbortController +
   accumulated-chunks replay buffer + attached-clients Set + state
   flags + D38 spawn-slot tracker.
3. `AttachedClient` shape — per-client tee buffer + byte-size meter +
   late-joiner replay flag + done/resolveNext/rejectNext for the
   single-reader-multi-writer tee.
4. Tee fan-out loop (one reader drains source, fans chunks to all
   attached clients).
5. Late-joiner replay policy — accumulated chunks burst-drained on
   attach.
6. Cache TTL race during inflight — late joiners see the inflight entry
   and join; expired cache slot is overwritten by inflight completion.
7. D38 maxConcurrent coordination — only first caller acquires; release
   fires once on source-complete/error/abort.
8. New `STREAM_BACKPRESSURE` error code. NOT a hard trigger. Affected
   client gets synthetic `{ type: 'stop', finish_reason: 'length' }` +
   `[DONE]` (matches D35 #10 truncation marker).
9. Mid-stream disconnect — remove from attached set; if 0 remaining,
   abort source via AbortController.
10. Replay buffer cap (10 MB, matches D23 cache-entry cap) — over cap
    marks entry not cacheable, late joiners get backpressure error.
11. Observability — 4 new log events
    (streaming_inflight_join / source_done / abort,
    stream_backpressure_disconnect) + new
    `X-OLP-Streaming-Inflight: source | attached | solo` header.
12. Server.mjs wiring — replaces the current peek+spawn pattern;
    TOCTOU window closes because Map check+insert is synchronous.
13. Test surface (when implementation lands) — 10 scenarios covering
    single-client / 2-concurrent / 3-concurrent / mid-stream join /
    first-disconnect / all-disconnect / source-error / backpressure /
    D38 coordination / TTL race / replay cap / X-OLP-* header values.
14. Defaults — PER_CLIENT_QUEUE_CAP=1MB, ACCUMULATED_REPLAY_CAP=10MB,
    STREAM_BACKPRESSURE not in HARD_TRIGGER_CODES.

**Multi-layer safeguards (the maintainer asked: "保证后面这一块会被处理而不会被忽略"):**

1. **`docs/v1x-roadmap.md` (NEW)** — single living landing page for
   every Phase-1 deferral. 7 items at D42:
   - #1 Streaming SF (this amendment)
   - #2 Multi-key auth (lib/keys.mjs)
   - #3 Soft trigger reactivation (ADR 0004 Amendment 2)
   - #4 /health activeSpawns integration (D38)
   - #5 Provider-level cacheKeyFields mask (ADR 0005 Amendment 7)
   - #6 Streaming-path SPAWN_FAILED salvage
   - #7 AUTH_MISSING tuple test coverage (D40 follow-up)
   Each entry names the ratifying ADR, load-bearing code anchor
   (file:line), GitHub issue (if any), concrete start trigger, and
   estimated effort. Maintainer's session-startup discipline grep
   this file at sprint kickoff.

2. **Issue #16 STAYS OPEN** — not closed in D42. Body updated post-
   commit to reference Amendment 8 with status "design ratified;
   implementation pending." Do not close until §13 test surface is
   green on actual implementation.

3. **`lib/cache/store.mjs#getOrCompute` JSDoc** — TODO comment for the
   sibling streaming API pointing at Amendment 8 + v1x-roadmap.md #1.

4. **`server.mjs` streaming-branch entry (~line 810)** — TODO comment
   block citing Amendment 8 + issue #16 + roadmap.md #1, naming the
   exact code lines the v1.x impl will replace.

5. **`README.md § Known limitations` section** — new subsection
   surfaces 4 limitations to users (streaming SF / soft triggers /
   multi-key auth / cacheKeyFields mask), each linking to the
   v1x-roadmap.md entry.

6. **Amendment 8 § "Cross-references and safeguards"** — explicit
   cross-link block enumerating the above 4 anchors so a future
   ADR-only reader knows every breadcrumb.

**Maintainer decision recorded:** Option 1 (design ADR only) chosen
over Option 2 (design + implementation now). Rationale: 200-400 lines
of concurrency primitives + 15-20 tests is not "pre-Phase-2 cleanup"
in shape — it is real v1.x feature work. Shipping streaming SF in a
v0.1.1 patch release would muddy the Phase 1 / Phase 2 contract that
v0.1.0 ratified. Personal/family-scale load makes the deferral safe
at v0.1.

Changes (6 files, +165 / -0):

- `docs/adr/0005-cache-cross-provider.md` — Amendment 8 prepended
  (133 lines).
- `docs/v1x-roadmap.md` — NEW file (148 lines).
- `lib/cache/store.mjs` — getOrCompute JSDoc gains TODO block (8 lines).
- `server.mjs` — streaming-branch entry gains TODO block (6 lines).
- `README.md` — Known limitations section (9 lines).
- `CHANGELOG.md` — D42 sub-entry under Unreleased (9 lines).

No code-behavior change. No new tests. No package.json bump
(phase_rolling_mode).

Authority:
- ADR 0005 Amendment 8 (this commit) — design ratification
- ADR 0005 Amendment 6 (D34) — original deferral with "design ADR
  needed" note that this commit fulfils
- GitHub issue #16 (round-6 F13) — sibling TOCTOU; STAYS OPEN
- ADR 0002 Amendment 6 (D38) — tryAcquireSpawn semantics
- ADR 0004 Amendment 5 (D40) — observability pattern extension
- CC 开发铁律 v1.6 § 10.x — design-only amendment; fresh-context
  reviewer not required per Iron Rule 10 implementation-phase scope
- CLAUDE.md release_kit_overlay phase_rolling_mode — under Unreleased

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:58:45 +10:00
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 a718d22900 ci: D37 — release.yml phase_rolling_mode gate (issue #17)
Round-6 F14: release.yml verified package.json/tag version match and
extracted the matching CHANGELOG section, but did not verify that
"## Unreleased" had been promoted to "## v<version>" before the tag
push. If someone tagged v0.1.0-bootstrap today, release.yml would
extract the stale ## v0.1.0-bootstrap section, ignoring all D-day
work folded under Unreleased — the exact failure mode documented in
MEMORY.md 2026-04-21 ("release.yml would publish stale notes that
ignored Unreleased amendments").

Option A applied: CI gate makes the policy enforceable. Manual
checklist (Option B) was rejected because it relies on human memory,
which is what produced the original failure.

New step "Enforce phase_rolling_mode (Unreleased must be promoted)"
runs after version-match check and before CHANGELOG extraction:

1. Extracts content between "## Unreleased" heading and the next
   "## " heading via awk.
2. Strips blank lines and parenthetical-sentinel lines via sed
   (acceptable forms: "(empty — Phase N entries land here once
   Phase N opens)", "(another sentinel)", multi-sentinel blocks).
3. If any non-trivial content remains, exits with ::error::
   instructing the maintainer to promote Unreleased → ## v<version>
   per CLAUDE.md release_kit.phase_rolling_mode.

Locally dry-run against 4 cases:
- Current CHANGELOG.md (sentinel-only Unreleased) → PASS
- Synthetic CHANGELOG with bullet-list under Unreleased → gate FIRES,
  reports offending lines indented for readability
- Synthetic CHANGELOG with no Unreleased section → PASS
- Synthetic CHANGELOG with multiple parenthetical sentinels and
  intervening blank lines → PASS

Authority:
- CLAUDE.md release_kit.phase_rolling_mode (D33 F11 added the policy;
  this gate enforces it)
- MEMORY.md 2026-04-21 OCP cross-machine sync entry (documents the
  same class of failure as a prior precedent)
- Round-6 cold-audit Finding 14

Gate is purely additive — adds a check, does not modify existing
release behavior. Fires only on tag push to v*.*.* — does not affect
normal push/PR CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:41:45 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 20:38:16 +10:00
taodengandClaude Opus 4.7 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>
2026-05-24 20:20:49 +10:00
28 changed files with 6403 additions and 115 deletions
-2
View File
@@ -5,7 +5,6 @@ on:
paths: paths:
- 'server.mjs' - 'server.mjs'
- 'lib/**' - 'lib/**'
- 'scripts/**'
- 'models-registry.json' - 'models-registry.json'
- '.github/workflows/alignment.yml' - '.github/workflows/alignment.yml'
push: push:
@@ -13,7 +12,6 @@ on:
paths: paths:
- 'server.mjs' - 'server.mjs'
- 'lib/**' - 'lib/**'
- 'scripts/**'
- 'models-registry.json' - 'models-registry.json'
- '.github/workflows/alignment.yml' - '.github/workflows/alignment.yml'
+38
View File
@@ -36,6 +36,44 @@ jobs:
fi fi
echo "Tag v${TAG_VERSION} matches package.json version ${PKG_VERSION}." echo "Tag v${TAG_VERSION} matches package.json version ${PKG_VERSION}."
- name: Enforce phase_rolling_mode (Unreleased must be promoted)
shell: bash
run: |
set -euo pipefail
if [ ! -f CHANGELOG.md ]; then
echo "::warning::CHANGELOG.md not found; skipping phase_rolling_mode gate."
exit 0
fi
# Per CLAUDE.md release_kit.phase_rolling_mode: a Phase-close PR must
# promote "## Unreleased" → "## v<version>" before the tag is pushed.
# This gate catches the failure mode where someone tags without
# promoting — release.yml would otherwise extract a stale
# "## v<version>" section and ignore D-day work folded into Unreleased.
#
# An "Unreleased" section is considered trivial (acceptable) when its
# body is empty or contains only blank lines and parenthetical sentinels
# like "(empty — Phase N entries land here once Phase N opens)". Any
# other line (bullet, paragraph, sub-heading) is treated as unpromoted
# content → the gate fires.
UNRELEASED_BODY="$(awk '
/^## Unreleased$/ { found=1; next }
found && /^## / { exit }
found { print }
' CHANGELOG.md)"
if [ -z "$UNRELEASED_BODY" ]; then
echo "No ## Unreleased section found — gate passes."
exit 0
fi
# Strip blank lines and parenthetical-sentinel-only lines.
NON_TRIVIAL="$(printf '%s\n' "$UNRELEASED_BODY" \
| sed -E '/^[[:space:]]*$/d; /^[[:space:]]*\(.*\)[[:space:]]*$/d')"
if [ -n "$NON_TRIVIAL" ]; then
echo "::error::CHANGELOG.md ## Unreleased section is non-trivial but tag v${{ steps.ver.outputs.version }} was pushed. Per CLAUDE.md release_kit.phase_rolling_mode, promote Unreleased → ## v<version> before tagging. Offending content:"
printf '%s\n' "$NON_TRIVIAL" | sed 's/^/ /'
exit 1
fi
echo "## Unreleased section is empty or sentinel-only — gate passes."
- name: Extract CHANGELOG section - name: Extract CHANGELOG section
id: notes id: notes
shell: bash shell: bash
+4 -2
View File
@@ -37,7 +37,9 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003. - `lib/ir/` — Intermediate Representation definition + serializers. Governed by ADR 0003.
- `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005. - `lib/cache/` — content-addressed cache layer (per-key isolation, `cache_control` bypass, chunked stream replay, singleflight). Governed by ADR 0005.
- `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004. - `lib/fallback/` — fallback engine (trigger detection, chain advancement, idempotent-failure safety, header annotation). Governed by ADR 0004.
- `lib/keys.mjs` — multi-key auth, per-key namespacing, audit log. Carries OCP's per-key isolation model into OLP. **📋 Planned (Phase 2) — not yet authored.** - `lib/keys.mjs` — multi-key auth, per-key namespacing, identity layer. Carries OCP's per-key isolation model into OLP. ** Phase 2 — D44 core + D45 server integration + D46 owner gating shipped (validateKey on every /v1/* + /health; chain filtered by providers_enabled; touchLastUsed fires post-response; /health payload trimmed for non-owner; X-OLP-Fallback-Detail gated by fallback_detail_header_policy).**
- `bin/olp-keys.mjs` — keygen CLI bootstrap surface per ADR 0007 § 9.1. **✅ Shipped at D47.** Subcommands: `keygen [--owner|--name=X|--providers=csv|--force]`, `list [--owner-only|--include-revoked]`, `revoke --id=X`. Plaintext token printed once on keygen. Installed via `package.json bin` so `npx olp-keys ...` works (also `npm run olp-keys ...`).
- `lib/audit.mjs` — append-only ndjson audit per ADR 0007 § 6.2 + § 8. **🟡 D45 — appendAuditEvent + getAuditDropCount shipped. Fires per /v1/chat/completions + /v1/models request including 401/403/5xx paths. Warn+1-retry on append failure; no memory buffer at Phase 2 (forward path).**
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.** - `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — not yet authored.**
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT. - `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change. - `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
@@ -45,7 +47,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens. - `.github/workflows/alignment.yml` — CI blacklist grep + per-provider citation soft check; fails the build on known-hallucinated tokens.
- `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5). - `CLAUDE.md` — Claude-Code-specific session instructions + `release_kit` overlay (Iron Rule 5.5).
**Implementation status note (as of 2026-05-24):** Files marked 📋 above are designed and documented but not yet on disk. For the full status table see `README.md § "Implementation status"`. Do not attempt to read or import these files — they will not be found. The shipped set as of Phase 1 is: `server.mjs`, `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `models-registry.json`, `test-features.mjs`. **Implementation status note (as of 2026-05-25):** Files marked 📋 above are designed and documented but not yet on disk; files marked 🟡 are partially shipped; files marked ✅ are Phase 2 deliverables. The shipped set as of D47 is: `server.mjs` (with Phase 2 auth middleware + audit wire + owner-vs-non-owner gating), `lib/ir/`, `lib/providers/{anthropic,codex,mistral}.mjs`, `lib/cache/{keys,store}.mjs`, `lib/fallback/engine.mjs`, `lib/keys.mjs` (core + loadAuthConfigSync — D44 + D45), `lib/audit.mjs` (D45), `bin/olp-keys.mjs` (D47), `models-registry.json`, `test-features.mjs` (Suites 1922). Phase 2 functional scope is complete; remaining is Phase 2 close → v0.2.0 (maintainer-triggered, explicit per CLAUDE.md `release_kit.phase_close_trigger`).
--- ---
+14 -2
View File
@@ -56,7 +56,7 @@ A plugin satisfying all five conditions is a **Speculative-Candidate**. It is Ru
| Plugin file | Phase | Labelled UNPINNED assumptions | | Plugin file | Phase | Labelled UNPINNED assumptions |
|---|---|---| |---|---|---|
| `lib/providers/codex.mjs` (D6) | Phase 2 | A3 (auth token field name), A4 (NDJSON event schema) | | `lib/providers/codex.mjs` (D6) | Phase 2 | A3 (auth token field name), A4 (NDJSON event schema) |
| `lib/providers/mistral.mjs` (D8) | Phase 3 | A4 (JSON output event schema), A5 (model flag), A6 (exact model IDs), A7 (streaming vs json output mode), A8 (stdin prompt passing) | | `lib/providers/mistral.mjs` (D8) | Phase 3 | A4 (JSON output event schema), A6 (exact model IDs), A7 (streaming vs json output mode), A8 (stdin prompt passing) |
The Anthropic plugin (`lib/providers/anthropic.mjs`, D4D5) is NOT in this class — its CLI authority is pinned (`@anthropic-ai/claude-code` v2.1.89 per the Provider Authority Pins table above). It conforms to the standard Rule 4 path. The Anthropic plugin (`lib/providers/anthropic.mjs`, D4D5) is NOT in this class — its CLI authority is pinned (`@anthropic-ai/claude-code` v2.1.89 per the Provider Authority Pins table above). It conforms to the standard Rule 4 path.
@@ -76,7 +76,7 @@ Each provider plugin in `lib/providers/<name>.mjs` is governed by the underlying
| Provider key | Provider CLI | Audit pin (TBD on Phase-1 spawn) | Risk Tier (see § Risk Tier Framework) | | Provider key | Provider CLI | Audit pin (TBD on Phase-1 spawn) | Risk Tier (see § Risk Tier Framework) |
|---|---|---|---| |---|---|---|---|
| `anthropic` | `claude -p` from `@anthropic-ai/claude-code` | inherits OCP's `cli.js` 2.1.89 audit pin at fork; OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 — `lib/providers/anthropic.mjs` header). Re-evaluate post-2026-06-15 per One-shot Triggered Audit above. | D (pre-2026-06-15) / re-evaluate post-2026-06-15 | | `anthropic` | `claude -p` from `@anthropic-ai/claude-code` | inherits OCP's `cli.js` 2.1.89 audit pin at fork; OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 — `lib/providers/anthropic.mjs` header); transcript artifact: `docs/provider-audits/anthropic.md` (captured 2026-05-24). Re-evaluate post-2026-06-15 per One-shot Triggered Audit above. | D (pre-2026-06-15) / re-evaluate post-2026-06-15 |
| `openai` | `codex exec --json` from OpenAI Codex CLI | Codex CLI reference page: https://developers.openai.com/codex/cli/reference (retrieved 2026-05-23 — §§ "codex exec [flags] PROMPT", "--json / --experimental-json", "--model, -m"; D6 WebFetch-verified reachable). Secondary authority: https://developers.openai.com/codex/cli/features §§ "Supported Models", "Automation". | D | | `openai` | `codex exec --json` from OpenAI Codex CLI | Codex CLI reference page: https://developers.openai.com/codex/cli/reference (retrieved 2026-05-23 — §§ "codex exec [flags] PROMPT", "--json / --experimental-json", "--model, -m"; D6 WebFetch-verified reachable). Secondary authority: https://developers.openai.com/codex/cli/features §§ "Supported Models", "Automation". | D |
| `mistral` | `vibe --prompt --output streaming` from Mistral Vibe CLI | Mistral Vibe terminal quickstart: https://docs.mistral.ai/mistral-vibe/terminal/quickstart (retrieved 2026-05-23 — § "--prompt flag triggers programmatic mode; --output selects format (text, json, streaming)"; D8 WebFetch-verified reachable). `--output streaming` selected (not `--output json`) because DOCS-1 § "Output Format Options" explicitly states `json` emits a single blob at the end — incompatible with the line-buffered NDJSON parser in `lib/providers/mistral.mjs`. `streaming` emits newline-delimited JSON per message, which the parser requires. See plugin header (lines 360-369). Configuration authority: https://docs.mistral.ai/mistral-vibe/terminal/configuration (§§ auth file `~/.vibe/.env`, `MISTRAL_API_KEY` env var). | D | | `mistral` | `vibe --prompt --output streaming` from Mistral Vibe CLI | Mistral Vibe terminal quickstart: https://docs.mistral.ai/mistral-vibe/terminal/quickstart (retrieved 2026-05-23 — § "--prompt flag triggers programmatic mode; --output selects format (text, json, streaming)"; D8 WebFetch-verified reachable). `--output streaming` selected (not `--output json`) because DOCS-1 § "Output Format Options" explicitly states `json` emits a single blob at the end — incompatible with the line-buffered NDJSON parser in `lib/providers/mistral.mjs`. `streaming` emits newline-delimited JSON per message, which the parser requires. See plugin header (lines 360-369). Configuration authority: https://docs.mistral.ai/mistral-vibe/terminal/configuration (§§ auth file `~/.vibe/.env`, `MISTRAL_API_KEY` env var). | D |
| `grok` | `grok -p --output-format streaming-json` (xAI Build) | TBD at Phase 8+ enable | C | | `grok` | `grok -p --output-format streaming-json` (xAI Build) | TBD at Phase 8+ enable | C |
@@ -124,6 +124,8 @@ OLP distinguishes **Candidate Providers** (declared in this constitution as inte
The v0.1 founding commit ships **zero Enabled Providers**. This is intentional: a constitution that names a provider as "default-enabled" while its CLI version, output shape, auth artifact, and exit-code semantics are still TBD violates Rules 1 (Cite First) and 3 (Match the Implementation). Enablement is a Phase audit deliverable, not a bootstrap claim. The v0.1 founding commit ships **zero Enabled Providers**. This is intentional: a constitution that names a provider as "default-enabled" while its CLI version, output shape, auth artifact, and exit-code semantics are still TBD violates Rules 1 (Cite First) and 3 (Match the Implementation). Enablement is a Phase audit deliverable, not a bootstrap claim.
**Note on phase terminology.** "Phase" in the tables and audit triggers below (and in § One-shot Triggered Audits → "OpenAI Codex ToS formal pin") refers to the **original per-plugin enablement plan** captured at project founding (one Tier-D plugin enabled per phase). The milestone phase numbering in [`README.md` § Phase plan](./README.md#phase-plan) was re-aligned at v0.1.1 close (D43-A, 2026-05-25) to reflect actually-shipped bundling — Phase 1 shipped all three Tier-D plugins + cache + fallback together as a single milestone, and Phase 2 became multi-key auth per ADR 0007. The two numberings are orthogonal: ALIGNMENT.md tracks **per-plugin enablement maturity**; README tracks **milestone release scope**.
### Enabled Providers ### Enabled Providers
| Provider key | Tier | Default state | Authority pin | Inclusion source | | Provider key | Tier | Default state | Authority pin | Inclusion source |
@@ -198,6 +200,16 @@ In addition to the recurring 14 May audit below, the following one-shot audits a
Any future Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale. Any future Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale.
### Controlled deviations (entry-surface scope)
This subsection enumerates entry-surface behaviours that intentionally extend beyond the OpenAI `/v1/chat/completions` and `/v1/models` specifications. Each entry is a **controlled deviation**: a documented, reviewed extension that ships under Rule 2(b)'s spirit (no invention without an authority) by treating `docs/openai-spec-pin.md` as the formal contract for the deviation. The contract there is binding; this list is the index.
1. **`/v1/models` alias entries** — *Issue #13 (D36)*. The OpenAI `/v1/models` specification (https://platform.openai.com/docs/api-reference/models/list) enumerates one entry per canonical model ID. OLP's `/v1/models` response additionally surfaces alias entries (e.g. `claude`, `sonnet`, `opus`, `haiku` alongside the canonical `claude-opus-4-7` / `claude-sonnet-4-6` / `claude-haiku-4-5`). Alias entries use `id: <alias-string>`, `object: 'model'`, `owned_by: <same provider key as canonical>`, and `created: <same timestamp as canonical target>` per D27 F15.
- **Rationale:** D27 F15 — onboarding friction when clients configured with `model: 'sonnet'` (a common alias used by Anthropic's own CLI and many OpenClaw-class tools) received an empty `/v1/models` response that did not surface the alias as a callable model id. Surfacing the alias makes the discovery loop usable for OpenAI-compatible clients with alias-aware UX.
- **Formal contract:** `docs/openai-spec-pin.md § GET /v1/models` is the authoritative shape for this deviation. The deviation is bounded by: (a) `owned_by` matches the canonical target's `owned_by`; (b) `created` matches the canonical target's `created`; (c) no fields are invented beyond the four OpenAI-spec entry fields (`id`, `object`, `created`, `owned_by`); (d) alias enumeration is sourced from `models-registry.json` via `getAliasMap()` — the SPOT — not hard-coded in `server.mjs`.
- **Compliance posture:** The deviation extends the response listing but does not invent fields or change field semantics. The risk vector is a hypothetical OpenAI-compatible client that asserts "one entry per canonical model" and trips on the extras; this risk is mitigated by the fact that aliases use the same `object: 'model'` shape, and any client iterating `data[]` simply sees more entries — none of which are malformed. No invention beyond what OpenAI's own `id` field already accepts as a free-form string.
- **Re-evaluation trigger:** Annual audit (14 May) re-checks whether OpenAI has shipped a formal alias-listing extension to `/v1/models` (in which case OLP migrates to it), or whether the deviation should be retired (in which case clients with alias-aware UX must migrate to the canonical IDs via the alias table).
--- ---
## Amendment Procedure ## Amendment Procedure
+287 -1
View File
@@ -4,7 +4,293 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased ## Unreleased
(empty — Phase 2 entries land here once Phase 2 opens) (empty — Phase 3 entries land here once Phase 3 opens)
## v0.2.0 — 2026-05-25
### Phase 2 — Multi-key auth + audit + owner gating + keygen CLI (D43-A → D47)
**Overview.** v0.2.0 closes Phase 2 — the multi-key authentication track that grew OLP from single-tenant anonymous-only proxy (v0.1.1) to a multi-identity deployment with per-key cache isolation, audit attribution, owner-vs-guest header gating, and a reproducible bootstrap CLI. 6 D-day commits (D43-A through D47) shipped between 2026-05-25 (single intensive session under the standing-autopilot grant). All 11 ADR 0007 § 10 acceptance criteria are implemented + tested.
**Test count: 468 (v0.1.1) → 544 (v0.2.0).** +76 tests across the Phase 2 arc.
**Phase 2 release_kit checklist**
- [x] All 6 D-day deliverables landed on main (D43-A, D43-B ADR draft, D44, D45, D46, D47)
- [x] CI green on every D-day merge commit + on this release commit's head
- [x] Fresh-context opus reviewer on every implementation D-day (D44/D45/D46/D47), maintainer text-review on D43-B ADR
- [x] All 11 ADR 0007 § 10 acceptance criteria (#1#11) covered by Suite 19/20/21/22 tests
- [x] CHANGELOG "Unreleased" promoted to "## v0.2.0 — 2026-05-25" with D43-A through D47 entries
- [x] `package.json` bumped 0.1.1 → 0.2.0
- [x] `CLAUDE.md release_kit.phase_rolling_mode`: `current_phase` Phase 2 → Phase 3; `current_pre_release_identifier` `0.2.0-phase2``0.3.0-phase3`
- [x] README status header + Implementation Status + Phase plan reflect Phase 2 shipped
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push; D37 phase_rolling_mode gate will pass because Unreleased is now sentinel-only)
**ADR 0007 § 10 acceptance criteria — final ship status**
| # | Criterion | Covering tests |
|---|---|---|
| 1 | Per-key cache namespace isolation | Suite 20i |
| 2 | Anonymous prod-default off → 401 | Suite 20a |
| 3 | Anonymous dev-mode on → 200 | Suite 20g |
| 4 | Owner-vs-guest `/health` gating | Suite 21a-d |
| 5 | Owner-vs-guest `X-OLP-Fallback-Detail` gating | Suite 21e-h |
| 6 | Post-revoke 401 within next request | Suite 19o + Suite 20e |
| 7 | Manifest atomicity + revoke-dominates-touch | Suite 19y-1..4 |
| 8 | Audit ndjson round-trip + PII guard | Suite 20j + 20j-stream + 20j-401 |
| 9 | Bootstrap keygen surface reproducible | Suite 22 |
| 10 | `OLP_OWNER_TOKEN` env override | Suite 19p + Suite 20f |
| 11 | `providers_enabled` 403 scope enforcement | Suite 20h |
**Known limitations carried beyond v0.2.0**
Phase 2 functional scope is complete. The following remain as Phase 3+ deferrals (tracked in `docs/v1x-roadmap.md` + new entries below):
- **Dashboard (`dashboard.html`)** — owner-only multi-provider quota / fallback / cache-hit-rate panels. Per ADR 0007 § 12 + v0.1 spec § 4.6. Phase 3 mainline.
- **Audit query layer + rotation** — `audit.ndjson` is append-only at v0.2.0; aggregate queries + log rotation deferred to Phase 3 alongside Dashboard.
- **`tried_providers` semantics on `key_no_provider_access` 403** — schema currently reports filter-rejected hops as "tried"; either ADR § 8 amendment (rename / add field) or D46+ semantic fix. Noted by D45 opus reviewer.
- **Per-provider per-key auth artifact mapping** — ADR § 12 explicit out-of-scope. Per-key cache + audit isolation works; per-key per-provider OAuth tokens (e.g., two OLP keys each authenticated to different OpenAI Codex accounts) is Phase 3+ work.
- **SQLite migration (Option 3 hybrid)** — ADR § 13 documents the forward path; trigger is Dashboard / SQL-aggregate-quota / multi-second audit-query workload. Requires engines bump (`>=22.13.0` or `>=23.4.0`) per ADR § 11 as a separate prior PR.
### D47 — `bin/olp-keys.mjs` keygen CLI (Phase 2 functional scope closes)
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 file `bin/olp-keys.mjs`** (~250 lines): subcommand CLI with three subcommands:
- `keygen [--owner] [--name=<label>] [--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 (recovery flow per ADR § 9.3). `--providers=*` (default) or comma-separated allowlist.
- `list [--owner-only] [--include-revoked]` — lists keys with `token_hash` redacted (lib/keys.mjs `listKeys` already redacts).
- `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 or `~/.olp/`.
- **`package.json` `bin` field**: `olp-keys``./bin/olp-keys.mjs` so `npx olp-keys ...` resolves; also `npm run olp-keys ...` via scripts.
- **Module shape**: CLI exposes `runCli(argv, { out, err })` so tests can invoke it with synthetic argv + IO writers (no process spawn). Main guard auto-runs when invoked as entrypoint.
- **Plaintext token discipline**: per ADR § 5 + § 9.1, plaintext is printed exactly once on stdout. Never logged, never written to manifest, never written to audit. Operators must capture immediately; lost → `--force` revoke + regenerate.
- **`--force` async correctness**: `cmdKeygen` is async and `await`s 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 the race where create-new runs before revoke-old completes.
- **Test surface (Suite 22, +20 tests — 524 → 544):**
- 22a-1..5: parseArgv unit tests (`--flag=value`, `--flag value`, boolean, mixed positional)
- 22b-1..5: keygen subcommand (owner default, name+providers, missing-name error, invalid-tier error, --force revoke-then-create flow with isolation tmpdir)
- 22c-1..3: list subcommand (empty, populated with token_hash-redaction check, --owner-only filter)
- 22d-1..4: revoke subcommand (valid id, idempotent re-revoke, missing-id error, nonexistent-id error)
- 22e-1..3: top-level CLI behaviour (--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 table gains `bin/olp-keys.mjs` row; Known limitations note updated to "Phase 2 functional scope complete; close pending"; new "Bootstrap workflow" section with copy-pasteable npx commands + recovery flow.
- **Test count:** 524 → 544 (+20 D47 tests in Suite 22).
- **Authority:** ADR 0007 (multi-key auth — § 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.
### D46 — owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (Phase 2 closes header observability gap)
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 is 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 ADR § 7.1:
- Auth gate at top — `authenticate(req)` returns 401 for unauth + `allow_anonymous: false` (consistent with /v1/* routes); 200 with trimmed payload for anonymous / guest; 200 with full payload for owner.
- Trim controlled by `_authConfig.owner_only_endpoints` — if `/health` is in the list, non-owner gets `{ ok: true, version }`; else (operator removes it) full payload to everyone (v0.1.1 opt-out knob).
- `touchLastUsed` fired on `res.on('finish')` for filesystem identities (matches /v1/* pattern). No audit row on /health — high-volume monitoring endpoint, audit volume noise not justified at Phase 2 (would land with Phase 3 Dashboard if aggregate /health stats become needed).
- **`server.mjs` `withFallbackDetailHeader` identity-aware emission** per ADR § 7.2:
- New helper `shouldEmitFallbackDetailHeader(olpIdentity)` reads `_authConfig.fallback_detail_header_policy`:
- `'owner_only'` (default) → emit only when `olpIdentity.owner_tier === 'owner'`
- `'all'` → emit unconditionally (v0.1.1 opt-back-in for operators who want the diagnostic header for all identities)
- `'none'` → suppress unconditionally
- When `olpIdentity` is null (pre-auth error paths), defaults to emit — preserves the v0.1.1 ungated behaviour for pre-auth errors where identity is unknown.
- `withFallbackDetailHeader` signature gains a third `olpIdentity` argument; both call sites in `handleChatCompletions` updated to pass `olpIdentity`.
- **Test surface (Suite 21, +9 tests + 1 added in Suite 20 — 515 → 524):**
- **20m** /health with no auth + `allow_anonymous=false` → 401 (consistency with /v1/* routes)
- **21a-d** /health payload trimming (criterion #4): anonymous trimmed; guest trimmed; owner full; `owner_only_endpoints: []` opts out (guest gets full)
- **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 a 2-hop chain (anthropic primary fail + openai secondary) to produce non-empty `fallbackDetail` for the header content.
- **Test-mode setup updated:** the global `__setAuthConfig({ allow_anonymous: true })` was 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 without modification — Suite 21 explicitly overrides per-case to exercise the production-default-gated paths.
- **Documentation:** AGENTS.md `lib/keys.mjs` marker updated to reflect D46 ship; Implementation-status-note updated. README.md Implementation Status row + Known limitations "Multi-key auth" note rewritten to reflect D46 ship + remaining keygen CLI.
- **Test count:** 515 → 524 (+9 D46 tests).
- **Authority:** ADR 0007 (multi-key auth — §§ 7.1 + 7.2 implementation contracts + § 10 acceptance criteria #4 + #5 covered); ADR 0004 Amendment 5 (D40 ratification of "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`).
### 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 acceptance criteria #1 (per-key cache isolation, validation-side end-to-end), #2 (anonymous prod-default off), #3 (anonymous dev-mode on), #6 (post-revoke 401 within next request — full), #8 (audit ndjson round-trip), #10 (`OLP_OWNER_TOKEN` env override — full server-side), #11 (`providers_enabled` 403 scope). Owner-vs-guest gating for `/health` + `X-OLP-Fallback-Detail` (criteria #4, #5) remains in D46 scope.
- **New file `lib/audit.mjs`** (~75 lines): `appendAuditEvent(event, opts)` writes one JSON event per line to `~/.olp/logs/audit.ndjson` (file 0600, dir 0700). § 6.2 retry semantics: warn + 1 retry on first failure; per-process drop counter + warn on second failure; NEVER throws. Per-call `OLP_HOME` env resolution (matches `lib/keys.mjs`). Exports `getAuditDropCount` for future /health surface.
- **`lib/keys.mjs`** extended with `loadAuthConfigSync({ olpHome })` reading the `auth` block from `~/.olp/config.json` with defaults per ADR § 7.2 (`allow_anonymous: false`, `owner_only_endpoints: ['/health']`, `fallback_detail_header_policy: 'owner_only'`). Both `lib/keys.mjs` + `lib/audit.mjs` now resolve `OLP_HOME` env per call (precedence: opts.olpHome → process.env.OLP_HOME → ~/.olp) so tests and operator deployments can redirect without code edits.
- **`server.mjs` auth middleware integration:**
- `extractToken(req)` parses `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
- `authenticate(req)` calls `validateKey(token, { allowAnonymous: _authConfig.allow_anonymous })`; returns identity on success, 401 `{ auth_required | invalid_or_revoked_key }` on failure.
- `isProviderEnabled(olpIdentity, providerKey)` enforces `providers_enabled` allowlist (`'*'` = all).
- `_authConfig` loaded at startup; warn `auth_allow_anonymous_enabled` fires if `allow_anonymous: true` so the relaxed posture is visible. Test seams `__setAuthConfig` / `__resetAuthConfig`.
- `handleChatCompletions` and `handleModels` both gated by `authenticate(req)` at top. Audit ctx object built throughout the handler lifecycle; `res.on('finish')` appends the row + fires `touchLastUsed` async (best-effort).
- **Identity-vs-credentials separation:** `olpIdentity` (the new validated identity) is 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+ scope per ADR § 12.
- `handleChatCompletions` chain filtered by `chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider))`; empty result returns 403 `key_no_provider_access` with helpful diagnostic message.
- `keyId = olpIdentity.keyId` (replacing hardcoded `'__anonymous__'` at the cache call sites).
- Audit captures fields throughout: post-auth (key_id, owner_tier); post-IR (model); post-chain-success (provider, fallback_hops, tried_providers, cache_status); post-chain-exhausted (error_code, providerUsed=chain[0], cache_status='miss'). Status code + latency populated on `res.on('finish')`.
- **Test surface (Suite 20, +15 tests, 499 → 514):**
- 20a-d: header parsing + valid key happy paths (Bearer / x-api-key / invalid → 401)
- 20e: revoked key 401 (closes criterion #6 end-to-end)
- 20f: `OLP_OWNER_TOKEN` env override returns 200 (criterion #10 full coverage)
- 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 — keys A/B with identical payload do not share cache (criterion #1 end-to-end)
- 20j + 20j-401: audit.ndjson written with § 8 schema fields including PII guard; 401 path also appends (criterion #8)
- 20k: filesystem key `last_used_at` populated after first successful request (D45 touchLastUsed wire)
- 20l + 20l-200: `/v1/models` also enforces auth (consistent gating across `/v1/*`)
- **Test-mode setup:** test-features.mjs sets `process.env.OLP_HOME` to a tmpdir at module load so audit + key writes don't pollute `~/.olp/`. After the server.mjs import resolves, calls `__setAuthConfig({ allow_anonymous: true })` so pre-D45 HTTP integration tests (Suite 18 etc.) that don't pass an Authorization header continue to pass as anonymous; Suite 20 explicitly overrides per-case for production-default-off coverage.
- **Documentation:** AGENTS.md `lib/keys.mjs` 🟡 marker updated + new `lib/audit.mjs` entry; AGENTS.md 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 and config block surfaced for users.
- **Test count:** 499 → 515 (+15 initial Suite 20 tests + 1 fold-in regression test `20j-stream` covering opus-P1 streaming audit-fidelity).
- **Fold-in (CI-fail recovery + fresh-context opus reviewer findings, 1 CI + 1 P1 + 2 P2 + 1 P3):**
- **CI Node 24 failure** — Suite 20 setup did not stub `CLAUDE_CODE_OAUTH_TOKEN` before the mock spawn ran; lib/providers/anthropic.mjs `_spawnAndStream` checks for an OAuth token BEFORE invoking the (mock) spawn, so the AUTH_MISSING pre-check fired and every Suite 20 200-expecting test 502'd on CI Node 24 (local Node 22 had the env from the maintainer's claude install). Fixed by `ensureSuite20FakeOAuth` / `restoreSuite20OAuth` helpers in `makeSuite20Server` / `teardownSuite20`; matches the existing pattern used at Suite 9 line ~2154 (`test-fake-oauth-token-for-cache-tests`).
- **P1 real-streaming audit fidelity** — single-hop streaming success path (server.mjs ~L1050+ `if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop ...)`) did not populate `auditCtx.provider` / `tried_providers` / `cache_status`, so audit rows for the most common deployed shape carried `provider: null`. Fixed by stamping these fields at the top of the streaming branch (between the streamPlugin null-check and the `streamHeaders` build) 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` makes a streaming request and 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 module load left a `/var/folders/.../olp-test-home-*` directory leak per `npm test` run. Fixed by `process.on('exit', () => rmSync(...))` registered immediately after the mkdtempSync. Best-effort; never throws at exit.
- **P3 handleModels 401 lacks OLP diagnostic headers** — `handleChatCompletions` 401 path passes `olpErrorHeaders({ startMs })` but `handleModels` did not. Aligned by adding the same headers to the `handleModels` `authResult.ok=false` return.
- **Deferred (acknowledged by reviewer as non-blocking):** P2 `tried_providers` semantics on `key_no_provider_access` 403 — schema currently reports filter-rejected hops as "tried" which a downstream Dashboard would misread; either ADR § 8 amendment (rename / add field) or D46+ semantic fix.
- **Authority:** ADR 0007 (multi-key auth — §§ 5/6.2/7/9.4 implementation contracts + § 10 acceptance criteria #1/#2/#3/#6/#8/#10/#11); 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`).
### 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 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`** (~462 lines after fold-in) — public API surface:
- `createKey({ name, owner_tier, providers_enabled, notes, olpHome })` — generates opaque `olp_<32-byte base64url>` token (47-char total), SHA-256 hashes it for manifest storage, atomically writes `keys/<id>/manifest.json` (mode 0600, dir 0700). Returns `{ id, plaintext_token, manifest }` — plaintext token is printed once and never persisted.
- `validateKey(plaintext, { allowAnonymous, olpHome })` — three-tier resolution per § 5 / § 7 / § 9.4: env override (`OLP_OWNER_TOKEN``__env_owner__` synthetic identity) → anonymous (only when `allowAnonymous: true`, returns `__anonymous__` identity) → filesystem manifest lookup (constant-time hash compare via `crypto.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 write-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 the 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.
- **§ 6.4 in-process per-key write-lock** — `Map<key-id, Promise>` chain; serializes intra-process writes. External (CLI) writes not lock-protected at Phase 2; atomic-rename + § 6.3 read-before-write give the `revoke dominates touch` safety property.
- **Test-only hooks** — `__setTouchInterleaveHook` (inject deterministic pause between touch's lock acquisition and read for race tests) + `__resetWriteLocks` (test cleanup).
- **What is NOT in D44 (split per ADR §§ 6.2 / 9.1 separation):** audit ndjson append (request-layer concern; D45 server glue); keygen CLI bootstrap surface (D45+); `server.mjs` integration replacing the hardcoded `'__anonymous__'` keyId at `server.mjs:502, :531` (D45); owner-vs-guest gating for `/health` and `X-OLP-Fallback-Detail` (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)**: revoke→touch, touch→revoke, interleaved external-revoke-via-hook (deterministically reproduces the § 6.3 race the maintainer's text review caught), 30-iteration concurrent-promise stress
- **Documentation:** AGENTS.md `lib/keys.mjs` 📋 marker → 🟡 "core landed at D44"; AGENTS.md Implementation-status-note + shipped-set updated to include `lib/keys.mjs`; README.md Implementation Status row + Known limitations "Multi-key auth" note updated to "core landed, server integration pending D45".
- **Fold-in (fresh-context opus reviewer findings, 2 P2 correctness + 2 P3 polish):**
- **P2 #1 lock-map cleanup** (`lib/keys.mjs` `_withKeyLock`): 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 — Map entries leaked one-per-unique-key-id. Bounded impact at family scale (~510 entries) but a real correctness bug. Fixed by storing `next` directly. New regression tests `19x-extra` (sequential) + `19x-extra-2` (concurrent 3-key × 3-touch contention) assert `__writeLockSize() === 0` post-drain.
- **P2 #2 `validateKey` non-string defensive coding**: prior version threw `TypeError` when called with a non-string truthy plaintext (`validateKey(42)` / `validateKey({})`), reaching `hashToken(<non-string>)``createHash().update(<non-string>)`. Q2 promised "bad inputs return null." Fixed via top-of-function `if (plaintextToken != null && typeof plaintextToken !== 'string') return null;`. New test `19m-extra` covers number / object / array / `allowAnonymous: true` paths.
- **P3 #3 19y-3 test scope comment**: test simulates external revoke landing BEFORE touch's read, not BETWEEN touch's read and write (which is currently unreachable because `touchLastUsed` has synchronous read→write — no await between `readManifest` and `writeManifestAtomic`). Added explanatory comment documenting the synchronous-read-write property as the satisfaction mechanism for ADR § 10 criterion #7 scenario 3, with a note that a post-read hook + matching test would be required if a future refactor introduces an await between read and write.
- **P3 #4 CHANGELOG line count**: corrected `~330 lines` to `~462 lines after fold-in` (matches `wc -l lib/keys.mjs`).
- **Test count after fold-in:** 468 → 499 (+31 tests: 28 initial + 3 fold-in regression tests).
- **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, 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` in cc-rules `d9da966`); PR #20 fresh-context opus reviewer findings.
### D43-B — ADR 0007 multi-key auth design draft (design-only, no code change)
Phase 2 mainline design ADR. Ratifies the storage / token / manifest / atomic-write / owner-gating / bootstrap / Node-baseline decisions ahead of D44+ implementation D-days. Pure design doc — no `.mjs` / no tests / 4 files touched.
- `docs/adr/0007-multi-key-auth.md` (new, ~420 lines after fold-ins): 13 sections covering Context / Decision (Option 2 + opaque key) / Storage layout (`~/.olp/keys/<key-id>/manifest.json` + `~/.olp/logs/audit.ndjson`) / Manifest schema (schema_version, token_hash, owner_tier, providers_enabled) / Token format (`olp_<32-byte base64url>`, SHA-256 hash) / Atomic write & audit append (manifest lifecycle-only atomic via tmpfile+fsync+rename; audit per-request append, warn + 1 retry, no memory buffer at Phase 2) / Owner-vs-guest-vs-anonymous gating (config.json `auth.allow_anonymous` default false, no env auto-detection) / Audit ndjson schema (no PII) / Bootstrap & recovery (minimal keygen command surface + `OLP_OWNER_TOKEN` env override with stable `__env_owner__` keyId) / Acceptance criteria (11 test surfaces for D44+) / Node baseline (Option 1 SQLite port rejection rationale citing `engines >=18` + CI 20/24 vs `node:sqlite` v22.5.0 with flag) / Out of scope (Dashboard, quota enforcement, audit query, file locking deferred to Phase 3+) / Future forward (Option 3 hybrid migration trigger + preconditions).
- `docs/adr/README.md` index: added ADR 0007 row with one-paragraph summary.
- `docs/v1x-roadmap.md` #2: marked **PHASE 2 ACTIVE (no longer deferred)**; "Design ADR (NOT YET RATIFIED)" → "Design ADR (ratified) → ADR 0007"; trigger updated to "already fired 2026-05-25"; code anchors pinned to exact line numbers (cache/store.mjs:77-79/:287, server.mjs:502/:531/:392/:1072/:1101).
- `CHANGELOG.md` Unreleased: this entry.
- **Fold-in #1 (fresh-context opus reviewer findings, 2 P2 + 3 P3, all polish):** § 6.2 step 1 — pin audit serialization timing to after status_code + latency_ms are known (resolves §10 #2 testability gap); new § 6.3.5 — explicit "no in-process validation cache at Phase 2" rule (resolves §10 #6 implicit-contract gap); § 6.1 — document deliberate omission of directory fsync after rename (single-process trade-off); § 9.4 — token-collision policy between `OLP_OWNER_TOKEN` and filesystem keys declared undefined behaviour; §10 #4 — test rephrased to assert against config-driven `owner_only_endpoints` rather than hardcoded payload shape.
- **Fold-in #2 (maintainer text-review findings, 1 P1 + 1 P2 + 1 P3):** § 6.3 rewritten to `last_used_at` revoke-dominates-touch semantics (P1 — fixes safety bug where lazy touch could overwrite revoke and silently clear `revoked_at`, breaking acceptance criterion #6 under concurrent CLI revoke + in-flight server request); § 6.4 reframed from "both states are valid" / "observability-grade" to "revoke dominates touch" with §6.3 as the load-bearing discipline; § 10 criterion #7 expanded to test all three orderings (revoke→touch, touch→revoke, interleaved) with explicit MUST: `revoked_at` non-null after revoke regardless of ordering; § 11 forward path step (1) corrected Node version history — minimum non-flag-gated baseline is v22.13.0 (LTS) / v23.4.0 (current), RC since v25.7.0, stable TBD (previous wording "Node v22.5.0+ for unflagged but RC" was factually wrong per https://nodejs.org/download/release/v22.12.0/docs/api/sqlite.html and https://nodejs.org/api/sqlite.html); this CHANGELOG entry line-count corrected from "~270 lines" to "~420 lines after fold-ins".
- **Test count:** 468 → 468 (design-only, no test change).
- **Authority:** Phase 2 kickoff handoff (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`); OLP v0.1 spec § 4.5 (planning authority for `~/.olp/` layout); OCP `keys.mjs` (prior-art for opaque-key + per-key isolation model); Node `node:sqlite` docs (https://nodejs.org/api/sqlite.html — Option 1 rejection rationale per ADR 0007 § 11); CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for design ADR per Iron Rule 10.
### D43-A — Phase 2 doc alignment (no code change)
Phase 1 was closed at v0.1.1; this commit aligns documentation surfaces to the Phase 2 reality before D43-B (ADR 0007 draft) lands. Pure doc cleanup; no `.mjs` or test changes.
- `CLAUDE.md release_kit.current_phase` Phase 1 → Phase 2; `current_pre_release_identifier` `0.1.0-bootstrap``0.2.0-phase2`.
- `README.md` status header + Implementation Status + Phase plan rewritten to reflect actually-shipped reality (v0.1.0 + v0.1.1 bundled the three Tier-D plugins + cache + fallback into a single Phase 1 milestone, not one phase per plugin as the original v0.1 spec planned). `lib/keys.mjs` row + "Multi-key auth not yet implemented" note updated to "Phase 2 active per ADR 0007 (drafting at D43-B)".
- `AGENTS.md` § Key files to know — `lib/keys.mjs` 📋 marker updated to "Phase 2 active per ADR 0007 (drafting at D43-B)"; Implementation-status-note paragraph dated 2026-05-25 + reflects Phase 1 close + Phase 2 active scope.
- `ALIGNMENT.md` § Provider Inventory — added one-paragraph "Note on phase terminology" clarifying that "Phase" in the Provider Inventory tables + § One-shot Triggered Audits "OpenAI Codex ToS formal pin" refers to the original per-plugin enablement plan, orthogonal to the milestone phase numbering in README. Fold-in for D43-A reviewer P2 finding; no governance-text change, no Speculative-Candidate plugin reclassification.
- **Test count:** 468 → 468 (no test change).
- **Authority:** `CLAUDE.md release_kit overlay phase_rolling_mode` — under Unreleased; Phase 2 kickoff handoff at `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`; ADR 0007 forthcoming at D43-B.
## v0.1.1 — 2026-05-25
### Phase 1 cleanup — pre-Phase-2 batch (D35D42, closes 16 of 17 issues)
**Overview.** v0.1.1 closes the post-v0.1.0 cleanup batch covering all 17 pre-Phase-2 issues raised during the 6-round cold-audit cycle on the Phase 1 deliverable. 8 D-day commits (D35D42) shipped between 2026-05-24 and 2026-05-25. 16 issues closed; issue #16 (streaming singleflight) stays OPEN as the v1.x tracker with its design ratified in ADR 0005 Amendment 8.
**Test count: 416 (v0.1.0) → 468 (v0.1.1).** +52 tests across the cleanup batch.
### D35 — pre-Phase-2 batch #1 (issues #4 #9 #10 #11 #12)
- **#4 — X-OLP-Latency-Ms uniform.** Audit confirmed already-correct via D32; D35 adds the `#4-audit` regression test pinning the 5-header invariant on the 503 no-provider sendError so future drift is caught immediately.
- **#9 — Streaming empty-then-clean-exit headers.** Zero-chunk streaming path now guards `!res.headersSent` and emits Content-Type=text/event-stream, Cache-Control=no-cache, Connection=keep-alive, X-Accel-Buffering=no, plus all 5 X-OLP-* headers via olpHeaders before writing `SSE_DONE`. Zero-chunk path correctly does NOT cache.
- **#10 — Streaming post-first-chunk error truncation marker.** Two sibling fixes: catch-block-firstChunkEmitted=true and error-chunk-after-first-chunk both now emit synthetic `{type:'stop', finish_reason:'length'}` via `irChunkToOpenAISSE` + `SSE_DONE` + `res.end()`. Per ADR 0004 § Fallback safety: post-first-chunk truncation surfaces as `length` finish, never a hang.
- **#11`validateIRRequest` irVersion strict check.** ADR 0003 IR contract pins irVersion to `'1.0'`. Validator now: `obj.irVersion !== undefined && obj.irVersion !== '1.0'` → rejection. Strict string match — `undefined` accepted (back-compat), `'1.0'` accepted, `'2.0'` rejected, numeric `1.0` rejected (`1.0 !== '1.0'`).
- **#12`alignment.yml` scripts/** trigger removal.** Removed from both `push.paths` and `pull_request.paths` since the `scripts/` directory does not currently exist (planned for Phase 7).
- **Test count:** 416 → 424 (+8).
### D36 — pre-Phase-2 batch #2 (issues #2 #5 #6 #13 #14 #15)
- **#2 — cache_control partial-noop debug log.** `server.mjs handleChatCompletions` fires `logEvent('debug', 'cache_control_partial_noop', { chain, marker_count })` at most once per request when markers present AND chain has at least one non-Anthropic hop. Per ADR 0005 § D2.
- **#5 — ADR 0002 vibe.mjs → mistral.mjs.** § Decision filesystem layout corrected to match the shipped file naming convention (file named after provider key, not CLI binary). Amendment 5 documents the correction + makes the convention statement explicit for future contributors.
- **#6 — mistral.mjs A5 flip + ALIGNMENT.md table update.** Header A5 (model flag) flipped from `UNPINNED-D-later-verifies` to `CONFIRMED-NOT-APPLICABLE` with DeepWiki citation; ALIGNMENT.md Speculative-Candidate table mistral row updated to remove A5.
- **#13 — /v1/models alias governance.** ALIGNMENT.md gains "Controlled deviations (entry-surface scope)" subsection documenting the alias surface as a controlled Rule 2(b) deviation; `docs/openai-spec-pin.md` gains the alias-surfacing subsection with full 4-field contract table.
- **#14 — cache_control slot determinism regression test.** 4 tests in test-features.mjs construct hand-built IRs with synthetic markers (bypassing openAIToIR which strips them at v0.1) and verify the cache key SHA-256 is deterministic. Per ALIGNMENT.md Rule 2 (No Invention), no `sortMarkers` helper shipped — the slot is dead-code at v0.1.
- **#15 — Anthropic v2.1.89 transcript artifact.** New file `docs/provider-audits/anthropic.md` as a single living version-capture artifact. Records observed `claude --version` (2.1.132 at capture date 2026-05-24), pinned version (v2.1.89 from D4), drift note, sample invocation, flag-surface table for 5 OLP-consumed flags. Closes the circular ALIGNMENT.md ↔ plugin header citation by anchoring on an external artifact.
- **Test count:** 424 → 431 (+7).
### D37 — release.yml phase_rolling_mode gate (issue #17)
- **CI gate enforcing phase_rolling_mode promotion discipline.** New "Enforce phase_rolling_mode (Unreleased must be promoted)" step in `release.yml` between the version-match check and the CHANGELOG extraction step. Awk extracts content between `## Unreleased` and the next `## ` heading; sed strips blank lines and parenthetical-sentinel-only lines. Non-trivial remaining content fails the workflow with `::error::` instructing the maintainer to promote Unreleased → `## v<version>` per CLAUDE.md release_kit.phase_rolling_mode.
- **Dry-run validated against 4 cases:** current sentinel-only Unreleased → PASS; synthetic non-trivial Unreleased → FIRES with offending lines reported; no Unreleased section → PASS; multi-sentinel + blank lines → PASS.
- **Gate is purely additive** — fires only on tag push to `v*.*.*`, does not affect normal push/PR CI.
- **Test count:** 431 → 431 (no test change — CI workflow only).
### D38 — maxConcurrent runtime enforcement (issue #1)
- **Spawn lifecycle gate** — `hints.maxConcurrent` is now enforced at runtime per ADR 0002 Amendment 6: `lib/providers/index.mjs` exports a per-provider `tryAcquireSpawn` / `releaseSpawn` / `getActiveSpawnCount` semaphore; `server.mjs` gates both the buffered and streaming spawn call sites in `handleChatCompletions` with a try/finally release. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` which the fallback engine treats as a hard trigger (ADR 0004 Amendment 4) — the chain advances to the next hop instead of queueing. If the entire chain is saturated, the user receives a chain-exhausted error via the existing exhaustion path. Closes #1. Queue+timeout deferred (see ADR 0002 Amendment 6 § Design choice). Test count 431 → 447.
- **Spawn lifecycle gate** — `hints.maxConcurrent` is now enforced at runtime per ADR 0002 Amendment 6: `lib/providers/index.mjs` exports a per-provider `tryAcquireSpawn` / `releaseSpawn` / `getActiveSpawnCount` semaphore; `server.mjs` gates both the buffered and streaming spawn call sites in `handleChatCompletions` with a try/finally release. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` which the fallback engine treats as a hard trigger (ADR 0004 Amendment 4) — the chain advances to the next hop instead of queueing. If the entire chain is saturated, the user receives a chain-exhausted error via the existing exhaustion path. Closes #1. Queue+timeout deferred (see ADR 0002 Amendment 6 § Design choice). Test count 431 → 447.
### D39 — D16 follow-ups (issue #3): explicit cache delete + eviction log + SPAWN_TIMEOUT asymmetry doc
- **Part 1 — `CacheStore.delete(keyId, cacheKey)`** — adds an explicit eviction primitive to `lib/cache/store.mjs`. Returns `boolean` (true if entry present and removed; false otherwise) and removes empty per-keyId namespace `Map` entries from the outer store for memory hygiene (matches the D38 `_activeSpawns` pattern). `server.mjs` D16 salvage path replaces `cacheStore.set(..., ttlMs=0)` (lazy tombstone that lived in the namespace `Map` until the next `get`/`peek` purged it) with `cacheStore.delete(...)` (immediate removal). Cache semantics unchanged — truncated responses still don't persist. ADR 0005 § "Cache write conditions" item 1 authority.
- **Part 2 — `cache_evicted_truncated` observability log** — adds an `info`-level structured log event fired immediately after the D16 eviction in `executeHopFn`. Carries `{ provider, model }` so dashboards can surface salvage frequency per (provider, model) pair. P3 polish; no semantic change.
- **Part 3 — sticky-cache regression test** — defense-in-depth test asserting two consecutive identical buffered requests that both trigger SPAWN_FAILED-with-chunks salvage each invoke a fresh spawn (spawnCount=2 across the two requests; second request reports `X-OLP-Cache: miss`). Catches any future regression where the eviction is dropped or the gate condition flips.
- **Part 4 — SPAWN_TIMEOUT salvage asymmetry documented (no code change)** — ADR 0004 Amendment 1 gains a new sub-section "Why SPAWN_TIMEOUT is excluded from salvage" with a 4-point rationale: (1) SPAWN_FAILED is a terminal signal, SPAWN_TIMEOUT is a deadline signal; (2) the next hop is a different provider with different speed characteristics, plausibly full-response-soon-after-T; (3) the "user paid for partial" framing applies to SPAWN_FAILED only — for SPAWN_TIMEOUT the user paid for "result within T"; (4) code inspection confirms the catch block matches only `code === 'SPAWN_FAILED'`. Includes hard-trigger-taxonomy completeness note and v1.x re-evaluation trigger (opt-in salvage-on-timeout for long deadlines).
- **Authority:** ADR 0005 § Cache layer / CacheStore API extension (Part 1); ADR 0004 Amendment 1 (Part 4); GitHub issue #3 — closed by this commit; D16 commit `bafa6d1` non-blocking suggestions — batched here.
- **Test count:** 447 → 452 (3 unit tests for `CacheStore.delete` + 1 log-event integration test + 1 sticky-cache regression test).
### D40 — `X-OLP-Fallback-Detail` header (issue #7)
- **New debug header on responses with a non-empty failure trail** — `lib/fallback/engine.mjs#executeWithFallback` now returns a `fallbackDetail` array of per-hop tuples on every code path. `server.mjs` emits `X-OLP-Fallback-Detail: <JSON-stringified array>` on any response where at least one hop failed before the chain resolved or exhausted (chain-exhausted, non-trigger-error, client-error, AUTH_MISSING, and success-with-prior-failure paths). Header is absent on clean primary success (no failure trail to report).
- **Tuple schema** — `{ hop, provider, model, code, error_message, trigger_type }` per failed hop. `code` is the `ProviderError` code or `'UNKNOWN'` for non-`ProviderError` exceptions; `error_message` is truncated to 200 chars with a U+2026 ellipsis on truncation; `trigger_type` matches D28's `classifyTrigger` output (`'hard'` / `'soft'` / `'auth_missing'` / `'client_error'` / `'non_trigger'`). Field shapes reuse D28's per-hop structured log event keys so logs and the header pivot on the same surface.
- **4KB UTF-8 byte cap** — if the JSON-stringified array exceeds 4096 bytes, tail tuples are dropped and a `{ truncated: true, omitted_hops: N }` sentinel is appended such that the total fits under the cap. Cap calculation uses `Buffer.byteLength('utf8')`, not string length.
- **RFC 7230 hygiene** — non-ASCII code points (e.g. the em dash in the D38 `CONCURRENCY_LIMIT` synthesised error message) are escaped as `\uXXXX` so the header value is pure ASCII. Node's HTTP header validator rejects multi-byte UTF-8 in field values; without this step, em-dash-bearing error messages would crash `res.writeHead`. `JSON.parse` round-trips the escaped form correctly.
- **Gating posture — ungated at v0.1** — the original ADR 0004 § Chain advancement step 4 specified owner-only gating. Per the maintainer decision in issue #7, v0.1 ships the header **ungated** (single-tenant family-scale per ALIGNMENT.md; no PII risk in error details). **Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands** — explicit follow-up tracked in AGENTS.md § Key files to know and ADR 0004 Amendment 5.
- **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).
### D42 — Streaming singleflight design ADR + v1.x roadmap (issue #16)
- **Design-only ratification of the v1.x streaming singleflight implementation.** ADR 0005 Amendment 6 (D34) had deferred this work with a "design alone warrants a dedicated ADR" note. D42 fulfils the note as ADR 0005 Amendment 8, ratifying the `cacheStore.getOrComputeStreaming(...)` API shape, per-(keyId, cacheKey) inflight Map, tee fan-out with bounded per-client backpressure queues, late-joiner replay buffer, AbortController propagation on all-disconnect, D38 `tryAcquireSpawn` coordination (only the first caller's spawn counts against the semaphore), cache TTL race handling, the new `STREAM_BACKPRESSURE` error code (NOT a hard trigger), and the new `X-OLP-Streaming-Inflight: source | attached | solo` header. Implementation acceptance criteria are enumerated in Amendment 8 §13.
- **Multi-layer safeguards to ensure the v1.x work is not forgotten.** New file `docs/v1x-roadmap.md` is a single living landing page for every Phase-1 deferral (streaming SF, multi-key auth, soft-trigger reactivation, `/health` activeSpawns, provider-level `cacheKeyFields`, streaming-path SPAWN_FAILED salvage, D40 AUTH_MISSING tuple test). Each entry names the ratifying ADR, the load-bearing code anchor, and a concrete trigger to start. Cross-references added at: `lib/cache/store.mjs#getOrCompute` JSDoc (sibling API TODO), `server.mjs` streaming-branch entry (~line 810, the peek+spawn pattern Amendment 8 replaces), `README.md § Known limitations` (user-facing surface), and `docs/adr/0005-cache-cross-provider.md` Amendment 8 § "Cross-references and safeguards".
- **Issue #16 status.** STAYS OPEN as the v1.x implementation tracker. The body of the issue is updated post-D42 to reference Amendment 8 and clarify scope ("design ratified; implementation pending"). DO NOT close the issue until Amendment 8 §13's test surface is green against an actual implementation.
- **No code-behavior change. No new tests.** Amendment 8 is design-only. The implementation will go through full Iron Rule 10 (fresh-context opus reviewer + acceptance-criteria-gated test pass) when the v1.x sprint kicks off.
- **Authority:** ADR 0005 Amendment 8 (this commit); ADR 0005 Amendment 6 (D34 — original deferral note); GitHub issue #16 (round-6 F13 — sibling TOCTOU); ADR 0002 Amendment 6 (D38 — `tryAcquireSpawn` semantics that §7 coordination builds on); ADR 0004 Amendment 5 (D40 — observability pattern §11 extends); `CLAUDE.md` release_kit_overlay phase_rolling_mode — under Unreleased; CC 开发铁律 v1.6 § 10.x (design-only amendment; fresh-context reviewer not required per the Iron Rule 10 implementation-phase scope, documented in the amendment's procedural mechanism).
- **Test count:** 468 → 468 (no test change — design-only).
### Phase 1 cleanup release_kit checklist
- [x] All 8 D-day deliverables landed on main (D35-D42)
- [x] CI green on every D-day commit + on this release commit's head
- [x] Cold-audit round 7 (fresh-context opus full-pass) — PASS_WITH_MINOR, 0 P1/P2 findings
- [x] 16 of 17 pre-Phase-2 GitHub issues closed (#1-#15 and #17); #16 stays OPEN as v1.x tracker
- [x] Issue #16 status comment posted referencing ADR 0005 Amendment 8 design ratification
- [x] CHANGELOG "Unreleased" promoted to "## v0.1.1 — 2026-05-25" with D35-D42 entries
- [x] `package.json` bumped from 0.1.0 → 0.1.1
- [x] `docs/v1x-roadmap.md` created — 7 deferred items with anchors + start triggers
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push; D37 phase_rolling_mode gate will pass because Unreleased is now sentinel-only)
### Known limitations carried to v1.x
Full list with code anchors + start triggers in [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md):
- Streaming-path singleflight (issue #16, ADR 0005 Amendment 8 design ratified)
- Multi-key auth (`lib/keys.mjs`)
- Soft-trigger reactivation (ADR 0004 Amendment 2)
- `/health` activeSpawns integration (ADR 0002 Amendment 6 forward note)
- Provider-level `cacheKeyFields` mask (ADR 0005 Amendment 7 forward note)
- Streaming-path SPAWN_FAILED salvage (bundled with #1 in v1.x)
- D40 AUTH_MISSING tuple test coverage (test polish)
## v0.1.0 — 2026-05-24 ## v0.1.0 — 2026-05-24
+2 -2
View File
@@ -135,7 +135,7 @@ release_kit:
# This overlay is the authoritative source. If Iron Rule 5 appears to be silently # This overlay is the authoritative source. If Iron Rule 5 appears to be silently
# violated (no version bump after many D-day pushes), check this section first # violated (no version bump after many D-day pushes), check this section first
# before filing a compliance finding. # before filing a compliance finding.
current_phase: Phase 1 current_phase: Phase 3
current_pre_release_identifier: "0.1.0-bootstrap" current_pre_release_identifier: "0.3.0-phase3"
phase_close_trigger: explicit maintainer action (not automated) phase_close_trigger: explicit maintainer action (not automated)
``` ```
+47 -15
View File
@@ -2,7 +2,7 @@
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left. A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
> **Status:** v0.1 — bootstrap. Most of this README is a skeleton; sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)). > **Status:** v0.2.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI. Phase 3 (Dashboard + audit query layer) is the next milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
--- ---
@@ -155,7 +155,7 @@ See also the [Implementation status](#implementation-status-as-of-2026-05-24) ta
Every response served through OLP carries: Every response served through OLP carries:
- `X-OLP-Provider-Used: <provider-key>` — which provider's plugin served the request. - `X-OLP-Provider-Used: <provider-key>` — 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: <model-id>` — which model the served provider used. - `X-OLP-Model-Used: <model-id>` — which model the served provider used.
- `X-OLP-Fallback-Hops: <n>` — number of fallback hops (`0` if served by the primary chain entry). - `X-OLP-Fallback-Hops: <n>` — number of fallback hops (`0` if served by the primary chain entry).
- `X-OLP-Cache: hit | miss | bypass` — cache layer outcome. - `X-OLP-Cache: hit | miss | bypass` — cache layer outcome.
@@ -165,9 +165,9 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
--- ---
## Implementation status (as of 2026-05-24) ## Implementation status (as of 2026-05-25, post-v0.2.0)
Phase 1 is in progress. This table reflects what is currently shipped vs. what is designed for later phases. Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 (Dashboard + audit query layer) is the next milestone. This table reflects what is currently shipped vs. what is designed for later phases.
| File / artifact | Status | Notes | | File / artifact | Status | Notes |
|---|---|---| |---|---|---|
@@ -182,7 +182,9 @@ Phase 1 is in progress. This table reflects what is currently shipped vs. what i
| Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 | | Soft trigger data path (`quotaStatus()` polling) | 📋 Planned (v1.x) | Evaluation logic shipped + tested; data ingestion deferred per ADR 0004 Amendment 2 |
| `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata | | `models-registry.json` | ✅ Shipped | SPOT for `(provider, model)` metadata |
| `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) | | `test-features.mjs` | ✅ Shipped | Comprehensive test suite covering IR, cache, fallback, and integration paths (CI: `test.yml`) |
| `lib/keys.mjs` | 📋 Planned (Phase 2) | Multi-key auth, per-key namespacing, audit log | | `lib/keys.mjs` | Phase 2 shipped (D44 + D45 + D46) | Multi-key auth core (`createKey` / `validateKey` / `listKeys` / `revokeKey` / `touchLastUsed`) per ADR 0007 §§ 5/6.1/6.3/6.3.5/6.4/9.4 + `loadAuthConfigSync` for `auth.allow_anonymous` / `owner_only_endpoints` / `fallback_detail_header_policy`. Server wires `validateKey` per request, filters chain by `providers_enabled`, fires `touchLastUsed` post-response, trims `/health` payload for non-owner, gates `X-OLP-Fallback-Detail` emission by policy. |
| `bin/olp-keys.mjs` | ✅ Phase 2 shipped (D47) | Keygen CLI per ADR 0007 § 9.1. `npx olp-keys keygen --owner` generates an owner key + prints plaintext token once; `npx olp-keys list` enumerates keys (token_hash redacted); `npx olp-keys revoke --id=X` marks a key revoked. `--olp-home=<path>` overrides `~/.olp/`. |
| `lib/audit.mjs` | 🟡 Phase 2 — shipped at D45 | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded (no message / response content). `OLP_HOME` env override supported for test/operator isolation. |
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard | | `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
| `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header | | `docs/provider-caveats.md` | 📋 Planned (Phase 3+) | Lossy-translation reference; for now documented inline in each plugin header |
| `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 | | `docs/openai-spec-pin.md` | ✅ Shipped (D30) | OpenAI spec snapshot for annual audit; v0.1 baseline pinned 2026-05-24 |
@@ -190,6 +192,37 @@ Phase 1 is in progress. This table reflects what is currently shipped vs. what i
| `scripts/migrate-from-ocp.mjs` | 📋 Planned (Phase 7) | OCP → OLP migration tool | | `scripts/migrate-from-ocp.mjs` | 📋 Planned (Phase 7) | OCP → OLP migration tool |
| `setup.mjs` | 📋 Planned | Setup wizard / initial config | | `setup.mjs` | 📋 Planned | Setup wizard / initial config |
### Known limitations
Behaviors that work correctly at personal/family scale but have ratified follow-ups for a v1.x sprint. Single landing page: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md).
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
- **Multi-key auth + owner gating + keygen CLI all shipped (D44 + D45 + D46 + D47); Phase 2 close pending.** `lib/keys.mjs` core shipped at D44; `server.mjs` invokes `validateKey` per request, filters the chain by `providers_enabled`, fires `touchLastUsed` post-response, and appends an audit row to `~/.olp/logs/audit.ndjson` for each `/v1/*` request (D45). At D46: `/health` payload trimmed for non-owner (returns `{ ok, version }` when `owner_only_endpoints` includes `/health`); `X-OLP-Fallback-Detail` emission gated by `fallback_detail_header_policy` (`owner_only` default suppresses for non-owner; `'all'` opts back into v0.1.1 behaviour; `'none'` suppresses entirely). At D47: keygen CLI shipped at `bin/olp-keys.mjs``npx olp-keys keygen --owner` produces an owner key (plaintext printed once); `npx olp-keys list` / `revoke` for lifecycle management. Phase 2 functional scope is complete; close to v0.2.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
**Bootstrap workflow (D47):** for first-run / production setup:
```bash
# 1. Generate an owner key (prints the plaintext token ONCE — capture it now)
npx olp-keys keygen --owner
# 2. Set production config (defaults to allow_anonymous: false)
# (Edit ~/.olp/config.json to enable providers + chains as usual)
# 3. Start the server
npm start
# 4. Validate the key works (substitute the captured plaintext token)
curl -H "Authorization: Bearer olp_..." http://localhost:3456/health
```
**Recovery if owner token is lost:** `npx olp-keys keygen --owner --force` revokes the previous owner key + creates a fresh one (plaintext printed once).
**New env vars consumed at D45:** `OLP_HOME` (override `~/.olp/` location, used by tests + operator deployments); `OLP_OWNER_TOKEN` (synthetic env-owner identity for headless / CI deployments — bypasses filesystem manifest lookup with stable `__env_owner__` keyId).
**New config block consumed at D45:** `config.json auth.{ allow_anonymous, owner_only_endpoints, fallback_detail_header_policy }`. Default `allow_anonymous: false` (production-off); set true to accept requests without an OLP API key (development / single-user dev mode). Startup emits a warn when `allow_anonymous: true` so the relaxed posture is observable.
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
--- ---
## Architecture ## Architecture
@@ -211,17 +244,16 @@ Read the ADRs in `docs/adr/` in order before proposing structural changes.
OLP lands in phases. Each phase has its own PR series and Iron-Rule-10 reviewer; this README's placeholders are filled per-phase via the [`release_kit`](./CLAUDE.md) overlay. OLP lands in phases. Each phase has its own PR series and Iron-Rule-10 reviewer; this README's placeholders are filled per-phase via the [`release_kit`](./CLAUDE.md) overlay.
- Phase 0 — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. **(current)** The original v0.1 spec (in `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations) planned one provider plugin per phase. The actual Phase 1 execution bundled the three Tier-D provider plugins + cache layer + fallback engine into a single shipped milestone (v0.1.0) followed by a cleanup batch (v0.1.1). The phase numbering below reflects what was actually shipped, not the original per-plugin partition.
- Phase 1 — `server.mjs` skeleton, IR, Anthropic plugin, cache D1+D4. Port from OCP.
- Phase 2 — OpenAI Codex plugin.
- Phase 3 — Mistral Vibe plugin.
- Phase 4 — Fallback engine + routing chains config + quota poll worker.
- Phase 5 — Cache cross-provider hardening (D2+D3).
- Phase 6 — Dashboard + observability (`/v0/management/quota`).
- Phase 7 — Release v0.1, OCP enters maintenance.
- Phase 8+ — Optional Grok / Kimi / tier-2 plugins; provider-native protocol endpoints; deterministic triggers.
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations. - **Phase 0** — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. ✅ Shipped (2026-05-23).
- **Phase 1** — Multi-provider proxy core: `server.mjs`, IR, three Tier-D provider plugins (Anthropic / OpenAI Codex / Mistral Vibe), cache (D1+D4) + cleanup (D2 bypass / D3 chunked replay / D23 size cap), fallback engine with first-chunk safety + hard triggers + per-hop log observability, IR↔OpenAI translation under Rule 2(b). ✅ Shipped — v0.1.0 (2026-05-24) + v0.1.1 cleanup (2026-05-25, D35D42).
- **Phase 2** — Multi-key auth (`lib/keys.mjs`) per ADR 0007: opaque OLP API keys, per-key cache namespacing, owner-vs-guest tier for header gating, audit ndjson (`lib/audit.mjs`), `/health` payload trimming + `X-OLP-Fallback-Detail` emission gating, `OLP_OWNER_TOKEN` env override, keygen CLI (`bin/olp-keys.mjs`). ✅ Shipped — v0.2.0 (2026-05-25, D43-A → D47). All 11 ADR 0007 § 10 acceptance criteria covered.
- **Phase 3** — Dashboard (`dashboard.html`) + audit query layer (deferred from Phase 2). Owner-only multi-provider quota panels, fallback rate, cache hit rate; localhost-bound by default. **(next)**.
- **Phase 4+** — v1.x roadmap items as triggered. Full deferred-work tracker: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md). Includes streaming-path singleflight ([issue #16](https://github.com/dtzp555-max/olp/issues/16) + ADR 0005 Amendment 8 design ratified), soft-trigger reactivation (ADR 0004 Amendment 2), `/health` activeSpawns integration, provider-level `cacheKeyFields` mask, streaming-path SPAWN_FAILED salvage.
- **Phase N (opt-in)** — Tier-2 / Tier-C provider plugins (Grok / Kimi / MiniMax / GLM / Qwen) per [ADR 0006](./docs/adr/0006-provider-inclusion.md); provider-native protocol endpoints; deterministic triggers. Triggered by tier-2 demand, not on the bootstrap path.
Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations. Phase 2 kickoff handoff: `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md`.
--- ---
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env node
/**
* bin/olp-keys.mjs — OLP key management CLI (Phase 2 / D47)
*
* Authority: ADR 0007 § 9 (Bootstrap & recovery — minimal keygen command
* surface) + § 10 acceptance criterion #9 (bootstrap workflow must be
* reproducible without manual file editing).
*
* Subcommands:
* keygen create a new OLP key; prints plaintext token to stdout ONCE
* list list all keys (manifests with token_hash redacted)
* revoke mark a key as revoked (idempotent; manifest stays for audit)
*
* Usage:
* olp-keys keygen --owner [--name=<label>] [--providers=anthropic,openai,...]
* olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=...]
* olp-keys keygen --owner --force (revokes existing owner keys; new owner)
* olp-keys list [--owner-only] [--include-revoked]
* olp-keys revoke --id=<key-id>
*
* Flags applicable to all subcommands:
* --olp-home=<path> override ~/.olp (defaults to OLP_HOME env or ~/.olp)
* --help print usage and exit 0
*
* Exit codes:
* 0 = success
* 1 = bad usage (missing args, unknown subcommand)
* 2 = operational failure (key not found, manifest invalid, FS error)
*
* The plaintext token from `keygen` is printed exactly once to stdout. It is
* never written to manifest, audit, or any log line. Operators must capture
* it immediately; lost → revoke + regenerate. Per ADR 0007 § 5 + § 9.1.
*/
import {
createKey,
listKeys,
revokeKey,
readManifest,
} from '../lib/keys.mjs';
// ── Arg parsing ───────────────────────────────────────────────────────────
/**
* Minimal flag parser. Supports:
* --flag=value → { flag: 'value' }
* --flag value → { flag: 'value' } (if next arg doesn't start with --)
* --flag → { flag: true }
* Returns { positional: string[], flags: Record<string, string|true> }.
*/
export function parseArgv(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq > 0) {
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
const name = arg.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
} else {
flags[name] = true;
}
}
} else {
positional.push(arg);
}
}
return { positional, flags };
}
const USAGE = `OLP key management CLI
Usage:
olp-keys keygen --owner [--name=<label>] [--providers=<csv>] [--force]
olp-keys keygen --name=<label> [--tier=guest|owner] [--providers=<csv>]
olp-keys list [--owner-only] [--include-revoked]
olp-keys revoke --id=<key-id>
Common flags:
--olp-home=<path> Override ~/.olp (default reads OLP_HOME env)
--help Print this message
Authority: ADR 0007 § 9 (bootstrap & recovery).`;
// ── Subcommand implementations ────────────────────────────────────────────
async function cmdKeygen(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const owner = flags.owner === true;
const force = flags.force === true;
let tier = flags.tier;
if (owner) tier = 'owner';
if (!tier) tier = 'guest';
if (tier !== 'owner' && tier !== 'guest') {
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
return 1;
}
const name = flags.name || (owner ? 'owner' : null);
if (!name) {
ioErr('Error: --name is required (or use --owner to default to "owner").\n');
return 1;
}
const providersFlag = flags.providers;
let providers_enabled;
if (providersFlag === undefined || providersFlag === true) {
providers_enabled = '*';
} else if (typeof providersFlag === 'string') {
providers_enabled = providersFlag.split(',').map(s => s.trim()).filter(Boolean);
if (providers_enabled.length === 0) providers_enabled = '*';
} else {
providers_enabled = '*';
}
// --force: revoke any existing owner keys before creating the new one.
// revokeKey is async (acquires per-key write lock); await each so the new
// owner key's createKey doesn't race the revoke writes.
if (force && tier === 'owner') {
const existing = listKeys({ olpHome });
for (const m of existing) {
if (m.owner_tier === 'owner' && m.revoked_at === null) {
try {
await revokeKey({ id: m.id, olpHome });
ioErr(`Revoked existing owner key id=${m.id} name="${m.name}" (--force).\n`);
} catch (err) {
ioErr(`Warning: failed to revoke existing owner key id=${m.id}: ${err?.message ?? err}\n`);
}
}
}
}
let result;
try {
result = createKey({ name, owner_tier: tier, providers_enabled, olpHome });
} catch (err) {
ioErr(`Error: createKey failed: ${err?.message ?? err}\n`);
return 2;
}
// Plaintext token — printed ONCE per ADR § 5 + § 9.1.
ioOut(`\n OLP key created — capture the plaintext token NOW; it will not be shown again.\n\n`);
ioOut(` id: ${result.id}\n`);
ioOut(` name: ${result.manifest.name}\n`);
ioOut(` owner_tier: ${result.manifest.owner_tier}\n`);
ioOut(` providers_enabled: ${typeof result.manifest.providers_enabled === 'string' ? result.manifest.providers_enabled : `[${result.manifest.providers_enabled.join(', ')}]`}\n`);
ioOut(` created_at: ${result.manifest.created_at}\n`);
ioOut(` manifest: ~/.olp/keys/${result.id}/manifest.json\n`);
ioOut(`\n token (plaintext): ${result.plaintext_token}\n\n`);
ioOut(` Pass via: Authorization: Bearer ${result.plaintext_token.slice(0, 12)}...\n`);
ioOut(` or: x-api-key: ${result.plaintext_token.slice(0, 12)}...\n\n`);
return 0;
}
function cmdList(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const ownerOnly = flags['owner-only'] === true;
const includeRevoked = flags['include-revoked'] === true;
let keys = listKeys({ olpHome });
if (ownerOnly) keys = keys.filter(k => k.owner_tier === 'owner');
if (!includeRevoked) keys = keys.filter(k => k.revoked_at === null);
if (keys.length === 0) {
ioOut('No keys.\n');
return 0;
}
ioOut(`\n ${keys.length} key${keys.length === 1 ? '' : 's'}:\n\n`);
for (const k of keys) {
const providers = typeof k.providers_enabled === 'string'
? k.providers_enabled
: `[${k.providers_enabled.join(', ')}]`;
const status = k.revoked_at === null ? 'active' : `revoked (${k.revoked_at})`;
const lastUsed = k.last_used_at ?? 'never';
ioOut(` id=${k.id}\n`);
ioOut(` name: ${k.name}\n`);
ioOut(` owner_tier: ${k.owner_tier}\n`);
ioOut(` providers: ${providers}\n`);
ioOut(` status: ${status}\n`);
ioOut(` created: ${k.created_at}\n`);
ioOut(` last_used: ${lastUsed}\n`);
if (k.notes) ioOut(` notes: ${k.notes}\n`);
ioOut('\n');
}
return 0;
}
async function cmdRevoke(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const id = typeof flags.id === 'string' ? flags.id : null;
if (!id) {
ioErr('Error: --id=<key-id> is required.\n');
return 1;
}
// Confirm the key exists before attempting revoke (clearer error path).
const m = readManifest(id, { olpHome });
if (m === null) {
ioErr(`Error: no key with id="${id}".\n`);
return 2;
}
if (m.revoked_at !== null) {
ioOut(`Key id=${id} already revoked at ${m.revoked_at} (no-op).\n`);
return 0;
}
try {
await revokeKey({ id, olpHome });
} catch (err) {
ioErr(`Error: revokeKey failed: ${err?.message ?? err}\n`);
return 2;
}
ioOut(`Revoked key id=${id} name="${m.name}".\n`);
return 0;
}
// ── CLI entry ────────────────────────────────────────────────────────────
/**
* Run the CLI with explicit argv + IO streams. Returns the intended exit code.
* Exported for tests (no process.exit, no direct stdout/stderr).
*
* @param {string[]} argv - args AFTER the subcommand name (e.g., ['keygen', '--owner']).
* The first element is the subcommand.
* @param {object} [opts]
* @param {(s: string) => void} [opts.out] - stdout writer; defaults to process.stdout.write
* @param {(s: string) => void} [opts.err] - stderr writer; defaults to process.stderr.write
* @returns {Promise<number>} exit code 0 / 1 / 2
*/
export async function runCli(argv, opts = {}) {
const ioOut = opts.out ?? (s => process.stdout.write(s));
const ioErr = opts.err ?? (s => process.stderr.write(s));
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
ioOut(USAGE + '\n');
return argv.length === 0 ? 1 : 0;
}
const [subcommand, ...rest] = argv;
const { flags } = parseArgv(rest);
switch (subcommand) {
case 'keygen': return await cmdKeygen(flags, ioOut, ioErr);
case 'list': return cmdList(flags, ioOut, ioErr);
case 'revoke': return await cmdRevoke(flags, ioOut, ioErr);
default:
ioErr(`Error: unknown subcommand "${subcommand}".\n${USAGE}\n`);
return 1;
}
}
// Main guard: only run when invoked as the entrypoint. ESM equivalent of
// `require.main === module` is comparing import.meta.url against argv[1].
const isMain = (() => {
try {
return import.meta.url === `file://${process.argv[1]}`;
} catch {
return false;
}
})();
if (isMain) {
runCli(process.argv.slice(2)).then(code => process.exit(code));
}
+39 -2
View File
@@ -7,6 +7,41 @@
## Amendments ## Amendments
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1)
- **Finding:** Amendment 1 (2026-05-23) ratified `maxSpawnTimeMs` into the Provider contract but explicitly noted that `hints.maxConcurrent` remained **declarative-only at v0.1** — type-validated at startup in `lib/providers/base.mjs` (`validateProvider` requires it to be a non-negative integer) but unenforced at runtime (no semaphore / in-flight counter / spawn queue in `server.mjs`). Cold-audit catch from D11 (commit `f659e29`): the diff-review reviewer grep-verified that the original ADR draft's claim "Enforced by the spawn-concurrency guard in `server.mjs`" was false. GitHub issue #1 was filed to track the gap. D38 closes that gap.
- **Change (D38):**
- Add a per-provider in-flight semaphore in `lib/providers/index.mjs` exporting three primitives plus a constant:
- `tryAcquireSpawn(providerName, maxConcurrent)` — atomic check-then-increment; returns `true` on success, `false` if at limit. Atomicity rests on the JS single-threaded invariant — the read and write are synchronous with NO `await` between them. A future async refactor MUST preserve this.
- `releaseSpawn(providerName)` — decrement; throws if the count would go negative (defensive bug guard for missing acquire / double release).
- `getActiveSpawnCount(providerName)` — returns current in-flight count; exported for diagnostics and tests (server.mjs uses it to populate the `activeSpawns` field on a synthesised `CONCURRENCY_LIMIT` error). `/health` integration deferred — when surfaced there it will land at `providers.status.<name>.activeSpawns`; not wired at D38.
- `DEFAULT_MAX_CONCURRENT_SPAWNS = 4` — defense-in-depth fallback when a plugin path bypasses `validateProvider` and passes undefined/null/NaN. The value matches the v0.1 plugin defaults (anthropic / codex / mistral all declare `hints.maxConcurrent: 4`).
- Wire the gate at both `provider.spawn(...)` call sites in `server.mjs handleChatCompletions`:
- **Buffered path** (inside `executeHopFn → collectAllChunks`): `tryAcquireSpawn` runs before `provider.spawn(...)`. On failure, synthesise `ProviderError(CONCURRENCY_LIMIT)` with `providerName` / `maxConcurrent` / `activeSpawns` fields for diagnostics and re-throw — the fallback engine treats it as a hard trigger (see ADR 0004 Amendment 4) and advances to the next chain hop. On success, the spawn drain loop runs inside a `try { … } finally { releaseSpawn(...) }` so the slot releases on every exit path (success, error, D16 SPAWN_FAILED salvage return, unexpected throw).
- **Streaming path** (single-hop real-SSE, `chain.length === 1` cache-miss branch): acquire happens BEFORE the streaming branch entry. If acquire fails, the branch is skipped and the request falls through to the buffered path — that path's own gate re-attempts acquire; a single-hop chain at maxConcurrent has no other hop to advance to, so the request surfaces a chain-exhausted error via `executeWithFallback`'s exhaustion path. If acquire succeeds, the existing streaming try/catch gains a `finally { releaseSpawn(streamProvider) }` so the slot releases on stop-chunk completion, generator exhaustion, abort, or any exception path.
- Add `CONCURRENCY_LIMIT` to `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` so the synthesised error type-checks with the existing closed enum. (Note: `CONCURRENCY_LIMIT` is synthesised by the orchestration layer, NOT thrown by provider plugins themselves — the code is in the enum for type consistency with the fallback engine's `HARD_TRIGGER_CODES` lookup.)
- Update the `maxConcurrent` description in § Decision (Provider contract hints) below — remove the "Declarative hint only at v0.1" caveat and add the implementation reference.
- **Update to § Decision § Provider contract hints (`maxConcurrent`):** replace the v0.1 caveat with: "`maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (`lib/providers/base.mjs validateProvider`) and enforced at runtime by `tryAcquireSpawn` / `releaseSpawn` in `lib/providers/index.mjs`. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` (per `PROVIDER_ERROR_CODES`, `lib/providers/base.mjs`), which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existing `executeWithFallback` exhaustion path."
- **Design choice — immediate-advancement vs. queue+timeout:** D38 implements immediate-advancement through the fallback chain. Rationale:
1. The fallback chain exists precisely for this kind of overflow — saturation on the primary hop is a natural fit for the existing advancement mechanism.
2. Queue+timeout introduces head-of-line blocking risk (a stuck/slow spawn blocks queued waiters) and adds a new timeout config surface (`hints.maxConcurrentWaitMs`?) that the contract does not currently have.
3. Immediate-advancement gives fail-fast latency and matches the OLP multi-provider proxy philosophy (the user has spread their quota across providers explicitly so saturation should reach an alternate provider as fast as possible).
4. Queue+timeout is **deferred to a future iteration** if real usage shows demand. Track via a follow-up issue if the design pressure surfaces.
- **Authority:** ALIGNMENT.md Rule 1 (Cite First) — internal authority is ADR 0002 (this ADR) + ADR 0004 (which adds CONCURRENCY_LIMIT to the hard-trigger taxonomy in its Amendment 4). No provider CLI doc cited because this change is internal to the orchestration layer; no provider plugin code changes (anthropic / codex / mistral already declare `hints.maxConcurrent` correctly per validateProvider).
- **Tests:** Suite 18 in `test-features.mjs` — 16 tests covering: `PROVIDER_ERROR_CODES` membership, `evaluateHardTriggers(CONCURRENCY_LIMIT)` returns true, semaphore unit behaviour (acquire / release / count / reset), saturation rejection, defensive coercion of non-integer maxConcurrent, double-release throws, HTTP-level concurrent-request peak-in-flight assertion (5 requests against maxConcurrent:2 → peak == 2), buffered-path counter release, streaming-path counter release, fallback advancement to secondary on saturated primary.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D38 issue #1 implementation; fresh-context opus reviewer to follow this implementation per Iron Rule 10).
### Amendment 5 — 2026-05-24: Correct § Decision filesystem layout — `vibe.mjs` → `mistral.mjs` (D36 #5)
- **Finding:** Issue #5 (D36) — § Decision filesystem layout (around line 47 of the original ADR) listed the Mistral provider plugin as `vibe.mjs` (named after the CLI binary `vibe`). The shipped file at `lib/providers/mistral.mjs` (D8) is named after the provider key, matching the established convention from the other two plugins: `anthropic.mjs` (provider key `anthropic`, CLI `claude`) and `codex.mjs` (provider key `openai`, CLI `codex`). The ADR's `vibe.mjs` entry was a drafting-time placeholder that did not get corrected when D8 landed `lib/providers/mistral.mjs`.
- **Change:** Replace `vibe.mjs # spawn `vibe --prompt --output json`` with `mistral.mjs # spawn `vibe --prompt --output streaming`` in the filesystem layout. The `--output streaming` correction also aligns the example with the actual D8 implementation (`mistral.mjs` line 377 uses `--output streaming`, not `--output json` — see D8 review-2 finding inside the plugin header).
- **Naming convention reaffirmed:** Provider plugin files are named after the **provider key** (`anthropic`, `openai`, `mistral`), not the CLI binary (`claude`, `codex`, `vibe`). Future provider plugins must follow this convention. The provider key is the load-bearing identifier — it appears in `models-registry.json`, cache keys, fallback chain configs, and ADR 0006 inclusion tables. The CLI binary name is an implementation detail that may change (e.g., a vendor rename) without affecting the rest of the system.
- **Authority:** Issue #5 (D36); naming convention established by `lib/providers/anthropic.mjs` (D4) and `lib/providers/codex.mjs` (D6) which both shipped before `lib/providers/mistral.mjs` (D8).
- **No code change:** D36 #5 is a docs-only correction. The plugin file already lives at the correct path.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D36 batch — ADR drift caught by issue-triage review of bootstrap ADRs).
### Amendment 4 — 2026-05-24: Ratify `contractVersion` as a required Provider contract field (D32 F5) ### Amendment 4 — 2026-05-24: Ratify `contractVersion` as a required Provider contract field (D32 F5)
- **Finding:** Round-4 cold-audit F5 (P3 governance omission) — `lib/providers/base.mjs` `validateProvider` enforces `p.contractVersion === '1.0'` and all three shipped plugins declare it, but the Provider contract field list in § Decision (lines ~63-74) does not include `contractVersion`. It was mentioned only in § Mitigations as a forward-looking note ("The contract is versioned. v1.0 is the subset in this ADR; future additions … require ADR amendment plus a contract-version bump. Old provider plugins continue to declare `contractVersion: '1.0'`…"), not as a required field. This is the same class of documentationimplementation gap as Amendment 1 (`maxSpawnTimeMs` retroactive sync). - **Finding:** Round-4 cold-audit F5 (P3 governance omission) — `lib/providers/base.mjs` `validateProvider` enforces `p.contractVersion === '1.0'` and all three shipped plugins declare it, but the Provider contract field list in § Decision (lines ~63-74) does not include `contractVersion`. It was mentioned only in § Mitigations as a forward-looking note ("The contract is versioned. v1.0 is the subset in this ADR; future additions … require ADR amendment plus a contract-version bump. Old provider plugins continue to declare `contractVersion: '1.0'`…"), not as a required field. This is the same class of documentationimplementation gap as Amendment 1 (`maxSpawnTimeMs` retroactive sync).
@@ -60,7 +95,9 @@ lib/providers/
index.mjs # static registry (enumeration of in-tree providers) index.mjs # static registry (enumeration of in-tree providers)
anthropic.mjs # spawn `claude -p` — port of OCP server.mjs spawn logic anthropic.mjs # spawn `claude -p` — port of OCP server.mjs spawn logic
codex.mjs # spawn `codex exec --json` codex.mjs # spawn `codex exec --json`
vibe.mjs # spawn `vibe --prompt --output json` mistral.mjs # spawn `vibe --prompt --output streaming` (file named after
# provider key per the convention established by
# anthropic.mjs / codex.mjs — see Amendment 5)
grok.mjs # spawn `grok -p --output-format streaming-json` (optional) grok.mjs # spawn `grok -p --output-format streaming-json` (optional)
kimi.mjs # spawn `kimi -p --output-format stream-json` (optional) kimi.mjs # spawn `kimi -p --output-format stream-json` (optional)
minimax.mjs # tier-2 optional, default-disabled minimax.mjs # tier-2 optional, default-disabled
@@ -83,7 +120,7 @@ Every provider plugin exports an object conforming to:
- `hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs, cacheable }` — fingerprint, concurrency, timeout, and cache hints: - `hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs, cacheable }` — fingerprint, concurrency, timeout, and cache hints:
- `requiresTTY` — boolean; whether the provider CLI requires a TTY to produce non-interactive output (e.g., some CLIs suppress JSON output unless forced with a flag or a TTY is present). - `requiresTTY` — boolean; whether the provider CLI requires a TTY to produce non-interactive output (e.g., some CLIs suppress JSON output unless forced with a flag or a TTY is present).
- `concurrentSpawnSafe` — boolean; whether the provider CLI is safe to spawn concurrently under the same auth context without rate-limit or session collisions. - `concurrentSpawnSafe` — boolean; whether the provider CLI is safe to spawn concurrently under the same auth context without rate-limit or session collisions.
- `maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. **Declarative hint only at v0.1**: the value is type-validated at startup (`lib/providers/base.mjs`) but no runtime enforcement (semaphore / in-flight counter / spawn queue) is wired in `server.mjs` yet. Tracking issue to be filed for a follow-up that lands the runtime guard. - `maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (`lib/providers/base.mjs validateProvider`) and **enforced at runtime** by `tryAcquireSpawn` / `releaseSpawn` in `lib/providers/index.mjs` (D38 — see Amendment 6). Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)`, which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existing `executeWithFallback` exhaustion path. (Pre-D38 caveat removed; tracking issue #1 closed by Amendment 6.)
- `maxSpawnTimeMs` — optional integer, milliseconds; maximum wall-clock time OLP allows for a single provider spawn before treating it as a hard fallback trigger. Defaults to `600000` (10 minutes) if absent. Enforcement lives inside each provider plugin's spawn drain loop (`_spawnAndStream`), which uses a `setTimeout` / `proc.kill` / `reject` pattern to throw `ProviderError(SPAWN_TIMEOUT)`; the fallback engine then treats this error as a hard trigger (ADR 0004 § Trigger taxonomy — Hard triggers bullet 4). The engine itself does not run the timer loop; it only acts on the thrown error. - `maxSpawnTimeMs` — optional integer, milliseconds; maximum wall-clock time OLP allows for a single provider spawn before treating it as a hard fallback trigger. Defaults to `600000` (10 minutes) if absent. Enforcement lives inside each provider plugin's spawn drain loop (`_spawnAndStream`), which uses a `setTimeout` / `proc.kill` / `reject` pattern to throw `ProviderError(SPAWN_TIMEOUT)`; the fallback engine then treats this error as a hard trigger (ADR 0004 § Trigger taxonomy — Hard triggers bullet 4). The engine itself does not run the timer loop; it only acts on the thrown error.
- `cacheable` — optional boolean, default `true`; if explicitly set to `false`, the provider opts out of OLP's response cache entirely. `executeHopFn` skips `cacheStore.getOrCompute` and calls `collectAllChunks` directly; no cache read or write occurs for any request to this provider. Omitting the field is equivalent to `cacheable: true`. See ADR 0005 § "Cache write conditions" item 3 and Amendment 3 above. (D23) - `cacheable` — optional boolean, default `true`; if explicitly set to `false`, the provider opts out of OLP's response cache entirely. `executeHopFn` skips `cacheStore.getOrCompute` and calls `collectAllChunks` directly; no cache read or write occurs for any request to this provider. Omitting the field is equivalent to `cacheable: true`. See ADR 0005 § "Cache write conditions" item 3 and Amendment 3 above. (D23)
+77 -2
View File
@@ -7,6 +7,55 @@
## Amendments ## 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.
- **Change (D40):**
- `lib/fallback/engine.mjs#executeWithFallback` now collects per-hop failure tuples in a new `fallbackDetail` array on the returned `FallbackResult`. Tuple shape reuses D28 log-event field shapes so logs and the header pivot on the same keys:
`{ hop, provider, model, code, error_message, trigger_type }`. `code` is the `ProviderError` code, or any string `err.code` (including the engine-synthetic `SOFT_TRIGGER`), or `'UNKNOWN'` for non-`ProviderError` exceptions. `error_message` is truncated to 200 chars (single-character ellipsis `…` appended on truncation). `trigger_type` is the same classification surfaced in the D28 log events.
- `server.mjs` emits the new header `X-OLP-Fallback-Detail: <JSON-stringified array>` on any response where `fallbackDetail` is non-empty — i.e., chain-exhausted, non-trigger-error, client-error, AUTH_MISSING, and success-with-prior-failure paths. Header is absent on clean primary success (semantically: no failure trail to report).
- 4KB UTF-8 byte cap on the header value: if the serialised array exceeds 4096 bytes, tail tuples are dropped one at a time and a `{ truncated: true, omitted_hops: N }` sentinel is appended such that the total fits under the cap.
- Non-ASCII characters in tuple fields (e.g. the em dash in the synthesised `CONCURRENCY_LIMIT` error message) are escaped as `\uXXXX` to satisfy RFC 7230 §3.2.6 `field-vchar` (Node's HTTP header validator rejects multi-byte UTF-8). `JSON.parse` round-trips the escaped form correctly.
- **Gating — Option A (ungated v0.1):** The original promise specified owner-only gating. Per the maintainer decision recorded in issue #7, v0.1 ships the header **ungated**: the failure detail is surfaced on every response regardless of API key identity. Rationale: OLP v0.1 is single-tenant family-scale (per ALIGNMENT.md § What this project is); no PII risk in error details. **Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands** — this is an explicit follow-up tracked in AGENTS.md § Key files to know and in the lib/keys.mjs Phase 2 planning. Until then, the header is informational on every response and operators should not assume per-key visibility differs.
- **Authority:** § Decision § Chain advancement step 4 (original promise — D40 fulfils it); 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.
- **Tests (test-features.mjs):** New describe block "D40 — X-OLP-Fallback-Detail header (issue #7)" covers: engine-level tuple shape on 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-level empty/null → null, small-array round-trip, >4KB cap with `{truncated:true,omitted_hops:N}` sentinel, RFC 7230 newline/CR escaping, and non-ASCII escaping (em dash regression guard for the D38 `CONCURRENCY_LIMIT` synthesised message); HTTP integration covers clean-1-hop-success (header absent), 2-hop-exhausted (2 tuples on the wire), and 2-hop-success-with-prior-failure (1 tuple on the wire). Test count 452 → 468 (16 new tests).
- **v1.x re-evaluation triggers:**
- When `lib/keys.mjs` lands (Phase 2), re-introduce owner-vs-non-owner gating. Update this amendment + § Observability headers below + AGENTS.md.
- If a future debug-header field becomes useful (e.g., `attempts`, `cache_eviction_count`, `last_chunk_index`), add to the tuple schema documented above + bump this amendment + extend the test schema assertions.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D40 issue #7 implementation; fresh-context opus reviewer to follow per Iron Rule 10).
### Amendment 4 — 2026-05-24: Add `CONCURRENCY_LIMIT` to v0.1 hard-trigger code taxonomy (D38, issue #1)
- **Finding:** ADR 0002 Amendment 1 (2026-05-23) ratified `hints.maxConcurrent` into the Provider contract as **declarative-only at v0.1** — no runtime enforcement. GitHub issue #1 tracked the gap. D38 lands runtime enforcement (see ADR 0002 Amendment 6 for the implementation details and design rationale). Once a saturation event occurs, the orchestration layer must communicate "this hop is at capacity — advance the chain" to the fallback engine using a code that fits the existing hard-trigger taxonomy in `evaluateHardTriggers`.
- **Change (D38):** Add `CONCURRENCY_LIMIT` to both:
- `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` (closed enum used by `ProviderError`).
- `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` — value `true` so `evaluateHardTriggers(ProviderError CONCURRENCY_LIMIT)` returns `true` and `classifyTrigger` returns `'hard'`. The chain advances to the next hop. The synthesised error carries diagnostic fields (`providerName`, `maxConcurrent`, `activeSpawns`) which surface in the existing `fallback_hard_trigger` log event via the `error.message` field.
- **v0.1 live hard-trigger codes after this amendment (5 codes):** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`, `CONCURRENCY_LIMIT`, plus the explicit non-trigger `AUTH_MISSING:false`. Pre-D38 list (per Amendment 3) was 4 codes (`SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT` as triggers + `AUTH_MISSING:false`).
- **Synthesis vs. plugin-thrown:** Unlike the other live hard-trigger codes, `CONCURRENCY_LIMIT` is **NOT thrown by provider plugins themselves**. It is synthesised by `server.mjs handleChatCompletions` when `tryAcquireSpawn(provider, hints.maxConcurrent)` returns false. The code lives in `PROVIDER_ERROR_CODES` for type consistency with the closed enum that `HARD_TRIGGER_CODES` keys on; the orchestration layer is the only callsite that throws it. A future provider plugin that gains its own internal concurrency limit (e.g., a CLI that returns a specific exit code on rate-limit) could thrown this code too; the enum is forward-compatible.
- **Design choice — immediate-advancement vs. queue+timeout:** Re-stating from ADR 0002 Amendment 6 because this ADR governs the trigger taxonomy that surfaces the decision to the user: saturation is treated as a **hard trigger** (chain advances immediately) rather than as a **soft trigger** (would gate before spawn but would not advance after spawn attempt) or as a queueable condition (would block + timeout). The hard-trigger framing matches "the primary hop refused to serve this request; advance" semantics. Queue+timeout would require a NEW trigger category outside the existing taxonomy (hard / soft / deterministic-deferred / cost-aware-deferred) and is deferred per ADR 0002 Amendment 6 rationale.
- **First-chunk safety:** `tryAcquireSpawn` runs **before** `provider.spawn(...)` and before any bytes are written to the response. A `CONCURRENCY_LIMIT` rejection therefore satisfies the first-chunk rule trivially — zero bytes have been emitted to the client. Fallback is safe.
- **Authority:** ADR 0002 Amendment 6 (runtime enforcement implementation); GitHub issue #1 (tracking).
- **Tests:** Suite 18 in `test-features.mjs` — see ADR 0002 Amendment 6 § Tests for the full list. Specifically for this ADR: tests 18a (PROVIDER_ERROR_CODES membership), 18b (`evaluateHardTriggers(CONCURRENCY_LIMIT) === true`), 18c (AUTH_MISSING regression guard — D38 did not flip it), 18k (chain advances to fallback hop on saturated primary).
- **v1.x re-evaluation triggers:**
- If a future plugin gains a CLI-level concurrency response that should NOT be a hard trigger (e.g., "soft limit hit, retry after backoff") — file a follow-up to add a new code (e.g., `CONCURRENCY_BACKOFF`) rather than reclassifying `CONCURRENCY_LIMIT`.
- If queue+timeout becomes desirable (real usage shows fail-fast advancement is too aggressive for certain workloads), file an amendment to this ADR adding queue semantics as a NEW trigger category — do not reclassify CONCURRENCY_LIMIT into the existing taxonomy.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D38 issue #1 implementation; fresh-context opus reviewer to follow per Iron Rule 10).
### Amendment 3 — 2026-05-24: Narrow v0.1 hard-trigger code taxonomy (D34 F7) ### Amendment 3 — 2026-05-24: Narrow v0.1 hard-trigger code taxonomy (D34 F7)
- **Finding:** Round-6 cold-audit F7 (P2) — `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` and `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` both listed `QUOTA_EXHAUSTED` and `RATE_LIMITED` as live hard-trigger codes. No v0.1 plugin emits either code. The Anthropic, Codex, and Mistral plugins all use `claude -p`, `codex exec --json`, and `vibe --prompt` respectively — none parse the underlying-API HTTP response status code or surface a structured quota/rate error; they only throw `SPAWN_FAILED`, `SPAWN_TIMEOUT`, `CLI_NOT_FOUND`, or `AUTH_MISSING`. The two `Hard triggers` bullets in § Trigger taxonomy ("HTTP 5xx from provider's underlying API" and "HTTP 4xx quota exhaustion") are therefore unreachable through the `ProviderError` code path at v0.1. - **Finding:** Round-6 cold-audit F7 (P2) — `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` and `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` both listed `QUOTA_EXHAUSTED` and `RATE_LIMITED` as live hard-trigger codes. No v0.1 plugin emits either code. The Anthropic, Codex, and Mistral plugins all use `claude -p`, `codex exec --json`, and `vibe --prompt` respectively — none parse the underlying-API HTTP response status code or surface a structured quota/rate error; they only throw `SPAWN_FAILED`, `SPAWN_TIMEOUT`, `CLI_NOT_FOUND`, or `AUTH_MISSING`. The two `Hard triggers` bullets in § Trigger taxonomy ("HTTP 5xx from provider's underlying API" and "HTTP 4xx quota exhaustion") are therefore unreachable through the `ProviderError` code path at v0.1.
@@ -15,7 +64,7 @@
- `QUOTA_EXHAUSTED` and `RATE_LIMITED` removed from `PROVIDER_ERROR_CODES` (base.mjs) and `HARD_TRIGGER_CODES` (engine.mjs). Dead code removal. - `QUOTA_EXHAUSTED` and `RATE_LIMITED` removed from `PROVIDER_ERROR_CODES` (base.mjs) and `HARD_TRIGGER_CODES` (engine.mjs). Dead code removal.
- A comment block added in `evaluateHardTriggers` labeling the HTTP-status branches as "forward-compat reserved — v0.1 plugins never attach statusCode." - A comment block added in `evaluateHardTriggers` labeling the HTTP-status branches as "forward-compat reserved — v0.1 plugins never attach statusCode."
- Test coverage: the two unit tests for `evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED/RATE_LIMITED → fires` are removed (tombstoned with a removal comment). All other hard-trigger tests that used these codes as convenient test vectors are rewritten to use `SPAWN_FAILED` / `SPAWN_TIMEOUT`. - Test coverage: the two unit tests for `evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED/RATE_LIMITED → fires` are removed (tombstoned with a removal comment). All other hard-trigger tests that used these codes as convenient test vectors are rewritten to use `SPAWN_FAILED` / `SPAWN_TIMEOUT`.
- **v0.1 live hard-trigger codes after this amendment:** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`. `AUTH_MISSING` remains in the table as an explicit `false` entry (deliberate non-trigger per ADR 0004 § "No fallback for client-side errors" analogue). - **v0.1 live hard-trigger codes after this amendment:** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`. `AUTH_MISSING` remains in the table as an explicit `false` entry (deliberate non-trigger per ADR 0004 § "No fallback for client-side errors" analogue). **Subsequently extended by Amendment 4 (D38) — `CONCURRENCY_LIMIT` added as a 4th true entry; the v0.1 live-codes list as of D38 is `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`, `CONCURRENCY_LIMIT` plus the explicit `AUTH_MISSING:false` non-trigger entry.**
- **v1.x re-activation path:** When a plugin gains HTTP-status parsing (e.g., an Anthropic plugin variant that makes direct Messages API calls rather than spawning `claude -p`), add the plugin-layer HTTP parsing, re-add `QUOTA_EXHAUSTED` and `RATE_LIMITED` to both tables, and amend this entry. The `evaluateHardTriggers` HTTP-status branches will then activate naturally with no further engine changes. - **v1.x re-activation path:** When a plugin gains HTTP-status parsing (e.g., an Anthropic plugin variant that makes direct Messages API calls rather than spawning `claude -p`), add the plugin-layer HTTP parsing, re-add `QUOTA_EXHAUSTED` and `RATE_LIMITED` to both tables, and amend this entry. The `evaluateHardTriggers` HTTP-status branches will then activate naturally with no further engine changes.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit). - **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit).
@@ -35,6 +84,26 @@
- **Streaming path note:** The D10 real-streaming branch (single-hop, `server.mjs` lines 401510) already handles the analogous case correctly via ADR 0004's first-chunk rule: once `firstChunkEmitted === true`, any subsequent error truncates the response with `res.end()` (no re-throw, no fallback). This amendment applies specifically to the **buffered path** (`collectAllChunks` + multi-hop fallback chains). The streaming path is not changed by D16. - **Streaming path note:** The D10 real-streaming branch (single-hop, `server.mjs` lines 401510) already handles the analogous case correctly via ADR 0004's first-chunk rule: once `firstChunkEmitted === true`, any subsequent error truncates the response with `res.end()` (no re-throw, no fallback). This amendment applies specifically to the **buffered path** (`collectAllChunks` + multi-hop fallback chains). The streaming path is not changed by D16.
#### D39 follow-up — explicit eviction primitive + observability log (2026-05-24, issue #3 Parts 1+2)
The original D16 cache-eviction implementation used `cacheStore.set(keyId, hopCacheKey, result, ttlMs=0)` to tombstone the just-written truncated entry. The TTL=0 entry survived in the per-keyId namespace `Map` until the next `get`/`peek` lazily purged it via the `_isAlive` check. D39 Part 1 replaces this with an explicit `cacheStore.delete(keyId, cacheKey)` primitive that (a) removes the entry from the namespace `Map` immediately, (b) removes the empty namespace `Map` entry from the outer store when it becomes empty (memory hygiene matching the D38 `_activeSpawns` pattern), and (c) returns `boolean` for caller inspection. D39 Part 2 adds a `cache_evicted_truncated` `info`-level log event with `{ provider, model }` fields immediately after the eviction, giving dashboards visibility into salvage frequency. Neither change alters the salvage semantics established by this Amendment — they are observability + memory-hygiene polish. Test coverage: 3 unit tests on `CacheStore.delete` (present-returns-true, absent-returns-false, empty-namespace-cleanup), 1 HTTP integration test asserting the log event fires with the correct fields, and 1 defense-in-depth regression test asserting two consecutive identical truncated requests both result in fresh spawns (no sticky cache).
#### Why SPAWN_TIMEOUT is excluded from salvage (D39 Part 4, issue #3 Part 4)
D16's salvage path is gated on `code === 'SPAWN_FAILED'`. SPAWN_TIMEOUT is **not** salvaged even when partial chunks have accumulated in the buffered path — the timeout error propagates from `collectAllChunks` as-is, the fallback engine fires the SPAWN_TIMEOUT hard trigger, and the chain advances to the next hop. This asymmetry is intentional and is the maintainer's design choice. The four-point rationale:
1. **SPAWN_FAILED is a terminal signal from this hop.** The provider crashed mid-stream; nothing more is coming from it. Salvaging the partial chunks is strictly better than discarding them (partial > nothing). Advancing the chain in this case offers no advantage: the same input may crash the next hop the same way (when the failure is input-dependent), and even when the next hop succeeds, the salvaged chunks were already paid for in quota — discarding them would be strict waste.
2. **SPAWN_TIMEOUT is a deadline signal, not a terminal signal.** It indicates the provider was slow (deadline exceeded per `hints.maxSpawnTimeMs`, which the plugin enforces — see the unconditional post-loop `if (spawnTimedOut) throw SPAWN_TIMEOUT` in each provider plugin, e.g. `lib/providers/anthropic.mjs`). The next hop is a *different provider* with different model-speed characteristics, so its full response is plausibly available sooner than the original hop's continuation would have been. Fallback advancement on timeout is more likely to give the user a complete response than salvaging partial-from-slow.
3. **The "user paid for partial" framing applies only to SPAWN_FAILED.** The D16 reviewer's "user paid for partial content, dropping it is strict waste" captures SPAWN_FAILED correctly: the deadline was honored, the provider died mid-stream, the chunks are real consumed quota. For SPAWN_TIMEOUT the user actually paid for "result within time T" — a partial result delivered *at* time T is not what was paid for. The fallback engine's "full result soon after time T" via a different provider is closer to the contract.
4. **Code-level inspection confirms the asymmetry (verified post-D38, D39 Part 4).** `collectAllChunks` in `server.mjs` matches only `spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0` for the salvage branch. SPAWN_TIMEOUT propagates through the same catch block via the unconditional re-throw, hits `evaluateHardTriggers` as a hard trigger (per Amendment 3: SPAWN_FAILED, CLI_NOT_FOUND, SPAWN_TIMEOUT; per Amendment 4: CONCURRENCY_LIMIT), and advances the chain. This asymmetry is not an oversight; it is the design.
**Hard-trigger taxonomy completeness:** The v0.1 hard-trigger code set is enumerated in Amendment 3 (D34 F7) and extended in Amendment 4 (D38, CONCURRENCY_LIMIT). Of those four codes, only SPAWN_FAILED participates in the salvage path. CLI_NOT_FOUND fires before any spawn output is possible (no partial chunks ever exist). CONCURRENCY_LIMIT fires before `provider.spawn(...)` is called (per Amendment 4 § First-chunk safety — zero bytes emitted at rejection moment). SPAWN_TIMEOUT can in principle accumulate partial chunks but is excluded from salvage per the rationale above.
**v1.x re-evaluation trigger:** If real usage shows users want partial-on-timeout for very long deadlines (e.g., a 5-minute `maxSpawnTimeMs` where the user would rather have whatever streamed in 5 minutes than re-pay quota on a different provider that may take its own 5 minutes), this asymmetry is queued as a future-design question. A v1.x amendment would need to: (a) make salvage-on-timeout opt-in per chain or per provider (default-off preserves v0.1 semantics), (b) extend `collectAllChunks` catch matching to a broader code set, (c) add tests parallel to the D16 Case A/Case B/single-hop trio for the SPAWN_TIMEOUT path. Not a v0.1 issue.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Cold Audit). Diff-review reviewers on earlier passes focused on the first-chunk rule for the real-streaming path; the buffered path has its own truncation-vs-fallback decision point, which the cold-audit pass on 2026-05-23 identified as Finding 17. - **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Cold Audit). Diff-review reviewers on earlier passes focused on the first-chunk rule for the real-streaming path; the buffered path has its own truncation-vs-fallback decision point, which the cold-audit pass on 2026-05-23 identified as Finding 17.
### Amendment 2 — 2026-05-24: Soft triggers deferred to v1.x (D22) ### Amendment 2 — 2026-05-24: Soft triggers deferred to v1.x (D22)
@@ -104,7 +173,7 @@ This is non-negotiable for v1.0. Post-first-chunk truncations are surfaced to th
1. Try A. If A succeeds, return; emit `X-OLP-Fallback-Hops: 0`, `X-OLP-Provider-Used: A`. 1. Try A. If A succeeds, return; emit `X-OLP-Fallback-Hops: 0`, `X-OLP-Provider-Used: A`.
2. If A's failure matches a hard or soft trigger AND no chunks emitted: try B. If B succeeds, return; emit `X-OLP-Fallback-Hops: 1`, `X-OLP-Provider-Used: B`. 2. If A's failure matches a hard or soft trigger AND no chunks emitted: try B. If B succeeds, return; emit `X-OLP-Fallback-Hops: 1`, `X-OLP-Provider-Used: B`.
3. If B also fails: try C. Same logic. 3. If B also fails: try C. Same logic.
4. If all of A, B, C fail: return **A's original error** (not B's, not C's) with `X-OLP-Fallback-Exhausted: A,B,C` listing the chain order, and per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, owner-only — gated behind a config flag for non-owner keys). 4. If all of A, B, C fail: return **A's original error** (not B's, not C's) with `X-OLP-Fallback-Exhausted: A,B,C` listing the chain order, and per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, ungated at v0.1 per Amendment 5 / D40, issue #7; owner-vs-non-owner gating planned for Phase 2 when `lib/keys.mjs` lands). The header is also emitted on success-with-prior-failure paths (e.g., A fails + B succeeds → response carries the 1-tuple failure trail for A). See § Observability headers below for the tuple schema and cap behaviour.
**Observability headers (per spec §4.7).** **Observability headers (per spec §4.7).**
- `X-OLP-Provider-Used: <provider-name>` - `X-OLP-Provider-Used: <provider-name>`
@@ -112,6 +181,12 @@ This is non-negotiable for v1.0. Post-first-chunk truncations are surfaced to th
- `X-OLP-Fallback-Hops: <integer ≥ 0>` - `X-OLP-Fallback-Hops: <integer ≥ 0>`
- `X-OLP-Cache: hit | miss | bypass` - `X-OLP-Cache: hit | miss | bypass`
- `X-OLP-Latency-Ms: <integer>` - `X-OLP-Latency-Ms: <integer>`
- `X-OLP-Fallback-Exhausted: <comma-separated provider list>` — emitted only when multiple providers were tried (D18; chain-exhaustion path).
- `X-OLP-Fallback-Detail: <JSON array>`**shipped as IMPLEMENTED at v0.1, ungated** per Amendment 5 (D40, issue #7). Emitted on any response where at least one prior hop failed before the chain resolved or exhausted; absent on clean primary success.
- **Tuple schema (per failed hop):** `{ hop: <0-indexed integer>, provider: <string>, model: <string>, code: <ProviderError.code or 'UNKNOWN'>, error_message: <string truncated to 200 chars with U+2026 ellipsis on truncation>, trigger_type: 'hard' | 'soft' | 'auth_missing' | 'client_error' | 'non_trigger' }`. Field shapes reuse D28's per-hop log event keys.
- **4KB UTF-8 byte cap:** if the JSON-stringified array exceeds 4096 bytes, tail tuples are dropped and a `{ truncated: true, omitted_hops: <count> }` sentinel is appended so the total fits under the cap.
- **RFC 7230 hygiene:** all non-ASCII code points are escaped as `\uXXXX` so the header value is pure ASCII (Node's HTTP header validator rejects multi-byte UTF-8). `JSON.parse` still round-trips the escaped form to the original Unicode.
- **Phase 2 follow-up:** owner-vs-non-owner gating is planned for when `lib/keys.mjs` lands. Until then, the header is informational on every response. See Amendment 5 for the full rationale and the gating re-introduction trigger.
Each fallback hop emits a structured log event with: timestamp, chain id, hop index, failed provider, trigger type, IR request hash, downstream provider that was tried next. Each fallback hop emits a structured log event with: timestamp, chain id, hop index, failed provider, trigger type, IR request hash, downstream provider that was tried next.
+133
View File
@@ -7,6 +7,139 @@
## Amendments ## Amendments
### Amendment 8 — 2026-05-25: Streaming singleflight — v1.x design ratification (D42, issue #16)
**Status:** Design ratified. Implementation deferred to v1.x.
**Context.** Amendment 6 (D34) formally deferred streaming-path D4 singleflight participation with the note "the design alone warrants a dedicated ADR." Round-6 cold-audit F13 (filed as issue #16) raised the sibling TOCTOU window: `server.mjs:782 preCheckHit = await cacheStore.peek(...)` followed by the streaming-branch entry conditionals at lines 817823 (the TODO anchor sits just above at line ~810 and is the navigable landmark; line numbers may drift across commits) creates a race where, between peek and spawn, a concurrent populator can write the cache OR a TTL can expire. The streaming branch is path-locked at the moment of the peek result.
This amendment ratifies the v1.x design so the implementation work has a single specification to follow.
**Design — per-(keyId, cacheKey) inflight Map + tee-streaming + bounded per-client backpressure.**
1. **Coordination primitive.** Extend `CacheStore` with `getOrComputeStreaming(keyId, cacheKey, sourceFactory): { stream: AsyncIterator<IRChunk>, isFirst: boolean }`. Internally maintains `_streamingInflight: Map<compositeKey, StreamingInflightEntry>`. Three outcomes on call:
- **Cache hit** (cached entry exists and is alive): synthesize an async iterator that yields the cached chunks. `isFirst = false`. No spawn.
- **Inflight join** (entry exists in `_streamingInflight`): attach a new `AttachedClient` to the existing entry. `isFirst = false`. No spawn.
- **Cache miss + no inflight**: create a new `StreamingInflightEntry`, invoke `sourceFactory()` to obtain the underlying spawn iterator, attach as the source. `isFirst = true`. Subsequent identical-key callers join this entry until it completes or aborts.
The Map check + insert is synchronous (no `await` between read and write), matching the D38 `tryAcquireSpawn` atomicity invariant. Document this in the implementation header.
2. **StreamingInflightEntry shape.**
```
{
compositeKey: string, // keyId + '\0' + cacheKey
source: AsyncIterator<IRChunk>,
sourceAbortController: AbortController,
accumulatedChunks: IRChunk[], // for late joiners (replay buffer)
attachedClients: Set<AttachedClient>,
sourceDone: boolean, // source iterator exhausted
sourceError: Error | null, // non-null if source threw
sourceAborted: boolean, // true if AbortController fired
spawnAcquiredProvider: string | null, // for D38 release coordination
}
```
3. **AttachedClient shape.**
```
{
id: string, // request ID (D40 fallback log correlator)
queue: IRChunk[], // per-client tee buffer
queueByteSize: number, // running sum of JSON.stringify(chunk).length for cap
yieldedAccumulated: boolean, // true after late-joiner replay drained
done: boolean,
resolveNext: ((chunk) => void) | null, // promise resolver for the next chunk
rejectNext: ((err) => void) | null,
}
```
4. **Tee fan-out loop (single-reader, multi-writer).** Source iterator is drained by ONE reader (the entry's tee task), which on each chunk:
- Pushes the chunk into `accumulatedChunks` (late-joiner replay buffer; bounded — see §10).
- For each `client ∈ attachedClients`: if `client.queueByteSize + chunkSize > PER_CLIENT_QUEUE_CAP` (default 1 MB), the client is disconnected with `STREAM_BACKPRESSURE` (see §8). Otherwise push the chunk into `client.queue`, update `queueByteSize`, fire `resolveNext` if pending.
When the source iterator returns/throws/aborts, the tee task:
- On normal completion: writes `accumulatedChunks` to cache via the standard cache-write conditions — `truncated-not-cached` from § Decision § "Cache write conditions" item 1; `cacheable=false` opt-out from Amendment 3; `claude -p --output-format text` wire-shape limitation from Amendment 5; size cap from Amendment 3. (Note: ADR 0005 has no Amendment 1 heading — the section §-Decision body item-1 is the source for `truncated-not-cached`, NOT a numbered amendment.) Resolves all clients' `resolveNext` with their remaining queue then sentinel-marks `done`. Releases the D38 spawn slot once. Removes the entry from `_streamingInflight`.
- On source error: rejects all clients with the error. Does NOT write cache. Releases the D38 spawn slot. Removes entry.
- On source abort (all clients disconnected): cancels the iterator via AbortController, releases the slot, removes entry. No cache write (partial response not persisted, matches D16 buffered-path SPAWN_FAILED salvage NOT applying to abort).
5. **Late-joiner policy.** When a new client attaches mid-stream:
- Drain `accumulatedChunks` into the client's queue immediately (synchronous burst).
- If the burst exceeds `PER_CLIENT_QUEUE_CAP`, the client is rejected immediately with `STREAM_BACKPRESSURE` (the implication is that the source has produced more than 1 MB before this client attached — late joiner is too late to catch up).
- From that point on, the client receives live chunks via the tee loop.
6. **Cache TTL race during inflight.** If a cache entry is alive at peek time but expires during the inflight period, late joiners that arrive AFTER expiry still see the inflight entry in `_streamingInflight` (Map lookup precedes cache peek per the new contract). They attach via inflight join. No fresh spawn. The expired cache entry is overwritten by the inflight completion.
7. **D38 maxConcurrent coordination.** Only the first caller's source-spawn calls `tryAcquireSpawn`. Subsequent attached clients DO NOT call it — they share the existing spawn slot. On source completion / error / abort, `releaseSpawn` fires once. If `tryAcquireSpawn` returns false for the first caller, the request fails with `CONCURRENCY_LIMIT` per D38 (existing behavior) and the streaming branch is not entered.
8. **Backpressure error code.** New `PROVIDER_ERROR_CODES.STREAM_BACKPRESSURE`. **NOT a hard trigger** — the source spawned successfully; only one client's queue overflowed. The affected client receives a synthetic `{ type: 'stop', finish_reason: 'length' }` followed by `[DONE]` (matching D35 #10 truncation marker pattern). Server logs `stream_backpressure_disconnect` with `{ provider, model, client_id, queue_byte_size, per_client_cap }`. Other attached clients continue receiving chunks normally.
9. **Client mid-stream disconnect (network drop / abort).** The HTTP response stream's `close` event triggers client cleanup: remove from `attachedClients`, no fallback advancement (the source is still running for other clients). If `attachedClients.size === 0`, the tee task fires `sourceAbortController.abort()` (which propagates to the underlying CLI spawn — D38's plugin spawn loops already handle AbortController per ADR 0002 § Provider contract).
10. **Replay buffer cap.** `accumulatedChunks` is bounded at `ACCUMULATED_REPLAY_CAP` (default 10 MB, matches the existing cache-entry size cap from D23). If the source produces more than the cap before completion, the entry is marked NOT cacheable (cache write skipped at source-complete). Late joiners attaching past the cap receive `STREAM_BACKPRESSURE` immediately (the replay burst would exceed `PER_CLIENT_QUEUE_CAP`). First caller's stream continues unaffected because they were attached before the cap was hit.
11. **Observability.** New log events:
- `streaming_inflight_join` — fires when a request attaches to an existing inflight entry. Fields: `{ provider, model, attached_count_after, accumulated_chunk_count }`.
- `streaming_inflight_source_done` — fires when the source completes. Fields: `{ provider, model, attached_count, accumulated_chunk_count, cache_written }`.
- `stream_backpressure_disconnect` — see §8.
- `streaming_inflight_abort` — fires when all clients disconnect and source is aborted. Fields: `{ provider, model, accumulated_chunk_count }`.
New X-OLP-* header: `X-OLP-Streaming-Inflight: source | attached | solo` distinguishing which role this client played. `solo` = first caller, no joiners during stream (functionally equivalent to today's behavior). `source` = first caller, ≥1 joiner attached. `attached` = joined an existing inflight entry. Adds one field to the X-OLP-* set (currently 5); update D18-D40 documentation when implementation lands.
12. **Server.mjs wiring.** Replace the current streaming branch peek+spawn pattern (server.mjs:782 `preCheckHit = await cacheStore.peek(...)` and lines 811817 conditional) with:
```js
const { stream, isFirst } = await cacheStore.getOrComputeStreaming(
keyId,
streamCacheKey,
async () => {
// sourceFactory: invoked only on first caller; encapsulates the D38
// tryAcquireSpawn gate and provider.spawn invocation
...
}
);
```
`isFirst` plumbed into the X-OLP-Streaming-Inflight header. The TOCTOU window collapses because Map check + insert is synchronous.
13. **Test surface (when implementation lands).** At minimum:
- Single client streaming (`isFirst=true`, no joiners) — behavior identical to today.
- 2 concurrent identical streams — only 1 spawn (`getActiveSpawnCount` returns 1 at the spawn peak); both clients receive identical chunk sequences in order.
- 3 concurrent, mid-stream join — client 2 attaches mid-stream, receives accumulated burst + live tail; client 3 attaches after source-complete, served from cache.
- First-client disconnect mid-stream, clients 2/3 continue, source NOT aborted.
- All clients disconnect mid-stream → source aborted (AbortController fired), no cache write.
- Source errors mid-stream → all attached clients receive the error; no cache write.
- Backpressure: slow client → `PER_CLIENT_QUEUE_CAP` exceeded → `STREAM_BACKPRESSURE` disconnect with `finish_reason: 'length'`; other clients unaffected.
- D38 semaphore: 2 concurrent identical streams hitting `maxConcurrent=1` — first spawns, second JOINS (does not get CONCURRENCY_LIMIT). 3 concurrent DIFFERENT streams hitting `maxConcurrent=2` — first 2 spawn, third gets CONCURRENCY_LIMIT (existing D38 path).
- Cache TTL race: entry expires during inflight; late joiner attaches via inflight join; inflight completion overwrites the expired cache slot.
- Replay buffer cap: source produces > `ACCUMULATED_REPLAY_CAP`; entry marked not cacheable; late joiner past cap gets `STREAM_BACKPRESSURE`; first caller stream unaffected.
- X-OLP-Streaming-Inflight header values across all the above scenarios.
14. **Defaults to ratify in implementation.** `PER_CLIENT_QUEUE_CAP = 1 MB`, `ACCUMULATED_REPLAY_CAP = 10 MB` (matches D23 cache-entry size cap), `STREAM_BACKPRESSURE` not in `HARD_TRIGGER_CODES`. These are starting points; v1.x implementation may tune based on real-world latency profiles.
**Issue #16 status.** Stays OPEN as the v1.x implementation tracker. The issue body should be updated post-D42 to reference this amendment and adjust scope ("design ratified; implementation pending"). DO NOT close issue #16 until §13's test surface is green on the actual implementation.
**Cross-references and safeguards (so the implementation is not forgotten):**
- `docs/v1x-roadmap.md` (new at D42) — single landing page for all v1.x deferrals, with streaming SF as item #1. Each entry cross-references the relevant ADR amendment and the GitHub issue.
- `lib/cache/store.mjs` — TODO comment near `getOrCompute` pointing at this amendment for the streaming sibling API.
- `server.mjs` — TODO comment near the streaming branch entry (line ~810) pointing at this amendment + issue #16 with the words "ADR 0005 Amendment 8 — v1.x".
- `README.md § Known limitations` — line item exposing this to users (current behavior: each concurrent identical streaming request spawns its own CLI).
- This amendment is item #1 in the next session-start handoff if the maintainer opens a v1.x sprint.
**Why this is the right shape (rationale):**
- Mirrors the D4 buffered-path singleflight (`getOrCompute`) pattern, keeping the cache API surface coherent rather than fragmenting into two parallel coordination primitives.
- Reuses D38 `tryAcquireSpawn` semantics for the first-caller path; attached callers naturally don't consume slots.
- Late-joiner replay via `accumulatedChunks` resolves the case where a client arrives between source-spawn and source-complete without forcing it to wait for completion.
- Bounded per-client queue protects against the "one slow client stalls the source" failure mode; the slow client gets a clean `STREAM_BACKPRESSURE` disconnect instead of corrupting the tee for other clients.
- AbortController propagation ensures the source CLI process is reaped if all clients drop — no orphan processes consuming Anthropic/Codex/Mistral quota.
**Authority:**
- Amendment 6 (D34 F1) — original deferral with "design alone warrants a dedicated ADR" note; this amendment is the dedicated ADR.
- GitHub issue #16 (round-6 F13) — sibling TOCTOU window; same root cause.
- ADR 0002 Amendment 6 (D38) — `tryAcquireSpawn` / `releaseSpawn` semantics that the §7 coordination builds on.
- D40 Amendment 5 — per-hop observability pattern that §11 extends to streaming.
- CC 开发铁律 v1.6 § 10.x — design ADR ratification; fresh-context reviewer not required for design-only amendments (no code change in D42).
**Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (design-only amendment per Iron Rule 10's implementation-phase scope — the implementation PR that lands this ADR's design will go through full Iron Rule 10 with a fresh-context opus reviewer at that time).
### Amendment 7 — 2026-05-24: Document cache-key-vs-CLI-args discrepancy as v0.1 conservative trade-off (D34 F8) ### Amendment 7 — 2026-05-24: Document cache-key-vs-CLI-args discrepancy as v0.1 conservative trade-off (D34 F8)
- **Finding:** Round-6 cold-audit F8 (P2) — Provider plugins (`lib/providers/anthropic.mjs`, `codex.mjs`, `mistral.mjs`) drop `temperature`, `max_tokens`, `top_p`, `stop`, `tools`, and `tool_choice` when spawning their respective CLIs. These CLIs (`claude -p`, `codex exec --json`, `vibe --prompt`) do not accept those flags. However, the cache key (per Amendment 2) includes all of them. Consequence: two requests that differ only in `temperature` produce identical CLI output (the CLI ignores it) but different cache keys → spurious miss. The caller pays the spawn cost twice for what is, at the CLI layer, the same request. - **Finding:** Round-6 cold-audit F8 (P2) — Provider plugins (`lib/providers/anthropic.mjs`, `codex.mjs`, `mistral.mjs`) drop `temperature`, `max_tokens`, `top_p`, `stop`, `tools`, and `tool_choice` when spawning their respective CLIs. These CLIs (`claude -p`, `codex exec --json`, `vibe --prompt`) do not accept those flags. However, the cache key (per Amendment 2) includes all of them. Consequence: two requests that differ only in `temperature` produce identical CLI output (the CLI ignores it) but different cache keys → spurious miss. The caller pays the spawn cost twice for what is, at the CLI layer, the same request.
+420
View File
@@ -0,0 +1,420 @@
# ADR 0007 — Multi-Key Auth (`lib/keys.mjs`)
- **Date:** 2026-05-25
- **Status:** Accepted (D43-B, design-only — implementation D-days D44+ follow)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:**
- OLP v0.1 spec § 4.5 (Auth & multi-key) — the planning authority for the `~/.olp/` layout used in § 3 below
- ADR 0001 (project founding) — single-tenant family-scale framing; this ADR keeps that framing while enabling multi-identity isolation within a single deployment
- ADR 0002 (plugin architecture) — `hints.cacheable` opt-out demonstrates the per-plugin gating pattern this ADR extends to per-key gating
- ADR 0004 Amendment 5 (D40 `X-OLP-Fallback-Detail`) — explicitly defers owner-only header gating to "Phase 2 when `lib/keys.mjs` lands"; this ADR is that landing event
- ADR 0005 (cache cross-provider) — D1 per-key isolation: the cache layer is already keyed by `keyId` (`Map<keyId, Map<cacheKey, CacheEntry>>` at `lib/cache/store.mjs:77-79`); this ADR fills the `keyId` source that is hardcoded to `'__anonymous__'` in `server.mjs` at v0.1.1
- **Prior-art authority:** OCP `keys.mjs` (at the maintainer workstation `~/ocp/keys.mjs`, OCP v3.13.0 production reference) — model adapted; storage strategy diverges per § 11 below
- **Phase 2 provenance:** `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` (committed in `cc-rules` `d9da966`) captures the catch-up brief, lane separation, and the four amendments the maintainer pinned during D43-B drafting
---
## 1. Context
OLP v0.1.1 ships with the cache namespace hardcoded to `'__anonymous__'` (server.mjs ~L502 buffered handler, ~L531 streaming handler). The cache data model in `lib/cache/store.mjs` is already segmented by `keyId` (per-key Map + per-key stats + per-key singleflight key composition), but the identity layer that produces a real `keyId` does not exist yet.
Phase 2 of OLP introduces multi-key authentication so a single OLP deployment can serve multiple human users (e.g., maintainer + family members + a CI client) with:
- **Per-key cache namespace isolation** — already wired in `store.mjs`; only the `keyId` source needs to land.
- **Per-key audit trail** — which key issued which request, what provider/model served it, what fallback / cache outcome resulted.
- **Per-key provider access scoping** — each key declares which providers it may invoke (`providers_enabled`).
- **Owner-vs-guest gating** for debug/observability surfaces that should not leak to non-owner identities, specifically:
- `/health` — currently returns full per-provider details to any caller. README has long claimed `/health` is owner-only (README.md § API Endpoints), but no auth gate has shipped. Phase 2 closes that gap.
- `X-OLP-Fallback-Detail` — D40 / ADR 0004 Amendment 5 explicitly shipped the header **ungated** at v0.1 with the note "Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands."
OCP solved an adjacent problem with `keys.mjs` (~417 LOC, SQLite-backed, single-tenant LAN mode). OLP cannot port that code verbatim — see § 11 (Node runtime baseline) — but the model (opaque key + per-key namespace + per-key audit + per-key quota) is the prior art this ADR adapts.
**Phase 2 is not a v1.x cross-phase deliverable.** `docs/v1x-roadmap.md` lists seven deferred items; only **#2 (multi-key auth)** is the Phase 2 mainline. The others (#1 streaming SF, #3 soft triggers, #4 `/health` `activeSpawns`, etc.) are triggered on demand and stay on the v1.x tracker.
---
## 2. Decision
OLP Phase 2 ships **filesystem-only multi-key auth with opaque tokens**, structured for migratability to a SQLite-indexed model when Phase 3+ Dashboard / SQL-aggregate quota work justifies that change.
Three load-bearing choices:
| Axis | Choice | Rationale |
|---|---|---|
| **Storage** | Filesystem manifest per key (`~/.olp/keys/<key-id>/manifest.json`) + append-only audit ndjson (`~/.olp/logs/audit.ndjson`) | Matches v0.1 spec § 4.5 layout; zero-dep within current Node baseline (§ 11); trivially backed-up / human-inspectable / git-crypt-encryptable; per-key isolation natural via filesystem hierarchy |
| **Token format** | Opaque `olp_<32-byte base64url>`; manifest stores SHA-256 hash, never plaintext | Mirrors OCP's `ocp_<24-byte>` opaque pattern; revocation is single-record (no JWT revocation-list problem); validation is single manifest read; family-scale has no stateless-validation pressure |
| **Migration lane** | Manifest remains the declarative SPOT in all future revisions; SQLite (if added in Phase 3+) becomes a query-side index synced on every manifest/audit write | Forward path documented in § 13 — never a single-direction door; manifest schema is always source of truth |
The decision **rejects** three plausible alternatives:
- **Option 1 (direct SQLite port from OCP)** — rejected at v0.2.0 because of a runtime baseline mismatch documented in § 11, not because of any flaw in SQLite or in OCP's design.
- **JWT tokens** — rejected because OLP has no stateless-validation pressure (the deployment is a single Node process; one manifest read per request is cheaper than the JWT-issuance / rotation / revocation-list infrastructure).
- **Auto-detected "dev mode" anonymous fallback** — rejected because behavioural divergence based on heuristics (NODE_ENV, hostname, port, etc.) creates security-incident-prone surprises. Anonymous access is an explicit configuration toggle (§ 7) or it does not happen.
---
## 3. Storage layout (`~/.olp/`)
The layout below is normative for v0.2.0. Each path is binary in spec — present-and-honored or absent-and-defaulted. No path may be silently created with a different name.
```
~/.olp/
config.json — top-level config (existing); gains `auth` block per § 7
keys/ — chmod 0700; per-key SPOT
<key-id>/
manifest.json — chmod 0600; JSON; schema in § 4
logs/ — chmod 0700
audit.ndjson — chmod 0600; one JSON event per line; schema in § 8
providers/ — existing; per-provider auth artifact root
anthropic/credentials.json
openai/codex_token.json
mistral/api_key.env
cache/ — file-backed cache (📋 v1.x); chmod 0700 when introduced
```
**`<key-id>` format.** Lowercase alphanumeric + hyphen + underscore, 832 chars, generated by the keygen command. NOT derived from the secret token — `<key-id>` is the public namespace identifier (cache key prefix, audit `key_id` field, log correlator); the secret token is separate.
**Why a directory per key (vs one `keys.json` index file).** Future per-key augmentation (per-key cache index, per-key provider-specific auth override, per-key rate-limit state) can land as additional files in `keys/<key-id>/` without re-writing a shared index. The directory is the namespace.
---
## 4. Manifest schema (`keys/<key-id>/manifest.json`)
```json
{
"schema_version": 1,
"id": "<key-id>",
"name": "<human-label>",
"token_hash": "<sha256-hex of the opaque token>",
"token_hash_algo": "sha256",
"owner_tier": "owner" | "guest",
"providers_enabled": ["<provider-key>", ...] | "*",
"quota": null,
"created_at": "<ISO-8601 UTC>",
"revoked_at": null | "<ISO-8601 UTC>",
"last_used_at": null | "<ISO-8601 UTC>",
"notes": "<optional free-form>"
}
```
Field semantics:
- **`schema_version`** — `1` at v0.2.0. Increment on any non-additive change to this schema. Implementation reads `schema_version` first; rejects unrecognized versions with a clear error.
- **`id`** — matches the parent directory name. If they disagree, validation fails (`manifest_id_mismatch`).
- **`name`** — human label for `olp keys list` output. Required, non-empty.
- **`token_hash`** — SHA-256 of the plaintext token (lowercase hex). The plaintext token is NEVER stored.
- **`token_hash_algo`** — `"sha256"` at v0.2.0. Schema-versioned forward-compat for future algorithm rotation.
- **`owner_tier`** — `"owner"` grants full /health + X-OLP-Fallback-Detail visibility; `"guest"` does not (§ 7).
- **`providers_enabled`** — array of provider keys (matching `models-registry.json` provider entries) OR literal `"*"` for all providers. Empty array `[]` means the key can authenticate but cannot dispatch any provider call (returns 403 with `key_no_provider_access`).
- **`quota`** — `null` at Phase 2 (no enforcement). Reserved for Phase 3+ quota work. Implementations MUST read `null` as "no quota enforcement"; non-null shapes are deferred to a Phase 3 ADR amendment.
- **`created_at`** — set at key creation; never modified.
- **`revoked_at`** — `null` while active; set to current timestamp on revocation. Revoked keys fail validation with `401 key_revoked`; their manifest stays on disk for audit attribution.
- **`last_used_at`** — updated on successful validation. Best-effort (lazy write OK; failure to update does NOT fail the request — § 6).
- **`notes`** — optional; useful for "spouse's laptop", "Pi staging", etc.
**Schema rigidity.** Unrecognized fields cause a warn but not a reject (forward-compat). Missing required fields cause a reject (`manifest_invalid`).
---
## 5. Token format
```
olp_<32 bytes from crypto.randomBytes, base64url-encoded, no padding>
```
- Prefix `olp_` is fixed (mirrors OCP's `ocp_`); enables grep / regex detection in logs / secret-scanners.
- 32 bytes = 256 bits of entropy. base64url-encoded = 43 characters; total token length = 47 characters including prefix.
- Hash with `crypto.createHash('sha256')` over the full token string (prefix included). Hex-lowercase the digest for `manifest.token_hash`.
**Why SHA-256, not argon2/bcrypt.** Argon2-class slow hashes are for low-entropy secrets (passwords). A 256-bit random token has no brute-force exposure in the relevant attack-cost model; SHA-256 is sufficient and ~6 orders of magnitude faster, which matters because validation runs on every request.
**No plaintext storage, ever.** The plaintext token leaves the keygen command (printed to stdout once) and the authenticated request (HTTP header). It is never logged, never written to manifest, never written to audit. The only persistent representation is the hash in `manifest.token_hash`.
---
## 6. Atomic write & audit append
Two distinct write surfaces with different semantics:
### 6.1 Manifest writes (lifecycle events only)
Manifest writes fire ONLY on key lifecycle events: `createKey`, `revokeKey`, `updateKey` (e.g., setting `providers_enabled`), and `touchLastUsed` (the lazy `last_used_at` update). Manifest is **not** written per request — per-request state goes to audit.
Atomic write pattern (POSIX):
1. Compute target path: `~/.olp/keys/<key-id>/manifest.json`.
2. Write to tmpfile in same directory: `~/.olp/keys/<key-id>/manifest.json.tmp.<pid>.<counter>`.
3. `fsync()` the tmpfile fd.
4. `rename()` tmpfile → final path (same-filesystem atomic).
5. Directory mode 0700, file mode 0600 enforced on every write.
POSIX-strict atomic-replace also requires `fsync()` on the containing directory after the rename to guarantee survival of an OS crash mid-flush. Phase 2 deliberately omits the directory fsync: the single-process family-scale deployment model accepts a tiny window where a rename can be lost under abrupt host crash. The trade-off is documented here so a future POSIX-strict deployment knows where to add the step.
Failure semantics:
- Step 2/3/4 failure → throw; caller handles. Lifecycle commands (`olp keygen` / `olp keys revoke`) report failure to the operator and exit non-zero. Server requests do not trigger lifecycle writes (the `touchLastUsed` path is best-effort — see 6.3).
### 6.2 Audit ndjson appends (per-request)
Per-request audit events append a single newline-terminated JSON object to `~/.olp/logs/audit.ndjson`.
Append pattern:
1. Serialize event (§ 8 schema) with trailing `\n`. Serialization fires AFTER `status_code` is determined and `latency_ms` is measured (i.e., after the request handler emits the response, around `res.end()` finalization). This pinning is what makes acceptance criterion #2 testable — the 401-on-anonymous case records `status_code: 401` + `latency_ms` in the same audit event.
2. `fs.appendFile(path, line, { mode: 0o600 })` (Node default opens with append flag).
3. On EAGAIN / EBUSY / ENOSPC: log warn `audit_append_failed_once` + retry once (synchronous, no backoff at Phase 2 — family-scale write rate makes contention rare).
4. On second-failure: log warn `audit_append_dropped` with the failure reason + a per-process drop counter; **do not block the request**; **do not buffer** (memory buffer is a forward path in § 13, deliberately not in Phase 2 scope).
Failure semantics:
- Audit append failure NEVER fails the request. Auditing is observability, not authorization.
- Dropped audit events surface via the warn log and the dropped-count metric (exposed in /health owner-tier view per § 7).
### 6.3 `last_used_at` lazy update (revoke-dominates-touch)
The `touchLastUsed` write goes through the same atomic-write pattern as 6.1, but is fired async after request response is dispatched. Failure logs warn `last_used_update_failed` and does NOT fail the request.
**Read-modify-write with revoke preservation.** `touchLastUsed` MUST:
1. Re-read the latest manifest from disk inside the per-key write-lock (§6.4) — not reuse the snapshot the validating request held.
2. If `revoked_at` is non-null in the freshly-read manifest, NO-OP (do not write). A revocation occurred between request validation and this lazy touch; the request was the last legitimate use of the now-revoked key.
3. Otherwise, merge the new `last_used_at` value into the freshly-read manifest, preserving ALL other fields including `revoked_at`, and write via the atomic-rename pattern.
This protects against the failure mode where a stale manifest snapshot held by the touch path overwrites a fresh revoke and silently clears `revoked_at` back to `null`. The safety property is **revoke dominates touch**: any ordering of CLI revoke and server-side `touchLastUsed` (revoke-then-touch, touch-then-revoke, or interleaved) leaves a revoked manifest. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest under concurrent CLI revoke + in-flight server request.
### 6.3.5 No in-process validation cache (Phase 2)
Token validation MUST hit the manifest on every authenticated request at Phase 2 — implementations MUST NOT introduce an in-process LRU / TTL cache of validation results. Rationale: revocation must take effect on the next request without an invalidation hop; the family-scale request rate makes per-request manifest read O(1) on the OS file-system cache. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest. A validation cache is a forward-path consideration if Phase 3+ load profile demands it; a separate ADR amendment ratifies the cache shape + invalidation contract before any cache code lands.
### 6.4 Locking (single-process Phase 2)
OLP at v0.2.0 is a single Node process per host. Concurrent manifest writes from inside the process are serialized via an in-process Map of per-key write-locks (`Map<key-id, Promise>`). Concurrent writes from outside the process (e.g., maintainer running `olp keys revoke` while server is running) are not protected by file locks at Phase 2.
Safety frame: the atomic-rename pattern guarantees corruption-free file content (no partial-merge state on disk), and the **read-before-write discipline in §6.3** makes the worst case "stale `last_used_at` field" (observability-grade) rather than "revoked_at silently cleared" (security-grade). Without §6.3, a touch carrying a pre-revoke snapshot could overwrite revoke and break acceptance criterion #6; with §6.3, any interleaving of revoke and touch leaves a revoked manifest. The CLI `revoke` writer always wins the dimension that matters; the touch writer may lose its `last_used_at` update if it raced a revoke (acceptable — the revoked key will not be used again).
Forward path: file-locking (`flock(2)`) is reserved if a future Phase introduces multi-writer scenarios (e.g., a setup wizard process running alongside the server). With multi-writer, §6.3's read-before-write still holds the revoke-dominates-touch contract; file-locking only adds defense-in-depth against rare time-of-check-time-of-use windows where two processes both re-read a non-revoked manifest, then both write back with the touch path silently dropping a concurrent in-flight revoke from a third writer.
---
## 7. Owner / guest / anonymous gating
### 7.1 The three identity classes
| Class | Source | Cache namespace | `/health` visibility | `X-OLP-Fallback-Detail` visibility | `/v1/chat/completions` |
|---|---|---|---|---|---|
| **owner** | Valid OLP key with `owner_tier: "owner"` | per-key (`<key-id>`) | full per-provider details | yes (header emitted) | yes |
| **guest** | Valid OLP key with `owner_tier: "guest"` | per-key (`<key-id>`) | trimmed (`{ status, version }` only) | no (header suppressed) | yes (scoped by `providers_enabled`) |
| **anonymous** | No `Authorization` / `x-api-key` header, AND `config.json auth.allow_anonymous: true` | `__anonymous__` (shared) | trimmed (`{ status, version }` only) | no (header suppressed) | yes |
When `auth.allow_anonymous: false` (default) and no key is presented, all routes return `401 auth_required`.
### 7.2 Configuration
`~/.olp/config.json` gains an `auth` block:
```json
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": ["/health", "/v0/management/quota"],
"fallback_detail_header_policy": "owner_only"
}
}
```
- **`allow_anonymous`** — default `false`. When `true`, requests without a key are accepted and namespaced under the legacy `__anonymous__` cache keyId.
- **`owner_only_endpoints`** — list of HTTP paths returning trimmed payloads to non-owner identities. `/health` is the canonical example.
- **`fallback_detail_header_policy`** — `"owner_only"` (default) emits `X-OLP-Fallback-Detail` only to owner tier. `"all"` reverts to v0.1.1 ungated behaviour. `"none"` suppresses unconditionally. The policy is the v0.1.1 → v0.2.0 migration knob for operators who want to delay re-gating.
### 7.3 Environment-based behaviour is rejected
Phase 2 deliberately does NOT auto-detect "dev" vs "production" via `NODE_ENV`, `hostname`, port, or any other heuristic. The rule is: `config.json auth.allow_anonymous` is the truth, and the operator sets it explicitly. Behavioural divergence on environment heuristics is a known source of "it works locally" security incidents and is out of scope by design.
---
## 8. Audit ndjson schema
One JSON object per line (newline-terminated, UTF-8), written by the per-request audit-append path (§ 6.2).
```json
{
"ts": "<ISO-8601 UTC>",
"key_id": "<key-id>" | "__anonymous__" | "__env_owner__",
"owner_tier": "owner" | "guest" | "anonymous",
"method": "POST" | "GET" | ...,
"path": "/v1/chat/completions" | "/v1/models" | ...,
"provider": "<provider-key>" | null,
"model": "<requested-model>" | null,
"status_code": 200 | 401 | 503 | ...,
"latency_ms": <int>,
"cache_status": "hit" | "miss" | "bypass" | null,
"fallback_hops": <int>,
"tried_providers": ["<provider-key>", ...],
"error_code": null | "<ProviderError code>",
"ir_request_hash": "<short hex>" | null,
"chain_id": "<correlator>" | null
}
```
Field origin:
- `ts` / `key_id` / `owner_tier` — set by the auth middleware.
- `method` / `path` / `status_code` / `latency_ms` — set by the request handler.
- `provider` / `model` / `cache_status` / `fallback_hops` / `tried_providers` / `error_code` — sourced from the existing D28 per-hop log fields (no new computation; same shapes the structured log already exposes).
- `ir_request_hash` / `chain_id` — sourced from D28 fields directly; enable join across audit, structured log, and the `X-OLP-Fallback-Detail` tuple.
**No PII.** Audit deliberately captures NO request body, NO response body, NO IR-message content. Hash + shape only. This is a personal/family deployment property; do not relax without a separate ADR amendment.
**Rotation.** Phase 2 does NOT rotate `audit.ndjson`. Rotation policy lands in Phase 3 alongside Dashboard / audit query work (§ 12).
---
## 9. Bootstrap & recovery
### 9.1 Minimal keygen command surface
Phase 2 MUST ship at least one executable entry that:
1. Generates an opaque OLP token (§ 5 format).
2. Computes its SHA-256 hash.
3. Writes a `keys/<key-id>/manifest.json` per § 4 with `owner_tier: "owner"` (first key) or as specified by flag.
4. Prints the plaintext token to stdout **exactly once**. The token is otherwise never logged.
5. Returns non-zero on any failure (manifest path conflict, filesystem permission, etc.).
The concrete shape — `npx olp keygen --owner`, `node bin/keygen.mjs --owner`, `node lib/keys/cli.mjs keygen --owner`, etc. — is an implementation choice and lands at D44 or D45. ADR 0007 does not pin the shape; it pins the requirement that the surface exists and is reproducible without manual file editing.
### 9.2 First-run flow
When `~/.olp/keys/` is empty AND `auth.allow_anonymous: false` (defaults), the server refuses to start `/v1/chat/completions` requests with a clear `401 no_keys_configured` until the operator runs the keygen command. The server itself does NOT auto-generate a key on first run — explicit operator action is required so the plaintext-once contract (§ 9.1 step 4) is honored on a terminal the operator can see.
When `~/.olp/keys/` is empty AND `auth.allow_anonymous: true`, the server starts normally and serves all requests under `__anonymous__`. Useful for dev / single-user-no-multi-tenancy deployments.
### 9.3 Owner key loss / rotation
If the operator loses their owner token, recovery is `<keygen-command> --owner --force`:
1. Generate a fresh owner key (new `<key-id>`, new plaintext).
2. Mark all existing `owner_tier: "owner"` keys' `revoked_at` to current timestamp. (Existing guest keys are not affected.)
3. Print the new plaintext once.
The old token is permanently invalid after revocation; the manifest stays on disk for audit attribution.
### 9.4 `OLP_OWNER_TOKEN` environment override
For headless / CI / containerized deployments, the env var `OLP_OWNER_TOKEN` is honored:
- Server startup reads `OLP_OWNER_TOKEN`. If set, the value is treated as a synthetic owner identity with stable `key_id: "__env_owner__"`.
- The plaintext token is NEVER logged, NEVER written to manifest, NEVER written to audit. The raw token leaves the env var and the request `Authorization` header only.
- Cache namespacing uses `__env_owner__` as the `keyId`, isolating env-owner traffic from filesystem-owner traffic.
- Audit attribution uses `key_id: "__env_owner__"` and `owner_tier: "owner"`.
- Server startup logs warn `non_persistent_owner_token` with no token material, alerting the operator that the env-owner identity will disappear on restart unless re-set.
Filesystem-stored owner keys (from § 9.1/9.2) continue to validate independently when `OLP_OWNER_TOKEN` is set; the env-owner is an additive credential, not a replacement.
**Token-collision policy.** Hash-collision between an `OLP_OWNER_TOKEN` plaintext and a filesystem-stored key's plaintext is undefined behaviour at Phase 2 (cache namespacing would diverge silently between `__env_owner__` and the filesystem `<key-id>`, while audit attribution would split). Operators MUST NOT reuse the same plaintext token across both surfaces. A future Phase MAY add a collision-detection startup check; not in Phase 2 scope.
---
## 10. Acceptance criteria
Implementation D-days (D44+) MUST land tests covering:
1. **Per-key cache isolation** — Two keys A and B with identical request payloads do NOT share cache. `cache_status` is `miss` for both first calls and `hit` for the second call from the SAME key only.
2. **Anonymous prod-default off** — With `auth.allow_anonymous: false` (no override), a request without a key receives `401 auth_required`; the audit event is recorded with `key_id: "__anonymous__"` and `status_code: 401`.
3. **Anonymous dev-mode on** — With `auth.allow_anonymous: true`, the same request succeeds with `keyId="__anonymous__"`.
4. **Owner-vs-guest /health gating (with default `auth.owner_only_endpoints` config)** — Owner key sees the full per-provider `providers` map in `/health`; guest key + anonymous see only `{ status, version }`. Test rephrases if the operator's `owner_only_endpoints` config does not include `/health` (test must assert the same gating predicate the config produces, not a hardcoded trimmed payload shape).
5. **Owner-vs-guest X-OLP-Fallback-Detail gating** — Same response payload for both owner and guest; header present for owner only.
6. **Key revocation** — After `revoke`, subsequent requests with that token return `401 key_revoked` within the next request (no caching of validation).
7. **Manifest atomicity + revoke-dominates-touch (§ 6.3, § 6.4)** — Concurrent `revoke` + `touchLastUsed` writes do not corrupt the manifest AND revoke always survives. Test: spawn two writers racing on the same key (revoke vs `touchLastUsed`) under three orderings — revoke-then-touch, touch-then-revoke, and interleaved (touch reads pre-revoke snapshot, then revoke writes, then touch attempts write). For all three orderings, assert: (a) final file parses as valid JSON; (b) `revoked_at` is non-null and equals the revoke writer's timestamp; (c) `last_used_at` may have either writer's value. The test FAILS if any interleaving produces `revoked_at: null` after the revoke writer completed. This pins the §6.3 read-before-write discipline.
8. **Audit ndjson round-trip** — Every line in `audit.ndjson` parses as valid JSON; every required field present; PII fields (message content, response content) absent.
9. **Bootstrap keygen surface** — The minimal keygen command (whatever shape D44 chooses) runs end-to-end without manual file editing, produces a working owner key, and prints the plaintext exactly once.
10. **`OLP_OWNER_TOKEN` env override** — With the env var set, a request bearing the env token validates as `keyId="__env_owner__"` with `owner_tier="owner"`; the raw token does NOT appear in any log line, audit event, or stack trace.
11. **`providers_enabled` scope enforcement** — A guest key with `providers_enabled: ["anthropic"]` requesting `model` that routes to `openai` receives `403 key_no_provider_access` and an audit event with the rejection reason.
---
## 11. Node baseline / storage portability
Option 2 (filesystem-only) was chosen at v0.2.0 over Option 1 (direct port of OCP's SQLite-backed `keys.mjs`) because of a runtime-baseline mismatch, not a critique of SQLite or of OCP's design.
Evidence:
- OLP `package.json` declares `engines.node` `">=18"` (file line 11).
- CI test matrix in `.github/workflows/test.yml` runs Node 20 and 24 (file line 13).
- `node:sqlite` was added in Node **v22.5.0**; v22.12 still required the `--experimental-sqlite` runtime flag to import; current Node API docs mark the module as **Release Candidate** (post-experimental but pre-stable). Source: https://nodejs.org/api/sqlite.html (retrieved during D43-B drafting 2026-05-25).
Adopting `node:sqlite` at v0.2.0 would require, in this order:
1. Raise `engines.node` to a version where the API is at minimum non-flag-gated. Per Node's release-history docs — v22.5.0 added the API behind `--experimental-sqlite` (source: https://nodejs.org/download/release/v22.12.0/docs/api/sqlite.html confirms v22.12 still required the flag); the module moved past flag-gating in **v22.13.0 (LTS line)** and **v23.4.0 (current line)**; the API entered **Release Candidate at v25.7.0** per current docs (https://nodejs.org/api/sqlite.html). The minimum non-flag-gated baseline for `engines.node` is therefore `>=22.13.0` (or `>=23.4.0` on the non-LTS path). A stable (post-RC) baseline is TBD pending future Node releases beyond v25.x.
2. Update the CI test matrix to drop Node 20 (or move the SQLite-using code behind a runtime feature check that exercises both code paths in CI).
3. Accept Release-Candidate API stability risk in the project's storage layer for the period until the API moves to stable.
These three are achievable but are not zero-cost and have second-order effects (e.g., existing Node 20 deployments by family clients break on upgrade). Phase 2 does not undertake them; § 13 documents the forward path.
**Decision posture statement.** "SQLite is good; the runtime baseline says not yet."
---
## 12. Out of scope (Phase 3+)
The following are deliberately deferred from Phase 2 and tracked elsewhere:
- **Dashboard (`dashboard.html`)** — owner-only multi-provider quota / fallback / cache-hit-rate panels. Deferred to **Phase 3**. (Was originally bundled into "Phase 6" in the pre-v0.1.1 README phase plan; the post-D43-A plan re-aligned this to Phase 3.)
- **Quota enforcement (`manifest.quota` non-null shapes)** — manifest schema reserves the field; semantics + enforcement land in a Phase 3 ADR amendment.
- **Audit query layer / rotation** — `audit.ndjson` is append-only at Phase 2; rotation policy + indexed query lands with Dashboard work (Phase 3).
- **Per-key per-provider auth artifact mapping** — Phase 2 uses the global `~/.olp/providers/<name>/` artifacts for all keys. Per-key override (e.g., two OLP keys each authenticated to a different OpenAI Codex account) is a Phase 3+ concern; the spec § 4.5 phrasing "Multi-key support per provider" anticipates this without locking the design.
- **Audit memory buffer on append failure** — see § 6.2 note; deliberate forward-path-only.
- **File-locking (`flock(2)`)** — see § 6.4 note.
---
## 13. Future forward — Option 3 migration (Phase 3+)
When Dashboard / SQL-aggregate quota / >5 users / multi-second audit-query workload arrives, OLP's storage layer migrates to a **hybrid** model that retains manifest as the declarative SPOT and adds a SQLite-indexed query mirror.
Required preconditions BEFORE any migration commit:
1. A separate prior PR raises `engines.node` and updates the CI matrix per § 11. This PR ships independently of any storage change.
2. An ADR amendment to this file documents the migration trigger (which of the criteria above fired) and the schema mapping from manifest → SQLite rows.
3. The migration code is a one-shot sync that reads every existing manifest, replays the audit log, populates SQLite from scratch, then begins dual-writing. Manifest writes remain authoritative; SQLite is rebuildable from manifest + audit at any time.
The migration is one-way (additive — SQLite gets added; manifest stays). Reverting from hybrid to manifest-only is supported by stopping SQLite writes and deleting the DB file.
**Forward-path audit memory buffer.** If audit append failures become non-rare (operational hint: `audit_append_dropped` count exceeds threshold in /health), Phase 3+ may add an in-process bounded buffer that flushes opportunistically. The buffer's design (size cap, flush interval, persistence on shutdown) is out of scope for Phase 2 and is a separate ADR amendment.
---
## Consequences
**Positive:**
- Closes the long-standing `lib/keys.mjs` 📋-Planned gap in AGENTS.md / README.md / v1x-roadmap.md.
- Lets D40 `X-OLP-Fallback-Detail` re-gate per its v0.1 deferral note.
- Lets README's long-standing claim "/health is owner-only" become factually true.
- Per-key cache namespacing becomes observable behaviour (was a latent affordance only).
- Family members can each have their own OLP key without sharing cache state.
- Audit trail per request enables troubleshooting questions ("did my call hit cache?", "which key triggered the fallback to mistral?") without inspecting logs.
**Negative / trade-offs:**
- Filesystem audit is O(N) for any aggregate query — acceptable until Phase 3 Dashboard work.
- Manifest atomicity at multi-writer scale is not bulletproof — see § 6.4; mitigated by the single-process Phase 2 deployment model.
- The plaintext-once contract puts UX burden on the keygen command output — operators must capture the token immediately on creation; lost = revoke + regenerate.
- Existing OCP users migrating will need new OLP keys (OCP's SQLite-backed keys are not portable to OLP's manifest layout — § 9 "Migration from OCP" in `scripts/migrate-from-ocp.mjs` 📋 Phase 7 may add a one-shot translator; not in Phase 2 scope).
**Reversibility:**
- Migration to Option 3 hybrid (§ 13) is supported and explicitly planned.
- Reverting Phase 2 entirely would require restoring the `__anonymous__` hardcoding in `server.mjs` and removing the auth middleware. The decision is reversible but no concrete trigger has been imagined; the decision is treated as durable.
---
## Authority citations
- **OLP v0.1 spec § 4.5** (planning authority for `~/.olp/` layout in § 3) — at `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations.
- **OCP `keys.mjs`** (prior-art reference for opaque-key + per-key isolation model) — at `~/ocp/keys.mjs` on the maintainer's workstation; OCP v3.13.0 production.
- **Phase 2 kickoff handoff** (decision provenance for Option 2 + opaque + four amendments) — `~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` committed in `cc-rules` `d9da966`.
- **Node `node:sqlite` documentation** (rejection rationale for Option 1 in § 11) — https://nodejs.org/api/sqlite.html (retrieved 2026-05-25).
- **`lib/cache/store.mjs:77-79, :287`** (proof that per-keyId namespace + singleflight composition are wired and ready to receive a real `keyId`).
- **`server.mjs:502, :531`** (the two hardcoded `'__anonymous__'` call sites Phase 2 implementation replaces).
- **`server.mjs:392, :1072, :1101`** (the three call sites Phase 2 implementation gates: `/health` handler entry and the two `X-OLP-Fallback-Detail` header-write paths).
- **ADR 0004 Amendment 5** (D40 ungated header + Phase 2 re-gating deferral) — `docs/adr/0004-fallback-engine.md`.
- **CLAUDE.md `release_kit.phase_rolling_mode` `current_pre_release_identifier`** = `"0.2.0-phase2"` — confirms this ADR lands in the Phase 2 sprint.
+1
View File
@@ -20,6 +20,7 @@ New ADRs increment from the highest existing number. Filenames are `NNNN-<short-
| [0004](0004-fallback-engine.md) | Fallback Engine Semantics & Safety | Trigger taxonomy (Hard / Soft / Deterministic-deferred / Cost-aware-deferred), idempotent-failure safety (first-chunk rule), chain advancement one-at-a-time, observability headers. | | [0004](0004-fallback-engine.md) | Fallback Engine Semantics & Safety | Trigger taxonomy (Hard / Soft / Deterministic-deferred / Cost-aware-deferred), idempotent-failure safety (first-chunk rule), chain advancement one-at-a-time, observability headers. |
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). | | [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. | | [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded by default / B explicit consent / C opt-in / D eligible-for-default-enabled), Candidate-vs-Enabled distinction, current v0.1 candidate inventory (0 Enabled), Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction; pending primary-source pin), consent UX, future provider addition procedure. |
| [0007](0007-multi-key-auth.md) | Multi-Key Auth (`lib/keys.mjs`) | Phase 2 design ADR (D43-B, 2026-05-25). Option 2 (filesystem manifest at `~/.olp/keys/<key-id>/manifest.json`) + opaque `olp_<32-byte>` token + SHA-256 hash. Owner / guest / anonymous tier gating with explicit `config.json auth.allow_anonymous` (default false). Bootstrap keygen command surface + `OLP_OWNER_TOKEN` env override with stable synthetic `key_id`. Audit ndjson append-only at `~/.olp/logs/audit.ndjson`, warn+1-retry on append failure. Rejects direct SQLite port at v0.2.0 due to Node baseline (`engines >=18` + CI 20/24 vs `node:sqlite` added 22.5.0 / RC); Option 3 hybrid documented as forward path when Phase 3+ Dashboard / SQL-aggregate quota arrives. |
## When to write a new ADR ## When to write a new ADR
+32
View File
@@ -136,6 +136,38 @@ Each entry: `{ "id": "<model-id>", "object": "model", "created": <ts>, "owned_by
no invented fields (per D27 F15). Alias entries are also surfaced as separate list members no invented fields (per D27 F15). Alias entries are also surfaced as separate list members
(per D27 F15 alias surfacing). (per D27 F15 alias surfacing).
**Alias surfacing — controlled deviation (D36 #13).** OpenAI's `/v1/models` spec
enumerates one entry per canonical model ID; OLP additionally surfaces alias entries
(e.g. `claude`, `sonnet`, `opus`, `haiku` alongside their canonical Anthropic targets).
This is a documented deviation from strict spec parity. It is governed by
`ALIGNMENT.md § Class-specific Exceptions → Controlled deviations (entry-surface scope)`,
which references this section as the formal contract.
The alias-entry contract:
| Field | Value for alias entry |
|---|---|
| `id` | the alias string (e.g. `'sonnet'`) — same shape as canonical entries |
| `object` | `'model'` — same as canonical entries |
| `created` | identical to the canonical target's `created` timestamp (per F12) |
| `owned_by` | identical to the canonical target's `owned_by` (i.e. the provider key) |
The alias list is sourced from `models-registry.json` via `getAliasMap()` in
`lib/providers/index.mjs` — the SPOT for alias-aware routing. `server.mjs handleModels`
appends alias entries to the canonical list only when the alias's canonical target's
provider is currently in `loadedProviders`. No fields beyond the four OpenAI-spec fields
are added on alias entries.
**Rationale (D27 F15):** Onboarding gap. Clients configured with `model: 'sonnet'` (a
common alias used by Anthropic's own CLI and OpenClaw-class tools) previously received
an empty `/v1/models` response that did not surface the alias as a callable model id.
Surfacing the alias makes the discovery loop usable for OpenAI-compatible clients with
alias-aware UX.
**Forward path:** Annual audit (14 May) re-checks whether OpenAI has shipped a formal
alias-listing extension to `/v1/models`. If so, OLP migrates the alias surface to that
shape. If not, the deviation continues unchanged.
**`created` field stability (F12 round-5 cold-audit):** OpenAI spec treats `created` as a **`created` field stability (F12 round-5 cold-audit):** OpenAI spec treats `created` as a
stable per-model attribute, not a request-time value. `server.mjs handleModels` uses stable per-model attribute, not a request-time value. `server.mjs handleModels` uses
`getModelCreated(modelId)` (from `lib/providers/index.mjs`) which reads the per-entry `getModelCreated(modelId)` (from `lib/providers/index.mjs`) which reads the per-entry
+124
View File
@@ -0,0 +1,124 @@
# Anthropic provider — version-capture artifact
- **Provider key:** `anthropic`
- **Plugin file:** `lib/providers/anthropic.mjs`
- **Last capture:** 2026-05-24 (D36 #15)
- **Capture host:** project maintainer's primary workstation (home-mac)
- **Status:** living artifact — re-capture at every plugin touch, or annually
during the 14 May Annual Alignment Audit, whichever comes first.
This artifact closes the circular-citation finding from Round-6 (issue #15):
ALIGNMENT.md § Provider Authority Pins anthropic row cites `@anthropic-ai/claude-code`
v2.1.89 (observed at D4) without an independent transcript; the plugin header cited
the observation date but pointed back to ALIGNMENT.md. This file is the in-repo
transcript artifact that grounds the OLP-side claim.
---
## Observed `claude --version`
The live binary on the maintainer's workstation today is:
```
2.1.132 (Claude Code)
```
Captured by running `claude --version` in a non-interactive shell on 2026-05-24.
## Plugin-pinned version
```
@anthropic-ai/claude-code v2.1.89
```
Source: ALIGNMENT.md § Authorities § "Provider authority pins" row `anthropic`
("OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 —
`lib/providers/anthropic.mjs` header)"). This is the version observed inside
the OLP-side D4 work; the plugin was authored against this version's flag
surface.
## Version drift note
The pinned version (v2.1.89, D4) and the live binary (v2.1.132, today) differ
because the `claude` CLI has continued to ship updates since D4. This drift is
within tolerance for the v0.1 baseline:
- The CLI flags OLP consumes — `-p`, `--output-format=text`,
`--no-session-persistence`, `--model`, `--debug` — are all still present and
semantically unchanged in v2.1.132 (verified today via `claude -p --help`
— see "Flag surface captured today" below).
- The pin in ALIGNMENT.md is conservative by design — it names the version OLP
was authored against, not the highest version known to work. Re-pinning to
v2.1.132 (or whichever version is current) is the right action at the next
Anthropic-plugin touch, when a reviewer can confirm no regressions.
- Re-audit recommended at: (a) next material change to
`lib/providers/anthropic.mjs`, OR (b) 14 May 2027 Annual Alignment Audit,
OR (c) the post-2026-06-15 one-shot triggered audit (ALIGNMENT.md
§ One-shot Triggered Audits) — whichever comes first.
## Sample invocation
The Anthropic plugin spawns the CLI with this argument shape (see
`lib/providers/anthropic.mjs` § `buildClaudeArgs` and `_spawnAndStream`):
```
claude -p --output-format text --no-session-persistence --model <model> [--debug]
```
- `-p` puts the CLI in non-interactive (print-and-exit) mode.
- `--output-format text` selects plain-text stdout. The plugin parses stdout as
plain text (no NDJSON envelope).
- `--no-session-persistence` disables session storage so OLP remains stateless
(per ADR 0001 § Non-mission — OLP is not a conversation-state store).
- `--model <model>` is forwarded from the IR's `model` field.
- `--debug` is added only when `OLP_DEBUG_CLAUDE` env is set, for development.
The prompt is written to the CLI's stdin (`messagesToPrompt(ir.messages)` from
`anthropic.mjs`), not passed as a positional argument.
## Flag surface captured today
Excerpt from `claude -p --help` on host home-mac on 2026-05-24 (v2.1.132). Only
the flags relevant to OLP's invocation are reproduced; the full help is much
larger.
| Flag | Description (verbatim, abridged) |
|---|---|
| `-p, --print` | Print response and exit (useful for pipes). |
| `--output-format <format>` | Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming) — choices: "text", "json", "stream-json". |
| `--no-session-persistence` | Disable session persistence — sessions will not be saved to disk and cannot be resumed (only works with --print). |
| `--model <model>` | Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6'). |
| `-d, --debug [filter]` | Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file"). |
All four load-bearing flags (`-p`, `--output-format`, `--no-session-persistence`,
`--model`) are present and accept the same value formats the OLP plugin uses.
The `--debug` flag is also present with the same semantics.
## Citation cross-references
- ALIGNMENT.md § Authorities § "Provider authority pins" anthropic row — names
this file as the transcript-artifact pin.
- `lib/providers/anthropic.mjs` header lines 1-50 — names this file as the
version-capture artifact (D26 F18 follow-up).
- ADR 0001 § Mission inheritance — establishes `claude -p` as Authority 1
source for the `anthropic` plugin.
- ADR 0005 Amendment 5 (D31 F11) — clarifies the wire limitation of
`claude -p --output-format text` w.r.t. cache_control marker delegation.
## Recapture procedure
When this artifact is next refreshed (per the "Version drift note" trigger
list), the maintainer should:
1. Run `claude --version` on the project maintainer's primary workstation.
2. Run `claude -p --help` and verify that `-p`, `--output-format`,
`--no-session-persistence`, `--model`, and `--debug` are all still present
with the same value formats.
3. Update the "Last capture", "Observed `claude --version`", and "Flag surface
captured today" sections with the new values.
4. If any flag's semantic has changed (not just a version bump), file an ADR
amendment on `lib/providers/anthropic.mjs` Authority 1 source and pin the
new version in ALIGNMENT.md.
5. If `claude -p --help` no longer enumerates one of OLP's load-bearing flags,
the plugin is broken against the new CLI — file a deletion or migration PR
per ALIGNMENT.md Rule 4 (Unalignable Plugins / Fields Are Deleted).
+108
View File
@@ -0,0 +1,108 @@
# OLP v1.x Roadmap — Deferred Work Tracker
**Purpose.** Single landing page for every Phase-1 deferral that an actual v1.x sprint must pick up. Each entry cross-references its ratifying ADR, its GitHub issue (if any), and the load-bearing code anchor so a future maintainer can resume without spelunking the commit history.
**Status:** Living document. Add new entries at the top. Each item should answer:
1. **What** is deferred?
2. **Why** was it deferred (link the ratifying ADR amendment).
3. **Where** does the work live in the tree today (file + anchor).
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
**Reading order for a v1.x sprint kickoff.** Items #1#3 are the most architecturally consequential and should be designed in dependency order: #2 (multi-key auth) blocks header gating in #1 and observability ownership in #4. #1 (streaming SF) blocks #5 (soft trigger reactivation) only if soft triggers are wired on streaming requests.
---
## #1 — Streaming-path singleflight + TOCTOU close
- **What.** `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory)` API replacing the current `peek + spawn` pattern in `server.mjs`. Per-(keyId, cacheKey) inflight Map with tee fan-out, bounded per-client backpressure queues, late-joiner replay buffer, AbortController propagation on all-disconnect.
- **Why deferred.** Personal/family-scale single-tenant load — N concurrent identical streaming requests is an edge case that has not been reported. Each concurrent caller receives the correct response; the waste is N CLI processes instead of one.
- **Design ADR (ratified).** [`docs/adr/0005-cache-cross-provider.md` Amendment 8](./adr/0005-cache-cross-provider.md) — full design including the inflight Map shape, tee policy, late-joiner replay, backpressure cap, D38 semaphore coordination, abort policy, cache TTL race handling, observability event set, and X-OLP-Streaming-Inflight header. Implementation acceptance criteria are in Amendment 8 §13.
- **Tracking issue.** GitHub issue [#16](https://github.com/dtzp555-max/olp/issues/16) — STAYS OPEN as v1.x tracker. Sibling: the closed-but-not-implemented Amendment 6 deferral (D34 F1).
- **Code anchors today.**
- `server.mjs` lines ~782 (`preCheckHit = await cacheStore.peek(...)`) and ~811817 (streaming branch entry) — these are the lines the new API replaces.
- `lib/cache/store.mjs` `getOrCompute` — sibling API; the new one mirrors its shape on the streaming path.
- **Trigger to start.** Any of: (a) report of N>1 concurrent identical streaming requests in the wild, (b) v1.x sprint planning kickoff with the maintainer explicitly opening this scope, (c) downstream feature requiring tee-streaming primitive (e.g., browser-side observer attaching to an existing stream).
- **Estimated effort.** Design ADR ratified (Amendment 8) = 30 min done. Implementation = 200400 lines + 15-20 tests + fresh-context reviewer pass. ~3-4 hours of subagent runtime with full Iron Rule 10 discipline.
## #2 — Multi-key auth (`lib/keys.mjs`) — **PHASE 2 ACTIVE (no longer deferred)**
- **Status.** Phase 2 active as of 2026-05-25. Design ratified at D43-B. This entry stays for cross-reference but is no longer a v1.x deferral; implementation D-days D44+ execute within Phase 2.
- **What.** Per-API-key identity, namespace scoping for the cache, ownership tier (owner vs guest) for header gating, and audit log of which key issued which request. Detailed scope in ADR 0007.
- **Design ADR (ratified).** [`docs/adr/0007-multi-key-auth.md`](./adr/0007-multi-key-auth.md) — Option 2 (filesystem manifest) + opaque token, with explicit forward path to Option 3 hybrid (SQLite-indexed mirror) when Phase 3+ Dashboard / SQL-aggregate quota work justifies. Migratable, manifest-as-SPOT.
- **Tracking.** Not a GitHub issue. Tracked here + via ADR 0007 acceptance criteria (§ 10) which drive the D44+ test surface.
- **Resolves.**
- `X-OLP-Fallback-Detail` owner-only gating (D40 / ADR 0004 Amendment 5 — currently ungated; Phase 2 re-gates per ADR 0007 § 7).
- `/health` per-key visibility (currently anonymous-only — owner / guest / anonymous tiers per ADR 0007 § 7).
- **Code anchors today (unchanged at ADR ratification; replaced by D44+ implementation).**
- `lib/cache/store.mjs:77-79` per-keyId namespace Map — wire is in place.
- `lib/cache/store.mjs:287` singleflight composition `${keyId}:${cacheKey}` — wire is in place.
- `server.mjs:502, :531` — the two `keyId='__anonymous__'` call sites to replace.
- `server.mjs:392``/health` handler entry (Phase 2 gate).
- `server.mjs:1072, :1101``X-OLP-Fallback-Detail` header-write paths (Phase 2 gate).
- **Trigger (already fired).** Maintainer opened Phase 2 sprint 2026-05-25.
## #3 — Soft trigger reactivation (ADR 0004 Amendment 2)
- **What.** Per-provider `quotaStatus` polling, `softThreshold` comparisons, soft-skip advancement when quota approaches limit. Currently `evaluateSoftTriggers` always returns `false` because `quotaSnapshot` is never populated.
- **Why deferred.** v0.1 hard triggers (SPAWN_FAILED / CLI_NOT_FOUND / SPAWN_TIMEOUT / CONCURRENCY_LIMIT) are sufficient for fallback advancement at personal/family scale. Soft triggers require persistent quota snapshots and a polling mechanism, which adds operational surface (timer drift, snapshot staleness, observability burden).
- **Design ADR.** [`docs/adr/0004-fallback-engine.md` Amendment 2](./adr/0004-fallback-engine.md) — explicit v1.x deferral with mitigations (startup warning if user configures soft thresholds without runtime enforcement).
- **Tracking.** Not a GitHub issue. Tracked here + via the startup warning in `server.mjs` (the `_softTriggersConfigured` warn emission).
- **Blocks.**
- Issue #8 (`X-OLP-Provider-Used` chain-origin semantics) — Option A (track `firstAttemptedProvider`) becomes preferable once soft triggers can fire. See ADR 0004 Amendment 6 § v1.x re-evaluation.
- `X-OLP-Fallback-Detail` `trigger_type: 'soft'` path — currently dead code, becomes live with this work.
- **Code anchors today.**
- `lib/fallback/engine.mjs` `evaluateSoftTriggers` (returns false unconditionally at v0.1).
- `lib/providers/base.mjs` `Provider.quotaStatus` contract (declared but unused at v0.1).
- **Trigger to start.** First quota-rate-limit event in the wild — at which point the operator would want pre-emptive advancement rather than spawn-then-fail.
## #4 — `/health` `activeSpawns` integration
- **What.** Surface D38 `getActiveSpawnCount(providerName)` per-provider on the `/health` endpoint at the path `providers.status.<name>.activeSpawns`.
- **Why deferred.** D38 (issue #1) shipped the runtime enforcement and exported `getActiveSpawnCount`; `/health` integration was scoped out as forward-looking polish.
- **Design ADR.** [`docs/adr/0002-plugin-architecture.md` Amendment 6](./adr/0002-plugin-architecture.md) — names the target path explicitly: "`/health` integration deferred — when surfaced there will land at `providers.status.<name>.activeSpawns`; not wired at D38."
- **Tracking.** Not a GitHub issue. Tracked here.
- **Code anchors today.**
- `lib/providers/index.mjs` exports `getActiveSpawnCount` already.
- `server.mjs handleHealth` — extension point for the new field.
- **Trigger to start.** First time the maintainer wants per-provider concurrency visibility for capacity planning.
## #5 — Provider-level `cacheKeyFields` (per-plugin mask)
- **What.** Per-plugin declaration of which IR fields are actually consumed by the underlying CLI invocation, used by `computeCacheKey` to skip fields that the plugin drops at spawn. Reduces spurious-miss rate from the v0.1 conservative-posture trade-off (Amendment 7).
- **Why deferred.** At personal/family scale the extra spawn cost from spurious misses is negligible. The contract extension adds complexity (per-plugin field set + plumbing through `buildDefaultChain``executeHopFn``computeCacheKey`).
- **Design ADR.** [`docs/adr/0005-cache-cross-provider.md` Amendment 7 § Forward path](./adr/0005-cache-cross-provider.md).
- **Tracking.** Not a GitHub issue. Tracked here.
- **Code anchors today.**
- Plugin file headers — each lists its "fields dropped at spawn" table for human reference; the v1.x amendment makes that table machine-readable.
- `lib/cache/keys.mjs computeCacheKey` — would accept `pluginCacheKeyMask` parameter.
- **Trigger to start.** First time spurious-miss rate becomes a measurable load factor.
## #6 — Streaming-path SPAWN_FAILED salvage
- **What.** Currently the streaming branch does NOT participate in D16 salvage (the salvage-on-SPAWN_FAILED + chunks pattern that the buffered path uses). Streaming SPAWN_FAILED mid-stream → the truncation marker (D35 #10) fires, but no salvage logic captures partial chunks for downstream cache reuse.
- **Why deferred.** Less impactful than #1 — at most one client benefits per spawn event, and the buffered path already provides salvage for the bulk of requests. Streaming is the minority path.
- **Design ADR.** Not yet ratified. Coordinated with #1 because the tee architecture changes the salvage semantics (multiple clients may want different finish_reason interpretations on source-mid-stream-failure).
- **Tracking.** Not a GitHub issue. Tracked here.
- **Trigger to start.** Bundled with #1 implementation work (the inflight tee architecture changes the salvage semantics, so designing them together is cheaper than serializing).
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up)
- **What.** Dedicated test in `test-features.mjs` Suite D40 that asserts the `fallbackDetail` tuple records the AUTH_MISSING path with `trigger_type: 'auth_missing'`. D40 reviewer flagged this as the last gap in the engine-path matrix; code is structurally correct, just lacks an explicit pin.
- **Why deferred.** Low priority — the AUTH_MISSING early-return branch has the tuple push BEFORE it (verified in D40 reviewer pass), so coverage is implicit via the other engine-path tests. A 3-line dedicated test would make the pin explicit.
- **Design.** No ADR needed. ~5-line test addition.
- **Tracking.** Not a GitHub issue. Tracked here.
- **Trigger to start.** Next routine test-suite hardening pass, OR when AUTH_MISSING handling is changed for any reason.
---
## Adding a new entry
When a future D-day defers work, the deferring commit should:
1. **Always** update this file with a new entry at the top.
2. **Always** name the ratifying ADR amendment (or note "no ADR yet — future work needs one").
3. **Always** name the load-bearing code anchor (`file:line` form preferred over symbolic names — the symbolic name can drift).
4. **Always** name a concrete trigger to start the work — vague triggers ("when needed") let entries rot.
5. If the deferral has a GitHub issue, keep it OPEN and reference it here. If it does NOT, leave a note explaining why (e.g., "tracked here only — no external governance event filed").
The maintainer's session-startup discipline should grep this file at sprint kickoff. If an entry's "trigger to start" condition is met, it leaves this page and becomes a sprint item.
+110
View File
@@ -0,0 +1,110 @@
/**
* lib/audit.mjs — OLP audit ndjson append (Phase 2 / D45)
*
* Authority: ADR 0007 § 6.2 (audit append semantics) + § 8 (event schema).
*
* Behaviour:
* - One JSON event per line; newline-terminated; UTF-8.
* - Append to ~/.olp/logs/audit.ndjson (chmod 0600 file, 0700 dir).
* - On append failure: log a warn ('audit_append_failed_once') + retry
* once synchronously.
* - On second-failure: increment per-process drop counter + log warn
* ('audit_append_dropped'); NEVER throw to the caller (audit is
* observability, not authorization). Per § 6.2.
* - No memory buffer at Phase 2 (forward-path note in ADR § 13).
*
* Atomicity note: Node's `fs.appendFileSync` opens with O_APPEND which is
* POSIX-atomic for writes <= PIPE_BUF (typically 4096 bytes). Our event
* payloads (§ 8 schema with hash + shape fields, no PII / no message
* content) are well under that limit, so concurrent in-process appends
* are line-atomic without explicit locking.
*
* No PII: § 8 explicitly excludes request body, response body, and IR
* message content. Hash + shape only.
*/
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
const OLP_HOME_ENV = 'OLP_HOME';
const RETRY_COUNT = 1; // § 6.2: warn + 1 retry
let _dropCounter = 0;
/**
* Resolve OLP home dir (matches lib/keys.mjs precedence): opts.olpHome →
* process.env.OLP_HOME → ~/.olp. Resolved per call so tests setting the
* env mid-run take effect.
*/
function _resolveOlpHome(opts) {
if (opts?.olpHome) return opts.olpHome;
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
return DEFAULT_OLP_HOME;
}
/**
* Append a single audit event to ~/.olp/logs/audit.ndjson.
*
* @param {object} event - § 8 schema fields (ts, key_id, owner_tier,
* method, path, provider, model, status_code, latency_ms, cache_status,
* fallback_hops, tried_providers, error_code, ir_request_hash, chain_id).
* Caller is responsible for populating fields; missing fields are
* serialized as undefined → omitted by JSON.stringify.
* @param {object} [opts]
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
* @param {(level: string, event: string, data?: object) => void} [opts.logEvent]
* - injectable structured logger; defaults to console.warn with JSON line
*/
export function appendAuditEvent(event, opts = {}) {
const olpHome = _resolveOlpHome(opts);
const logsDir = join(olpHome, 'logs');
const path = join(logsDir, 'audit.ndjson');
const line = JSON.stringify(event) + '\n';
const logEvent = opts.logEvent ?? ((level, ev, data) => {
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
process.stderr.write(JSON.stringify(entry) + '\n');
});
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
try {
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
// Tighten dir mode in case it already existed with broader permissions.
try { chmodSync(logsDir, 0o700); } catch { /* tolerate EPERM */ }
appendFileSync(path, line, { mode: 0o600 });
return;
} catch (err) {
if (attempt < RETRY_COUNT) {
logEvent('warn', 'audit_append_failed_once', {
path,
error: err?.message ?? String(err),
});
continue;
}
_dropCounter++;
logEvent('warn', 'audit_append_dropped', {
path,
error: err?.message ?? String(err),
drop_count: _dropCounter,
});
return;
}
}
}
/**
* Per-process count of audit events dropped due to repeated append failure.
* Useful for /health observability surface (D46).
*/
export function getAuditDropCount() {
return _dropCounter;
}
/**
* Test-only: reset the drop counter to zero. Suite 20 uses this between
* cases to assert independent failure-handling counts.
*/
export function __resetAuditDropCount() {
_dropCounter = 0;
}
+41
View File
@@ -265,6 +265,14 @@ export class CacheStore {
* @param {() => Promise<*>} computeFn - async function producing the value * @param {() => Promise<*>} computeFn - async function producing the value
* @param {number} [ttlMs] * @param {number} [ttlMs]
* @returns {Promise<*>} * @returns {Promise<*>}
*
* TODO(v1.x — ADR 0005 Amendment 8 / issue #16): add a sibling
* `getOrComputeStreaming(keyId, cacheKey, sourceFactory)` for the streaming
* path. This API handles buffered responses only; the streaming branch in
* server.mjs currently uses a peek+spawn pattern with a TOCTOU window.
* The streaming sibling will mirror this method's shape but with a tee
* fan-out and per-client backpressure queues. See docs/v1x-roadmap.md #1
* for the design contract and acceptance criteria.
*/ */
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) { async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
// 1. Cache hit — return immediately, no singleflight overhead // 1. Cache hit — return immediately, no singleflight overhead
@@ -341,6 +349,39 @@ export class CacheStore {
}; };
} }
/**
* Removes a specific (keyId, cacheKey) entry immediately.
*
* ADR 0005 § "Cache write conditions" item 1 (D39, issue #3 Part 1):
* D16 truncation-eviction previously used `set(..., ttlMs=0)` to leave a
* tombstone that the next `get`/`peek` would lazily purge. That pattern
* left dead entries in the namespace Map until next access, accruing
* memory if no follow-up read ever fires. `delete(keyId, cacheKey)` makes
* the eviction explicit and immediate.
*
* Memory hygiene: if the per-keyId namespace becomes empty after delete,
* the namespace Map entry itself is removed (mirrors the pattern in D38
* `_activeSpawns` so empty namespaces don't accumulate in `_store`).
*
* Stats: this method does NOT touch hit/miss counters — it is an eviction
* primitive, not a read. Aggregate `size` reported by `stats()` reflects
* the removal on the next call.
*
* @param {string} keyId
* @param {string} cacheKey
* @returns {boolean} true if the entry was present and removed; false if absent.
*/
delete(keyId, cacheKey) {
const ns = this._store.get(keyId);
if (!ns) return false;
const had = ns.delete(cacheKey);
// Memory hygiene: drop empty namespace Map entries.
if (had && ns.size === 0) {
this._store.delete(keyId);
}
return had;
}
/** /**
* Clears cache entries (and stats) for a specific keyId, or ALL entries. * Clears cache entries (and stats) for a specific keyId, or ALL entries.
* *
+120 -14
View File
@@ -27,11 +27,16 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
/** /**
* Maps ProviderError codes to hard-trigger decisions. * Maps ProviderError codes to hard-trigger decisions.
* *
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7): * v0.1 live codes (per ADR 0004 Amendment 3 (D34 F7) + Amendment 4 (D38)):
* - SPAWN_FAILED → hard trigger (provider CLI failed) * - SPAWN_FAILED → hard trigger (provider CLI failed)
* - CLI_NOT_FOUND → hard trigger (binary missing) * - CLI_NOT_FOUND → hard trigger (binary missing)
* - AUTH_MISSING → NOT a hard trigger (user-config failure; user must fix) * - AUTH_MISSING → NOT a hard trigger (user-config failure; user must fix)
* - SPAWN_TIMEOUT → hard trigger (per ADR 0004 § Trigger taxonomy bullet 4) * - SPAWN_TIMEOUT → hard trigger (per ADR 0004 § Trigger taxonomy bullet 4)
* - CONCURRENCY_LIMIT → hard trigger (D38 / issue #1, ADR 0004 Amendment 4):
* synthesized by server.mjs when a provider is at its
* hints.maxConcurrent in-flight limit. The chain
* advances immediately to the next hop instead of
* queueing — design rationale per ADR 0004 Amendment 4.
* *
* QUOTA_EXHAUSTED and RATE_LIMITED removed (D34 F7 / ADR 0004 Amendment 3): * QUOTA_EXHAUSTED and RATE_LIMITED removed (D34 F7 / ADR 0004 Amendment 3):
* no v0.1 plugin parses underlying-API HTTP status codes, so these codes * no v0.1 plugin parses underlying-API HTTP status codes, so these codes
@@ -44,8 +49,9 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
const HARD_TRIGGER_CODES = { const HARD_TRIGGER_CODES = {
SPAWN_FAILED: true, SPAWN_FAILED: true,
CLI_NOT_FOUND: true, CLI_NOT_FOUND: true,
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision) AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
CONCURRENCY_LIMIT: true, // ADR 0004 Amendment 4 (D38, issue #1): saturation → advance chain
}; };
/** /**
@@ -221,6 +227,18 @@ export function evaluateSoftTriggers(triggerConfig, quotaSnapshot) {
* @property {object|null} [quotaSnapshot] — optional pre-fetched quota snapshot * @property {object|null} [quotaSnapshot] — optional pre-fetched quota snapshot
*/ */
/**
* @typedef {Object} FallbackDetailTuple — per-hop failure detail tuple emitted in X-OLP-Fallback-Detail.
* D40 (issue #7) — Option A (ungated v0.1). Shapes reuse D28 log event fields so future readers
* can grep both surfaces consistently.
* @property {number} hop — 0-indexed hop number
* @property {string} provider — provider name at this hop
* @property {string} model — model string at this hop (from chain hop, which carries IR model)
* @property {string} code — ProviderError code, or 'UNKNOWN' for non-ProviderError exceptions
* @property {string} error_message — error message, truncated to 200 chars
* @property {string} trigger_type — classifyTrigger() output: 'hard' | 'auth_missing' | 'client_error' | 'non_trigger' | 'soft' for engine-synthesized soft skips
*/
/** /**
* @typedef {Object} FallbackResult * @typedef {Object} FallbackResult
* @property {Array<object>|null} chunks — IR chunk array on success; null if exhausted * @property {Array<object>|null} chunks — IR chunk array on success; null if exhausted
@@ -229,8 +247,65 @@ export function evaluateSoftTriggers(triggerConfig, quotaSnapshot) {
* @property {number} fallbackHops — chain index of the serving hop (0=primary, 1=first fallback, etc.) * @property {number} fallbackHops — chain index of the serving hop (0=primary, 1=first fallback, etc.)
* @property {Error|null} originalError — first-hop error if exhausted; null on success * @property {Error|null} originalError — first-hop error if exhausted; null on success
* @property {string[]} triedProviders — all providers tried, in chain order * @property {string[]} triedProviders — all providers tried, in chain order
* @property {FallbackDetailTuple[]} fallbackDetail — per-hop failure tuples (D40, issue #7).
* On success, contains the failing hops before the serving hop (may be empty).
* On exhausted/non-trigger/client-error/auth-missing return paths, contains every failed hop.
* Server.mjs emits X-OLP-Fallback-Detail when this array is non-empty.
*/ */
/**
* Truncates an error message to at most 200 characters. If truncation occurs,
* the result ends with a single-character ellipsis (U+2026 '…') to signal
* the cut. Used to keep X-OLP-Fallback-Detail tuples readable in dashboards.
*
* D40 (issue #7) — see ADR 0004 § Observability headers.
*
* @param {unknown} message
* @returns {string}
*/
function truncateErrorMessage(message) {
const s = typeof message === 'string' ? message : String(message ?? '');
if (s.length <= 200) return s;
return s.slice(0, 199) + '…';
}
/**
* Builds the per-hop failure tuple emitted in X-OLP-Fallback-Detail.
* Field shapes reuse D28's structured log event values so the header and
* the log line are pivotable on the same keys.
*
* D40 (issue #7) — see ADR 0004 § Observability headers.
*
* @param {number} hop
* @param {string} provider
* @param {string} model
* @param {Error} err
* @param {'hard'|'soft'|'auth_missing'|'client_error'|'non_trigger'|null} triggerType
* @returns {FallbackDetailTuple}
*/
function makeFallbackDetailTuple(hop, provider, model, err, triggerType) {
let code;
if (err instanceof ProviderError && err.code) {
code = err.code;
} else if (typeof err?.code === 'string') {
// Carries err.code from soft-trigger synthesized errors (code: 'SOFT_TRIGGER')
// or any custom error class that uses string codes. Non-string err.code
// falls through to 'UNKNOWN' so a numeric Node errno (e.g. ECONNREFUSED's
// numeric system errno) does not get mis-typed.
code = err.code;
} else {
code = 'UNKNOWN';
}
return {
hop,
provider,
model,
code,
error_message: truncateErrorMessage(err?.message),
trigger_type: triggerType ?? 'non_trigger',
};
}
/** /**
* Executes a provider chain with fallback semantics. * Executes a provider chain with fallback semantics.
* *
@@ -270,6 +345,12 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
let originalError = null; // Per ADR 0004: first-hop error is the canonical signal let originalError = null; // Per ADR 0004: first-hop error is the canonical signal
let firstErrorRecorded = false; let firstErrorRecorded = false;
// D40 (issue #7) — per-hop failure tuples for X-OLP-Fallback-Detail.
// Reuses D28 log event field shapes; emitted by server.mjs on any response
// where this array is non-empty. ADR 0004 § Observability headers.
/** @type {FallbackDetailTuple[]} */
const fallbackDetail = [];
for (let i = 0; i < chain.length; i++) { for (let i = 0; i < chain.length; i++) {
const hop = chain[i]; const hop = chain[i];
const { provider, model, softTriggers = null, quotaSnapshot = null } = hop; const { provider, model, softTriggers = null, quotaSnapshot = null } = hop;
@@ -307,13 +388,17 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
// should treat 'SOFT_TRIGGER' as engine-synthetic. D9 review-2 noted // should treat 'SOFT_TRIGGER' as engine-synthetic. D9 review-2 noted
// this; documented here so future readers do not try to add SOFT_TRIGGER // this; documented here so future readers do not try to add SOFT_TRIGGER
// to the PROVIDER_ERROR_CODES closed enum. // to the PROVIDER_ERROR_CODES closed enum.
const softErr = Object.assign(
new Error(`Soft trigger fired for provider ${provider}: quota threshold exceeded`),
{ code: 'SOFT_TRIGGER', provider },
);
if (!firstErrorRecorded) { if (!firstErrorRecorded) {
originalError = Object.assign( originalError = softErr;
new Error(`Soft trigger fired for provider ${provider}: quota threshold exceeded`),
{ code: 'SOFT_TRIGGER', provider },
);
firstErrorRecorded = true; firstErrorRecorded = true;
} }
// D40: record soft-skipped hop in fallbackDetail. trigger_type='soft' lets
// downstream readers distinguish a skipped hop from a spawned-and-failed one.
fallbackDetail.push(makeFallbackDetailTuple(i, provider, model, softErr, 'soft'));
continue; continue;
} }
@@ -342,6 +427,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i, fallbackHops: i,
originalError: null, originalError: null,
triedProviders, triedProviders,
fallbackDetail, // D40: failing hops that came BEFORE this success
}; };
} catch (err) { } catch (err) {
// Record FIRST hop error as the canonical signal // Record FIRST hop error as the canonical signal
@@ -351,6 +437,12 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
firstErrorRecorded = true; firstErrorRecorded = true;
} }
// D40 (issue #7): classify once and record the per-hop tuple. The same
// trigger_type value flows into the log event below (consistency between
// logs and X-OLP-Fallback-Detail).
const errTriggerType = classifyTrigger(err);
fallbackDetail.push(makeFallbackDetailTuple(i, provider, model, err, errTriggerType));
logEvent('warn', 'fallback_hop_error', { logEvent('warn', 'fallback_hop_error', {
chain_id: chainId, chain_id: chainId,
hop: i, hop: i,
@@ -358,7 +450,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
model, model,
error: err.message, error: err.message,
code: err.code ?? null, code: err.code ?? null,
trigger_type: classifyTrigger(err), trigger_type: errTriggerType,
ir_request_hash: irRequestHash, ir_request_hash: irRequestHash,
next_provider: chain[i + 1]?.provider ?? null, next_provider: chain[i + 1]?.provider ?? null,
}); });
@@ -383,6 +475,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i, fallbackHops: i,
originalError: err, originalError: err,
triedProviders, triedProviders,
fallbackDetail, // D40
}; };
} }
@@ -406,6 +499,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i, fallbackHops: i,
originalError: err, originalError: err,
triedProviders, triedProviders,
fallbackDetail, // D40
}; };
} }
@@ -422,7 +516,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
error: err.message, error: err.message,
code: err.code ?? null, code: err.code ?? null,
advance_to_hop: i + 1, advance_to_hop: i + 1,
trigger_type: classifyTrigger(err), trigger_type: errTriggerType,
ir_request_hash: irRequestHash, ir_request_hash: irRequestHash,
next_provider: chain[i + 1]?.provider ?? null, next_provider: chain[i + 1]?.provider ?? null,
}); });
@@ -437,7 +531,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
provider, provider,
error: err.message, error: err.message,
code: err.code ?? null, code: err.code ?? null,
trigger_type: classifyTrigger(err), trigger_type: errTriggerType,
ir_request_hash: irRequestHash, ir_request_hash: irRequestHash,
next_provider: null, next_provider: null,
}); });
@@ -448,6 +542,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i, fallbackHops: i,
originalError: err, originalError: err,
triedProviders, triedProviders,
fallbackDetail, // D40
}; };
} }
} }
@@ -464,6 +559,16 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
next_provider: null, 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 { return {
chunks: null, chunks: null,
providerUsed: chain[0].provider, providerUsed: chain[0].provider,
@@ -471,6 +576,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: chain.length, fallbackHops: chain.length,
originalError, originalError,
triedProviders, triedProviders,
fallbackDetail, // D40 (issue #7): per-hop failure tuples; every attempted hop on exhaustion
}; };
} }
+10 -1
View File
@@ -40,7 +40,7 @@ export const VALID_ROLES = ['system', 'user', 'assistant', 'tool'];
/** /**
* @typedef {Object} IRRequest * @typedef {Object} IRRequest
* @property {string} irVersion - always IR_VERSION * @property {string} [irVersion] - optional; when present must equal IR_VERSION ('1.0'). Pre-D35 IRs lack this field and remain valid.
* @property {IRMessage[]} messages * @property {IRMessage[]} messages
* @property {string} model * @property {string} model
* @property {boolean} stream * @property {boolean} stream
@@ -177,6 +177,15 @@ export function validateIRRequest(obj) {
} }
} }
// Optional: irVersion — must be '1.0' if present; undefined accepted for pre-existing IRs
// Per ADR 0003 § Required fields: analogous to contractVersion='1.0' in base.mjs.
// Decision: undefined accepted because openai-to-ir.mjs sets irVersion on construction;
// pre-existing IRs without it still validate. Strict '1.0' rejection only when explicitly
// set wrong.
if (obj.irVersion !== undefined && obj.irVersion !== '1.0') {
errors.push(`irVersion must be '1.0' (got: ${JSON.stringify(obj.irVersion)})`);
}
// Optional: tool_choice — 'auto' | 'none' | 'required' | {type:'function', function:{name}} // Optional: tool_choice — 'auto' | 'none' | 'required' | {type:'function', function:{name}}
if (obj.tool_choice !== undefined) { if (obj.tool_choice !== undefined) {
if (typeof obj.tool_choice === 'string') { if (typeof obj.tool_choice === 'string') {
+522
View File
@@ -0,0 +1,522 @@
/**
* lib/keys.mjs — OLP multi-key auth (Phase 2 / D44 core)
*
* Authority: ADR 0007 (multi-key auth). Read that ADR before modifying.
*
* This module implements the identity / lifecycle layer for OLP API keys:
* - Opaque token generation (§ 5)
* - Manifest read + atomic write (§ 6.1)
* - Per-key in-process write-lock (§ 6.4)
* - touchLastUsed read-modify-write with revoke preservation (§ 6.3)
* - validateKey lookup with NO validation cache (§ 6.3.5) — manifests are
* read on every authenticated request
* - Env override (OLP_OWNER_TOKEN → __env_owner__) per § 9.4
* - Anonymous escape-hatch identity per § 7
*
* What is NOT in this module (intentional split):
* - audit ndjson append (§ 6.2) — request-layer concern; D45 (server.mjs glue)
* - keygen CLI bootstrap surface (§ 9.1) — D45+ (separate command entry)
* - server.mjs integration (replace '__anonymous__' constants) — D45
* - owner-vs-guest /health + X-OLP-Fallback-Detail gating — D46
*
* The module is filesystem-only at v0.2.0. The future Option-3 SQLite-indexed
* mirror (ADR 0007 § 13) is invisible from this module's API — when added, the
* SQLite write happens inside writeManifestAtomic / revokeKey and the module's
* public surface is unchanged.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import {
readFileSync, writeFileSync, openSync, fsyncSync, closeSync,
renameSync, readdirSync, mkdirSync, chmodSync, existsSync,
} from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ── Constants ─────────────────────────────────────────────────────────────
export const SCHEMA_VERSION = 1;
export const TOKEN_PREFIX = 'olp_';
export const TOKEN_RANDOM_BYTES = 32; // 256 bits entropy
export const KEY_ID_RANDOM_BYTES = 6; // 8 base64url chars
export const ANONYMOUS_KEY_ID = '__anonymous__';
export const ENV_OWNER_KEY_ID = '__env_owner__';
export const ENV_OWNER_VAR = 'OLP_OWNER_TOKEN';
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
export const OLP_HOME_ENV = 'OLP_HOME';
/**
* Resolve the OLP home directory. Precedence:
* 1. `opts.olpHome` (explicit caller override — tests, CLI flags)
* 2. `process.env.OLP_HOME` (operator / CI env override)
* 3. `~/.olp` (default per ADR 0007 § 3)
* Resolved dynamically per call so tests setting OLP_HOME mid-run take effect.
*/
function _resolveOlpHome(opts) {
if (opts?.olpHome) return opts.olpHome;
if (process.env[OLP_HOME_ENV]) return process.env[OLP_HOME_ENV];
return DEFAULT_OLP_HOME;
}
// ── Internal state ────────────────────────────────────────────────────────
// Per-key in-process write-lock chain (§ 6.4). Map<key-id, Promise>.
// Each entry is the tail of the promise chain for that key-id; serialized
// access via _withKeyLock.
const _writeLocks = new Map();
// Test hook: injected pause between touchLastUsed's read and write phases.
// Used by acceptance criterion #7 to deterministically reproduce the
// interleaved revoke-during-touch race. Default no-op.
let _touchInterleaveHook = async () => {};
// ── Path helpers ──────────────────────────────────────────────────────────
function _olpHome(opts) { return _resolveOlpHome(opts); }
function _keysDir(opts) { return join(_olpHome(opts), 'keys'); }
function _keyDir(id, opts) { return join(_keysDir(opts), id); }
function _manifestPath(id, opts) { return join(_keyDir(id, opts), 'manifest.json'); }
// ── Crypto helpers ────────────────────────────────────────────────────────
/**
* Generate an opaque OLP token per § 5: `olp_<32-byte base64url>`.
* Total length 47 chars (4 prefix + 43 base64url).
*/
export function generateToken() {
return TOKEN_PREFIX + randomBytes(TOKEN_RANDOM_BYTES).toString('base64url');
}
/**
* Generate a key-id per § 3: lowercase alphanumeric + hyphen + underscore.
* 8 base64url chars from 6 random bytes; lowercased.
*/
export function generateKeyId() {
return randomBytes(KEY_ID_RANDOM_BYTES).toString('base64url').toLowerCase();
}
/**
* SHA-256 of the full token string (prefix included), hex-lowercase.
* Matches § 5 hash spec.
*/
export function hashToken(plaintextToken) {
return createHash('sha256').update(plaintextToken).digest('hex');
}
/**
* Constant-time comparison of two hex-encoded hashes.
* Returns false on length mismatch (rather than throwing).
*/
function _safeHexCompare(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
const bufA = Buffer.from(a, 'hex');
const bufB = Buffer.from(b, 'hex');
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
// ── Manifest schema validation (§ 4) ──────────────────────────────────────
/**
* Validates a parsed manifest object against the § 4 schema.
* Throws Error('manifest_invalid: <reason>') on schema violations.
* Unknown fields are tolerated (forward-compat).
*/
export function validateManifest(obj) {
if (typeof obj !== 'object' || obj === null) {
throw new Error('manifest_invalid: not an object');
}
if (obj.schema_version !== SCHEMA_VERSION) {
throw new Error(`manifest_invalid: unrecognized schema_version ${obj.schema_version}`);
}
for (const field of ['id', 'name', 'token_hash', 'token_hash_algo', 'owner_tier', 'providers_enabled', 'created_at']) {
if (obj[field] === undefined) {
throw new Error(`manifest_invalid: missing required field "${field}"`);
}
}
if (obj.token_hash_algo !== 'sha256') {
throw new Error(`manifest_invalid: unsupported token_hash_algo "${obj.token_hash_algo}"`);
}
if (!['owner', 'guest'].includes(obj.owner_tier)) {
throw new Error(`manifest_invalid: owner_tier must be "owner" or "guest", got "${obj.owner_tier}"`);
}
if (!(obj.providers_enabled === '*' || Array.isArray(obj.providers_enabled))) {
throw new Error('manifest_invalid: providers_enabled must be "*" or array');
}
return obj;
}
// ── Manifest IO (§ 6.1) ───────────────────────────────────────────────────
/**
* Read manifest for a key-id. Returns parsed object or null if file absent.
* Throws on JSON parse error or schema violation.
*/
export function readManifest(id, opts = {}) {
const path = _manifestPath(id, opts);
if (!existsSync(path)) return null;
const raw = readFileSync(path, 'utf-8');
const obj = JSON.parse(raw);
if (obj.id !== id) {
throw new Error(`manifest_id_mismatch: directory "${id}" contains manifest with id "${obj.id}"`);
}
return validateManifest(obj);
}
/**
* Atomic manifest write per § 6.1: tmpfile + fsync + rename, 0600 file / 0700 dir.
* Caller MUST hold the per-key write-lock (§ 6.4) when invoking this for
* lifecycle events. Lock acquisition is the caller's responsibility because
* createKey allocates a new key-id (no existing lock yet) while revoke /
* touchLastUsed operate on an existing key-id.
*/
export function writeManifestAtomic(id, manifest, opts = {}) {
if (manifest.id !== id) {
throw new Error(`writeManifestAtomic: manifest.id "${manifest.id}" mismatches id arg "${id}"`);
}
validateManifest(manifest);
const dir = _keyDir(id, opts);
mkdirSync(dir, { recursive: true, mode: 0o700 });
try { chmodSync(dir, 0o700); } catch { /* tolerate EPERM on pre-existing dir */ }
const finalPath = _manifestPath(id, opts);
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
const serialized = JSON.stringify(manifest, null, 2) + '\n';
const fd = openSync(tmpPath, 'w', 0o600);
try {
writeFileSync(fd, serialized);
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(tmpPath, finalPath);
// § 6.1 step 5: enforce 0600 even if umask interfered.
try { chmodSync(finalPath, 0o600); } catch { /* tolerate EPERM */ }
}
// ── Per-key write lock (§ 6.4) ────────────────────────────────────────────
/**
* Serialize concurrent in-process writes against the same key-id.
* Returns the value produced by fn(); awaits prior queued work first.
*
* Lock-map semantics: each caller stores its own `next` promise as the
* Map tail. New callers chain off the stored tail (`get(id)` returns the
* current tail = prior caller's next). On finally, we compare-and-delete
* the tail by identity — if no one queued after us, the Map still points
* at our `next` and we clean up; if a later caller chained, the Map points
* at their `next` and we leave it alone.
*
* (D44 fold-in correctness fix: prior version stored
* `prev.then(() => next)`, a derived promise that never matched the
* cleanup-identity check, leaving stale Map entries per unique key-id.
* Storing `next` directly fixes the cleanup; tested by 19u-extra.)
*/
async function _withKeyLock(id, fn) {
const prev = _writeLocks.get(id) ?? Promise.resolve();
let release;
const next = new Promise(r => { release = r; });
_writeLocks.set(id, next);
try {
await prev;
return await fn();
} finally {
release();
if (_writeLocks.get(id) === next) _writeLocks.delete(id);
}
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Create a new OLP key. Generates a fresh opaque token (returned in
* plaintext exactly once) and writes the manifest atomically.
*
* @param {object} args
* @param {string} args.name - human label (required, non-empty)
* @param {'owner'|'guest'} [args.owner_tier='guest']
* @param {string[]|'*'} [args.providers_enabled='*']
* @param {string} [args.notes='']
* @param {string} [args.olpHome] - test override; defaults to ~/.olp
* @returns {{ id: string, plaintext_token: string, manifest: object }}
* The plaintext_token MUST be displayed to the operator exactly once and
* never logged. The manifest contains only the hash.
*/
export function createKey(args = {}) {
const { name, owner_tier = 'guest', providers_enabled = '*', notes = '', olpHome } = args;
if (typeof name !== 'string' || name.length === 0) {
throw new Error('createKey: name is required (non-empty string)');
}
if (!['owner', 'guest'].includes(owner_tier)) {
throw new Error(`createKey: owner_tier must be "owner" or "guest", got "${owner_tier}"`);
}
if (!(providers_enabled === '*' || Array.isArray(providers_enabled))) {
throw new Error('createKey: providers_enabled must be "*" or string array');
}
const id = generateKeyId();
const plaintext_token = generateToken();
const manifest = {
schema_version: SCHEMA_VERSION,
id,
name,
token_hash: hashToken(plaintext_token),
token_hash_algo: 'sha256',
owner_tier,
providers_enabled,
quota: null,
created_at: new Date().toISOString(),
revoked_at: null,
last_used_at: null,
notes,
};
writeManifestAtomic(id, manifest, { olpHome });
return { id, plaintext_token, manifest };
}
/**
* List all keys. Returns array of manifest objects with `token_hash` redacted
* (kept on disk; omitted from list output per common operational hygiene —
* the hash itself is non-secret but listing it bulk-reads adds nothing).
*
* Skips manifests that fail schema validation; would log warn in real impl.
*
* @returns {Array<object>} possibly empty
*/
export function listKeys(opts = {}) {
const dir = _keysDir(opts);
if (!existsSync(dir)) return [];
const entries = readdirSync(dir);
const out = [];
for (const id of entries) {
if (id.startsWith('.')) continue;
try {
const m = readManifest(id, opts);
if (m === null) continue;
// Redact token_hash from list output (keep on disk).
const { token_hash, ...rest } = m;
out.push(rest);
} catch {
// Skip invalid manifest; production impl would log warn.
continue;
}
}
return out;
}
/**
* Revoke a key by id. Sets revoked_at to current ISO timestamp.
* Idempotent: revoking an already-revoked key returns true without rewriting.
* Returns false if the key-id does not exist on disk.
*/
export async function revokeKey(args = {}) {
const { id, olpHome } = args;
if (!id || typeof id !== 'string') throw new Error('revokeKey: id required');
return _withKeyLock(id, async () => {
const m = readManifest(id, { olpHome });
if (m === null) return false;
if (m.revoked_at !== null) return true; // already revoked; no-op
m.revoked_at = new Date().toISOString();
writeManifestAtomic(id, m, { olpHome });
return true;
});
}
/**
* Validate a plaintext token. Returns an identity object on success, null
* on any failure (missing token, no match, revoked, manifest invalid).
*
* Resolution order:
* 1. If plaintext === process.env.OLP_OWNER_TOKEN → synthetic env-owner identity.
* 2. If !plaintext and allowAnonymous → anonymous identity.
* 3. Else hash plaintext, scan ~/.olp/keys/ for a manifest with matching hash.
* Revoked manifests return null (caller produces 401 key_revoked).
*
* § 6.3.5: this function MUST hit the manifest filesystem on every call
* (no in-process validation cache at Phase 2).
*
* @param {string|null} plaintextToken
* @param {object} [opts]
* @param {boolean} [opts.allowAnonymous=false] - server reads config and passes through
* @param {string} [opts.olpHome]
* @returns {{ id, owner_tier, providers_enabled, source }|null}
*/
export function validateKey(plaintextToken, opts = {}) {
const { allowAnonymous = false, olpHome } = opts;
// Defensive: non-string truthy inputs (number, object, etc.) return null
// rather than throwing in hashToken. Matches missing-token semantics.
// (D44 fold-in P2 #2: prior version threw TypeError on validateKey({}) /
// validateKey(42) by reaching createHash().update(<non-string>).)
if (plaintextToken != null && typeof plaintextToken !== 'string') return null;
// 1. Env owner override (§ 9.4)
const envToken = process.env[ENV_OWNER_VAR];
if (envToken && plaintextToken && _safeHexCompare(hashToken(plaintextToken), hashToken(envToken))) {
return {
id: ENV_OWNER_KEY_ID,
owner_tier: 'owner',
providers_enabled: '*',
source: 'env',
};
}
// 2. Anonymous fallback (§ 7)
if (!plaintextToken) {
if (allowAnonymous) {
return {
id: ANONYMOUS_KEY_ID,
owner_tier: 'anonymous',
providers_enabled: '*',
source: 'anonymous',
};
}
return null;
}
// 3. Filesystem manifest lookup (§ 6.3.5 — every request, no cache)
const dir = _keysDir({ olpHome });
if (!existsSync(dir)) return null;
const hash = hashToken(plaintextToken);
let entries;
try { entries = readdirSync(dir); } catch { return null; }
for (const id of entries) {
if (id.startsWith('.')) continue;
let m;
try { m = readManifest(id, { olpHome }); } catch { continue; }
if (m === null) continue;
if (!_safeHexCompare(m.token_hash, hash)) continue;
if (m.revoked_at !== null) return null; // revoked → caller produces 401
return {
id: m.id,
owner_tier: m.owner_tier,
providers_enabled: m.providers_enabled,
source: 'filesystem',
};
}
return null;
}
/**
* Update last_used_at lazily after a successful request. Per § 6.3:
* 1. Re-read latest manifest from disk inside the per-key write-lock.
* 2. If revoked_at is non-null in fresh read → NO-OP (preserve revocation).
* 3. Otherwise merge new last_used_at preserving all other fields.
*
* Best-effort: any error is logged via console.warn and swallowed; this
* function never throws (§ 6.3 "Failure logs warn and does NOT fail the
* request").
*
* Anonymous + env-owner identities have no manifest → no-op silently.
*/
export async function touchLastUsed(id, opts = {}) {
if (id === ANONYMOUS_KEY_ID || id === ENV_OWNER_KEY_ID) return;
try {
await _withKeyLock(id, async () => {
// Test hook fires BEFORE the read so race tests can deterministically
// inject an external revoke that the read must observe. In production
// the hook is a no-op; the read is the only filesystem access and
// happens inside the per-key write-lock.
await _touchInterleaveHook(id, opts);
// § 6.3 step 1: re-read latest manifest inside the lock.
const fresh = readManifest(id, opts);
if (fresh === null) return; // key removed from disk
// § 6.3 step 2: NO-OP if revoked.
if (fresh.revoked_at !== null) return;
// § 6.3 step 3: merge last_used_at preserving all other fields.
fresh.last_used_at = new Date().toISOString();
writeManifestAtomic(id, fresh, opts);
});
} catch (err) {
// § 6.3 best-effort: warn, never throw.
console.warn(JSON.stringify({
event: 'last_used_update_failed',
id,
error: err?.message ?? String(err),
}));
}
}
// ── Auth config loader (§ 7.2) ────────────────────────────────────────────
/**
* Read the `auth` block from ~/.olp/config.json. All fields defaulted
* so partial / absent config is safe.
*
* Defaults per ADR § 7.2:
* - allow_anonymous: false (production-off default)
* - owner_only_endpoints: ['/health'] (D46 consumes; D45 only loads)
* - fallback_detail_header_policy: 'owner_only' (D46 consumes; D45 only loads)
*
* Returns the auth config object. Never throws — missing file / parse
* error / missing `auth` key all fall back to defaults.
*
* @param {object} [opts]
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
* @returns {{ allow_anonymous: boolean, owner_only_endpoints: string[], fallback_detail_header_policy: 'owner_only'|'all'|'none' }}
*/
export function loadAuthConfigSync(opts = {}) {
const olpHome = _resolveOlpHome(opts);
const path = join(olpHome, 'config.json');
const DEFAULTS = {
allow_anonymous: false,
owner_only_endpoints: ['/health'],
fallback_detail_header_policy: 'owner_only',
};
if (!existsSync(path)) return { ...DEFAULTS };
try {
const raw = readFileSync(path, 'utf-8');
const cfg = JSON.parse(raw);
const auth = (cfg && typeof cfg === 'object' && cfg.auth && typeof cfg.auth === 'object')
? cfg.auth
: {};
return {
allow_anonymous: typeof auth.allow_anonymous === 'boolean' ? auth.allow_anonymous : DEFAULTS.allow_anonymous,
owner_only_endpoints: Array.isArray(auth.owner_only_endpoints) ? auth.owner_only_endpoints : DEFAULTS.owner_only_endpoints,
fallback_detail_header_policy: ['owner_only', 'all', 'none'].includes(auth.fallback_detail_header_policy)
? auth.fallback_detail_header_policy
: DEFAULTS.fallback_detail_header_policy,
};
} catch {
// Malformed JSON / unreadable file → safe defaults
return { ...DEFAULTS };
}
}
// ── Test-only hooks ───────────────────────────────────────────────────────
/**
* Test-only: install a hook called inside touchLastUsed between the
* read-phase and write-phase. Used to deterministically reproduce the
* interleaved-revoke race (acceptance criterion #7).
*
* Pass null to reset to no-op.
*/
export function __setTouchInterleaveHook(hookOrNull) {
_touchInterleaveHook = hookOrNull ?? (async () => {});
}
/**
* Test-only: clear all in-process write-locks. Useful for test cleanup
* to avoid lock state leaking across tests.
*/
export function __resetWriteLocks() {
_writeLocks.clear();
}
/**
* Test-only: report the current size of the in-process write-lock Map.
* Used by Suite 19 to verify lock cleanup fires (D44 fold-in P2 #1
* regression test — Map must shrink to 0 after all queued callers finish).
*/
export function __writeLockSize() {
return _writeLocks.size;
}
+8 -1
View File
@@ -9,6 +9,12 @@
* present at D4 implementation (captured at D4 implementation per ALIGNMENT.md * present at D4 implementation (captured at D4 implementation per ALIGNMENT.md
* Rule 5 — the circular ALIGNMENT.md ↔ plugin header citation is resolved by * Rule 5 — the circular ALIGNMENT.md ↔ plugin header citation is resolved by
* this in-plugin record of the OLP-side observation). * this in-plugin record of the OLP-side observation).
* D36 #15: See docs/provider-audits/anthropic.md for the version-capture
* artifact (single living document — captured 2026-05-24; re-capture at every
* plugin touch or annual audit). The artifact records the live `claude --version`
* today (v2.1.132) versus the plugin pin (v2.1.89, D4) and verifies that the
* load-bearing flags (-p, --output-format, --no-session-persistence, --model,
* --debug) are all still present and semantically unchanged in the current binary.
* *
* Spawn pattern ported from: * Spawn pattern ported from:
* OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence) * OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence)
@@ -360,7 +366,8 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
// timeout. This unconditional throw closes the race — SPAWN_TIMEOUT always // timeout. This unconditional throw closes the race — SPAWN_TIMEOUT always
// surfaces as a hard trigger to the fallback engine regardless of which path // surfaces as a hard trigger to the fallback engine regardless of which path
// the timer fire took. Note: any partial chunks already yielded are discarded // the timer fire took. Note: any partial chunks already yielded are discarded
// by the caller; SPAWN_TIMEOUT salvage parity is tracked in issue #3. // by the caller. SPAWN_TIMEOUT is intentionally excluded from D16 salvage —
// see ADR 0004 Amendment 1 § "Why SPAWN_TIMEOUT is excluded from salvage".
if (spawnTimedOut) { if (spawnTimedOut) {
throw new ProviderError( throw new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`, `claude spawn timed out after ${maxSpawnTimeMs}ms`,
+11 -3
View File
@@ -141,8 +141,15 @@ export function validateProvider(p) {
/** /**
* Error codes surfaced by provider plugins. * Error codes surfaced by provider plugins.
* *
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7): * v0.1 live codes (per ADR 0004 Amendment 3, D34 F7 and Amendment 4, D38):
* SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT * SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT, CONCURRENCY_LIMIT
*
* CONCURRENCY_LIMIT (D38, issue #1): synthesized by the orchestration layer
* (NOT thrown by provider plugins themselves) when a spawn is attempted
* against a provider already at its hints.maxConcurrent limit. The fallback
* engine treats this as a hard trigger so the chain advances to the next hop
* rather than queueing. See ADR 0002 Amendment 6 (runtime enforcement) and
* ADR 0004 Amendment 4 (CONCURRENCY_LIMIT in hard-trigger taxonomy).
* *
* QUOTA_EXHAUSTED and RATE_LIMITED were removed (D34 F7): no plugin parses * QUOTA_EXHAUSTED and RATE_LIMITED were removed (D34 F7): no plugin parses
* underlying-API HTTP status codes at v0.1, so these codes are never emitted. * underlying-API HTTP status codes at v0.1, so these codes are never emitted.
@@ -153,7 +160,8 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
'AUTH_MISSING', 'AUTH_MISSING',
'CLI_NOT_FOUND', 'CLI_NOT_FOUND',
'SPAWN_FAILED', 'SPAWN_FAILED',
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger 'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
'CONCURRENCY_LIMIT', // ADR 0002 Amendment 6 / ADR 0004 Amendment 4 (D38, issue #1)
]); ]);
export class ProviderError extends Error { export class ProviderError extends Error {
+130
View File
@@ -209,3 +209,133 @@ export function getProviderByName(loadedProviders, name) {
export function listAllProviderNames() { export function listAllProviderNames() {
return STATIC_REGISTRY.map(p => p.name); return STATIC_REGISTRY.map(p => p.name);
} }
// ── Concurrency semaphore (D38, issue #1) ─────────────────────────────────
//
// Authority: ADR 0002 Amendment 6 (maxConcurrent runtime enforcement landed in D38)
// and ADR 0004 Amendment 4 (CONCURRENCY_LIMIT added to hard-trigger taxonomy).
//
// Per-provider in-flight spawn counter. The orchestration layer (server.mjs
// handleChatCompletions) calls tryAcquireSpawn() before provider.spawn() and
// releaseSpawn() after spawn lifecycle completion. On saturation, the caller
// synthesises a ProviderError(CONCURRENCY_LIMIT) which the fallback engine
// treats as a hard trigger — the chain advances to the next hop. If the
// entire chain is saturated, the user receives a chain-exhausted error via
// the existing executeWithFallback exhaustion path.
//
// Design decision (deliberate): immediate-advancement via fallback, NOT
// queue+timeout. Rationale (per D38 issue #1 design discussion):
// 1. The fallback chain exists precisely for this kind of overflow.
// 2. A queue introduces head-of-line blocking + a new timeout config surface.
// 3. Immediate-advancement gives fail-fast latency, matching the OLP
// multi-provider proxy philosophy.
// 4. Queue+timeout is deferred — track via a future issue if real usage
// shows need.
//
// **Atomicity invariant**: JavaScript is single-threaded; the
// read-then-write pair inside tryAcquireSpawn() executes synchronously with
// NO `await` between the check and the increment. This is the only reason
// the semaphore is correct without a Mutex. A future async refactor MUST
// preserve this — do NOT introduce an `await` between the limit check and
// the count update or the semaphore loses its mutual-exclusion guarantee
// (two callers could each read count=limit-1 before either increments).
//
// Module-level state: lives for the process lifetime; tests that need
// isolation should call __resetSpawnCounters() in their teardown.
//
// @type {Map<string, number>} provider name → current in-flight spawn count
const _activeSpawns = new Map();
/**
* Default cap for tryAcquireSpawn when a plugin omits hints.maxConcurrent.
*
* validateProvider in base.mjs requires hints.maxConcurrent to be a
* non-negative integer at startup, so a missing value should not happen in
* production. This default is defense-in-depth for callers that pass a
* stripped-down provider stub (e.g., in tests) or future plugin paths that
* bypass validation. The value (4) matches the v0.1 plugin defaults
* (anthropic / codex / mistral all declare hints.maxConcurrent: 4).
*/
export const DEFAULT_MAX_CONCURRENT_SPAWNS = 4;
/**
* Atomically attempts to reserve a spawn slot for `providerName`.
*
* If the current in-flight count is below `maxConcurrent`, increments the
* counter and returns true. Otherwise returns false WITHOUT incrementing —
* the caller is responsible for surfacing the saturation as a
* ProviderError(CONCURRENCY_LIMIT) for the fallback engine to consume.
*
* Atomicity: the check and the increment happen in a single synchronous
* block with no `await` in between. See the module-level invariant comment
* above for why this is sufficient.
*
* @param {string} providerName — provider key (e.g. 'anthropic')
* @param {number} [maxConcurrent=DEFAULT_MAX_CONCURRENT_SPAWNS] — limit from hints.maxConcurrent
* @returns {boolean} true if a slot was acquired, false if at limit
*/
export function tryAcquireSpawn(providerName, maxConcurrent = DEFAULT_MAX_CONCURRENT_SPAWNS) {
// Defensive: coerce undefined/null/non-integer to the default. validateProvider
// already enforces this at startup; this guards future plugin paths that
// bypass validation.
const limit = (typeof maxConcurrent === 'number' && Number.isInteger(maxConcurrent) && maxConcurrent >= 0)
? maxConcurrent
: DEFAULT_MAX_CONCURRENT_SPAWNS;
const current = _activeSpawns.get(providerName) ?? 0;
// Atomic check-then-increment (no `await` between read and write).
if (current >= limit) {
return false;
}
_activeSpawns.set(providerName, current + 1);
return true;
}
/**
* Releases a spawn slot for `providerName`. Must be called exactly once per
* successful tryAcquireSpawn() call, regardless of whether the spawn succeeded
* or threw. The caller in server.mjs uses a try/finally pattern to guarantee
* the release fires on every exit path (success, error, abort, streaming end).
*
* Throws if the count would go negative — that indicates a bug (a release
* without a matching acquire, or a double-release). The throw is loud on
* purpose so the bug surfaces in tests rather than silently corrupting the
* counter for future requests.
*
* @param {string} providerName — provider key (e.g. 'anthropic')
* @throws {Error} if no slot is currently held for providerName
*/
export function releaseSpawn(providerName) {
const current = _activeSpawns.get(providerName) ?? 0;
if (current <= 0) {
throw new Error(
`releaseSpawn(${providerName}): counter would go negative — release without matching acquire (or double-release)`,
);
}
const next = current - 1;
if (next === 0) {
_activeSpawns.delete(providerName);
} else {
_activeSpawns.set(providerName, next);
}
}
/**
* Returns the current in-flight spawn count for `providerName`. Used by
* /health, diagnostics, and tests that need to assert peak concurrency.
*
* @param {string} providerName
* @returns {number} non-negative integer; 0 if no spawns in flight
*/
export function getActiveSpawnCount(providerName) {
return _activeSpawns.get(providerName) ?? 0;
}
/**
* @internal — test seam: reset all in-flight spawn counters to zero. Used by
* test teardown to ensure a clean state across suites. Production code MUST
* NOT call this — it bypasses the acquire/release pairing invariant.
*/
export function __resetSpawnCounters() {
_activeSpawns.clear();
}
+15 -10
View File
@@ -142,17 +142,22 @@
* D-later E2E will capture real `vibe --output json` stdout and pin the * D-later E2E will capture real `vibe --output json` stdout and pin the
* actual field names; mismatched fields will be corrected then. * actual field names; mismatched fields will be corrected then.
* *
* A5 (model flag — UNPINNED-D-later-verifies): * A5 (model flag — CONFIRMED-NOT-APPLICABLE):
* Name: model_flag * Name: model_flag
* Status: UNPINNED-D-later-verifies * Status: CONFIRMED-NOT-APPLICABLE
* Basis: DOCS-3 mentions model selection via "/config" inside the interactive * Basis: DeepWiki (DOCS-4) full CLI command flag enumeration confirms that
* Vibe UI. The quickstart (DOCS-1) does not show a `--model` CLI flag for * `vibe` has no `--model` flag in programmatic mode. Model selection happens
* programmatic mode. OLP does NOT pass `--model` in the spawn args at D8 * exclusively via `~/.vibe/config.toml` (set interactively via the `/config`
* because no CLI reference confirms this flag exists on the `vibe` command * command inside Vibe per DOCS-3) — there is no CLI-flag surface OLP can use
* (per ALIGNMENT.md Rule 2: "if the underlying authority does not perform * to pass `model` per-request. ALIGNMENT.md Rule 2: the underlying authority
* the operation, the PR must state this explicitly"). * does not perform the operation, so OLP must not invent one. The IR's
* D-later E2E: run `vibe --help` to enumerate all flags; if --model exists * `model` field is used by OLP for routing only; the Vibe CLI will use
* and the flag name is confirmed, add it to spawn args with the model ID. * whatever model is configured at the user level in `~/.vibe/config.toml`.
* Pinning source: DeepWiki CLI commands reference enumeration (DOCS-4).
* See also `irToMistral` (line 371-374) which records the same finding at
* the spawn-args construction site.
* (D36 #6: status flipped from UNPINNED-D-later-verifies → CONFIRMED-NOT-APPLICABLE.
* Header status now matches the spawn-site finding that was already in place at D8.)
* *
* A6 (exact model IDs — UNPINNED-D-later-verifies): * A6 (exact model IDs — UNPINNED-D-later-verifies):
* Name: model_ids * Name: model_ids
+6 -2
View File
@@ -1,12 +1,16 @@
{ {
"name": "olp", "name": "olp",
"version": "0.1.0", "version": "0.2.0",
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.", "description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
"type": "module", "type": "module",
"main": "server.mjs", "main": "server.mjs",
"bin": {
"olp-keys": "./bin/olp-keys.mjs"
},
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
"test": "node test-features.mjs" "test": "node test-features.mjs",
"olp-keys": "node bin/olp-keys.mjs"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+649 -54
View File
@@ -29,7 +29,16 @@ import {
generateRequestId, generateRequestId,
SSE_DONE, SSE_DONE,
} from './lib/ir/ir-to-openai.mjs'; } from './lib/ir/ir-to-openai.mjs';
import { loadProviders, listAllProviderNames, getAliasMap, getModelCreated } from './lib/providers/index.mjs'; import {
loadProviders,
listAllProviderNames,
getAliasMap,
getModelCreated,
tryAcquireSpawn,
releaseSpawn,
getActiveSpawnCount,
DEFAULT_MAX_CONCURRENT_SPAWNS,
} from './lib/providers/index.mjs';
import { ProviderError } from './lib/providers/base.mjs'; import { ProviderError } from './lib/providers/base.mjs';
import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs'; import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs';
import { CacheStore } from './lib/cache/store.mjs'; import { CacheStore } from './lib/cache/store.mjs';
@@ -39,6 +48,15 @@ import {
buildDefaultChain, buildDefaultChain,
loadFallbackConfigSync, loadFallbackConfigSync,
} from './lib/fallback/engine.mjs'; } from './lib/fallback/engine.mjs';
// Phase 2 / D45 — multi-key auth integration per ADR 0007.
import {
validateKey,
touchLastUsed,
loadAuthConfigSync,
ANONYMOUS_KEY_ID,
ENV_OWNER_KEY_ID,
} from './lib/keys.mjs';
import { appendAuditEvent } from './lib/audit.mjs';
// ── Config ──────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────
@@ -84,6 +102,25 @@ if (_softTriggersConfigured) {
}); });
} }
// ── Auth config (Phase 2 / D45, ADR 0007 § 7.2) ───────────────────────────
// auth.allow_anonymous default false (production-off). Test seam below.
let _authConfig = loadAuthConfigSync();
if (_authConfig.allow_anonymous === true) {
logEvent('warn', 'auth_allow_anonymous_enabled', {
message: 'config.json auth.allow_anonymous is true — all routes accept requests without an OLP API key; production deployments should set this to false (ADR 0007 § 7).',
});
}
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
export function __setAuthConfig(config) {
_authConfig = config ?? { allow_anonymous: false, owner_only_endpoints: ['/health'], fallback_detail_header_policy: 'owner_only' };
}
/** @internal — reset auth config to file-loaded state. */
export function __resetAuthConfig() {
_authConfig = loadAuthConfigSync();
}
// ── Provider registry ───────────────────────────────────────────────────── // ── Provider registry ─────────────────────────────────────────────────────
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless // ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
// ~/.olp/config.json has providers.enabled.X = true. // ~/.olp/config.json has providers.enabled.X = true.
@@ -263,15 +300,272 @@ function olpErrorHeaders({ startMs, model }) {
}; };
} }
// ── X-OLP-Fallback-Detail (D40, issue #7) ─────────────────────────────────
/**
* 4KB UTF-8 byte cap on the X-OLP-Fallback-Detail header value. Per RFC 7230 §3.2.5
* intermediaries are not obligated to forward arbitrarily large header values;
* 4KB matches the conservative upper bound (well below the 8KB total-header
* default of common reverse proxies — nginx `large_client_header_buffers`,
* Apache `LimitRequestFieldSize`). Tuples beyond the cap are dropped and
* a sentinel { truncated:true, omitted_hops:N } is appended.
*/
export const FALLBACK_DETAIL_BYTE_CAP = 4096;
/**
* Serialises per-hop fallback failure tuples into the X-OLP-Fallback-Detail
* header value. Returns null if the input array is empty/missing (header
* should not be emitted in that case).
*
* D40 (issue #7) — see ADR 0004 § Observability headers.
*
* Cap behaviour:
* - JSON.stringify the tuples; if Buffer.byteLength <= 4096, return as-is.
* - Otherwise, drop tuples from the tail one at a time until the array PLUS
* a trailing { truncated:true, omitted_hops:N } sentinel fits under the cap.
* - If even a single tuple + sentinel cannot fit (extremely long error_message
* beyond engine truncation, e.g. very long provider/model names), return
* just the sentinel { truncated:true, omitted_hops:<all> } — never produce
* a value > 4096 bytes.
*
* RFC 7230 hygiene: JSON.stringify already escapes raw newlines (\n → \\n),
* carriage returns, and other control characters. In addition, we escape all
* non-ASCII code points to \uXXXX sequences because Node's HTTP header
* validator rejects multi-byte UTF-8 in field values (and RFC 7230 §3.2.6
* limits `field-vchar` to ASCII VCHAR / obs-text). Without this step, an
* em dash (U+2014) in a synthesised error message — e.g. the CONCURRENCY_LIMIT
* message produced in server.mjs `collectAllChunks` — would trigger
* `Invalid character in header content` from `res.writeHead`.
*
* @param {Array<object>|null|undefined} fallbackDetail — from FallbackResult.fallbackDetail
* @returns {string|null} — header value, or null to skip emission
*/
export function serializeFallbackDetailHeader(fallbackDetail) {
if (!Array.isArray(fallbackDetail) || fallbackDetail.length === 0) {
return null;
}
const full = jsonStringifyAscii(fallbackDetail);
if (Buffer.byteLength(full, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
return full;
}
// Cap exceeded — drop tail tuples until [...kept, sentinel] fits.
// Linear scan from the full array down to 0 kept tuples. Worst case O(n^2)
// on serialisation length, but n is bounded by chain length (small) so this
// is fine in practice.
for (let kept = fallbackDetail.length - 1; kept >= 0; kept--) {
const omitted = fallbackDetail.length - kept;
const sentinel = { truncated: true, omitted_hops: omitted };
const candidate = jsonStringifyAscii([...fallbackDetail.slice(0, kept), sentinel]);
if (Buffer.byteLength(candidate, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
return candidate;
}
}
// Even an array containing only the sentinel exceeds the cap — produce the
// shortest possible valid sentinel value. This branch should be unreachable
// for any realistic chain (the sentinel itself is ~45 bytes for omitted_hops
// up to 9999).
return jsonStringifyAscii([{ truncated: true, omitted_hops: fallbackDetail.length }]);
}
/**
* JSON.stringify wrapper that escapes every non-ASCII code point as \uXXXX
* so the result is safe to embed in an HTTP header value (RFC 7230 §3.2.6
* field-vchar). JSON itself accepts both literal Unicode and \uXXXX escapes,
* so JSON.parse round-trips correctly.
*
* Surrogate-pair handling: characters above U+FFFF (emoji etc.) are already
* emitted as JS surrogate pairs by the string iterator; each surrogate is
* a code unit in range 0xD8000xDFFF, which our >= 0x80 guard catches.
*
* @param {unknown} value
* @returns {string}
*/
function jsonStringifyAscii(value) {
// The replace pattern is the UTF-8 literal byte range for code points
// U+0080..U+FFFF (every non-ASCII BMP character). U+0080 is non-printable,
// so the source line can render as the empty character class "[-...]" in
// editors that hide it — the range is intentional and load-bearing for
// RFC 7230 §3.2.6 compliance.
return JSON.stringify(value).replace(/[€-￿]/g, (ch) => {
return '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
});
}
/**
* Identity-aware gate for X-OLP-Fallback-Detail emission per ADR 0007 § 7.2.
* Reads `_authConfig.fallback_detail_header_policy`:
* - 'owner_only' (default) → emit only when olpIdentity.owner_tier === 'owner'
* - 'all' → emit unconditionally (v0.1.1 behaviour, opt-in)
* - 'none' → suppress unconditionally
* When olpIdentity is null (early-error paths before auth completed),
* defaults to 'all' → emit (preserves the v0.1.1 ungated behaviour for
* pre-auth errors where we don't yet know identity).
*
* @param {{owner_tier?: string}|null|undefined} olpIdentity
* @returns {boolean}
*/
function shouldEmitFallbackDetailHeader(olpIdentity) {
const policy = _authConfig?.fallback_detail_header_policy ?? 'owner_only';
if (policy === 'none') return false;
if (policy === 'all') return true;
// 'owner_only' — gate by identity tier
if (!olpIdentity) return true; // pre-auth path: don't suppress diagnostic info
return olpIdentity.owner_tier === 'owner';
}
/**
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
* failure tuples are non-empty AND the per-request identity is permitted
* to see the header per the policy (ADR 0007 § 7.2). Returns the base
* object unchanged otherwise.
*
* D40 (issue #7) — gating added at D46 per ADR 0004 Amendment 5 ratification.
*
* @param {Record<string,string>} baseHeaders
* @param {Array<object>|null|undefined} fallbackDetail
* @param {{owner_tier?: string}|null|undefined} olpIdentity - request identity; null on pre-auth paths
* @returns {Record<string,string>}
*/
function withFallbackDetailHeader(baseHeaders, fallbackDetail, olpIdentity) {
if (!shouldEmitFallbackDetailHeader(olpIdentity)) return baseHeaders;
const value = serializeFallbackDetailHeader(fallbackDetail);
if (value === null) return baseHeaders;
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
}
// ── Route handlers ──────────────────────────────────────────────────────── // ── Route handlers ────────────────────────────────────────────────────────
// ── Auth middleware (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
/**
* Extract the plaintext OLP token from request headers.
* Tries `Authorization: Bearer <token>` first, then `x-api-key: <token>`.
* Returns the token string or null.
*
* @param {import('node:http').IncomingMessage} req
* @returns {string|null}
*/
function extractToken(req) {
const auth = req.headers['authorization'];
if (typeof auth === 'string') {
const match = /^Bearer\s+(\S+)$/i.exec(auth);
if (match) return match[1];
}
const xKey = req.headers['x-api-key'];
if (typeof xKey === 'string' && xKey.length > 0) return xKey;
return null;
}
/**
* Authenticate the request per ADR 0007 §§ 5 / 7 / 9.4.
* - Try env-owner override first (OLP_OWNER_TOKEN).
* - Then filesystem manifest lookup by SHA-256 hash.
* - Else, if auth.allow_anonymous is true → anonymous identity.
* - Else → 401 auth_required.
*
* Returns { ok: true, authContext } on success or
* { ok: false, status, code, message } on failure (401).
*
* @param {import('node:http').IncomingMessage} req
* @returns {{ ok: true, authContext: { keyId: string, owner_tier: 'owner'|'guest'|'anonymous', providers_enabled: string[]|'*', source: 'env'|'filesystem'|'anonymous' } } | { ok: false, status: number, code: string, message: string }}
*/
function authenticate(req) {
const token = extractToken(req);
const identity = validateKey(token, { allowAnonymous: _authConfig.allow_anonymous });
if (identity === null) {
// Distinguish "no token presented" from "token presented but invalid/revoked".
// Both surface as 401 to the client (don't leak which case it was), but the
// server-side audit + log captures the source for operator diagnosis.
return {
ok: false,
status: 401,
code: token ? 'invalid_or_revoked_key' : 'auth_required',
message: token
? 'OLP API key is invalid or has been revoked.'
: 'OLP API key required. Pass via "Authorization: Bearer <token>" or "x-api-key: <token>".',
};
}
return {
ok: true,
authContext: {
keyId: identity.id,
owner_tier: identity.owner_tier,
providers_enabled: identity.providers_enabled,
source: identity.source,
},
};
}
/**
* Check whether the given provider key is permitted for this identity.
* `providers_enabled: '*'` grants all; an array is a whitelist.
*
* @param {{ providers_enabled: string[]|'*' }} authContext
* @param {string} providerKey
* @returns {boolean}
*/
function isProviderEnabled(authContext, providerKey) {
if (authContext.providers_enabled === '*') return true;
return Array.isArray(authContext.providers_enabled) && authContext.providers_enabled.includes(providerKey);
}
/** /**
* GET /health * GET /health
* Returns server health including count of loaded providers and per-provider *
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used * Phase 2 / D46 (ADR 0007 § 7.1): identity-aware payload.
* by startup and /health endpoint per ADR 0002 § Provider contract description). * - owner identity → full per-provider details (existing payload)
* - guest / anonymous → trimmed { ok, version } only
* - no auth, allow_anonymous=false → 401 (consistent with /v1/* routes)
*
* The trimming behavior is gated on `_authConfig.owner_only_endpoints` —
* the entry `/health` lives there by default. Removing `/health` from
* the list reverts to v0.1.1 full-payload-to-everyone behaviour.
*
* Authority: ADR 0007 § 7.1 (Identity-class table) + § 7.2 (owner_only_endpoints).
* Closes acceptance criterion #4.
*/ */
async function handleHealth(req, res) { async function handleHealth(req, res) {
const startMs = Date.now();
// D46: audit on /health is intentionally NOT enabled at Phase 2.
// /health is a high-volume monitoring endpoint; per-call audit rows would
// generate operational noise that has no observability value until a
// Phase 3+ Dashboard aggregates /health stats. Deferred to Phase 3.
const authResult = authenticate(req);
if (!authResult.ok) {
return sendError(res, authResult.status, authResult.message, authResult.code);
}
const olpIdentity = authResult.authContext;
// Per § 7.1: owner sees full payload; guest + anonymous see trimmed.
// Per § 7.2: gating is opt-out via `owner_only_endpoints` config; if
// `/health` is removed from the list, all identities see the full
// payload (operators wanting v0.1.1 behaviour have this knob).
const gatedEndpoints = Array.isArray(_authConfig?.owner_only_endpoints)
? _authConfig.owner_only_endpoints
: ['/health'];
const isGated = gatedEndpoints.includes('/health');
const isOwner = olpIdentity.owner_tier === 'owner';
// Touch last_used_at for filesystem identities post-response. The callee
// also early-returns on ANONYMOUS / ENV_OWNER keyIds (lib/keys.mjs § 6.3
// wrapper) — this guard is defense-in-depth + skip the async call entirely
// for non-filesystem identities.
if (olpIdentity.keyId !== ANONYMOUS_KEY_ID && olpIdentity.keyId !== ENV_OWNER_KEY_ID) {
res.on('finish', () => {
touchLastUsed(olpIdentity.keyId).catch(() => {});
});
}
if (isGated && !isOwner) {
// Trimmed payload per § 7.1.
return sendJSON(res, 200, { ok: true, version: VERSION });
}
// Full payload (owner OR /health removed from owner_only_endpoints).
const enabled = loadedProviders.size; const enabled = loadedProviders.size;
const available = listAllProviderNames().length; const available = listAllProviderNames().length;
const providerStatuses = {}; const providerStatuses = {};
@@ -305,6 +599,46 @@ async function handleHealth(req, res) {
* Empty case: if no providers are enabled, data: [] is returned naturally. * Empty case: if no providers are enabled, data: [] is returned naturally.
*/ */
function handleModels(req, res) { function handleModels(req, res) {
const startMs = Date.now();
// Audit + auth for /v1/models per Phase 2 / D45 (ADR 0007 § 7).
const auditCtx = {
ts: new Date().toISOString(),
key_id: ANONYMOUS_KEY_ID,
owner_tier: 'anonymous',
method: 'GET',
path: '/v1/models',
provider: null,
model: null,
status_code: 0,
latency_ms: 0,
cache_status: null,
fallback_hops: 0,
tried_providers: [],
error_code: null,
ir_request_hash: null,
chain_id: null,
};
let _authedKeyId = null;
res.on('finish', () => {
auditCtx.status_code = res.statusCode;
auditCtx.latency_ms = Date.now() - startMs;
try { appendAuditEvent(auditCtx); } catch { /* best-effort */ }
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
touchLastUsed(_authedKeyId).catch(() => {});
}
});
const authResult = authenticate(req);
if (!authResult.ok) {
auditCtx.error_code = authResult.code;
return sendError(res, authResult.status, authResult.message, authResult.code,
olpErrorHeaders({ startMs }));
}
auditCtx.key_id = authResult.authContext.keyId;
auditCtx.owner_tier = authResult.authContext.owner_tier;
_authedKeyId = authResult.authContext.keyId;
const data = []; const data = [];
// Canonical entries first // Canonical entries first
@@ -354,9 +688,64 @@ function handleModels(req, res) {
async function handleChatCompletions(req, res) { async function handleChatCompletions(req, res) {
const startMs = Date.now(); const startMs = Date.now();
// Audit context — fields populated as the request proceeds; § 8 schema.
// Fired on res.on('finish') below regardless of success / error path.
const auditCtx = {
ts: new Date().toISOString(),
key_id: ANONYMOUS_KEY_ID, // updated post-auth
owner_tier: 'anonymous', // updated post-auth
method: 'POST',
path: '/v1/chat/completions',
provider: null,
model: null,
status_code: 0, // updated on finish
latency_ms: 0, // updated on finish
cache_status: null,
fallback_hops: 0,
tried_providers: [],
error_code: null,
ir_request_hash: null,
chain_id: null,
};
// Wire post-response audit + lazy last_used_at update once, at the top, so
// every code path below (401, 400, 500, 200, streaming) emits an audit row.
// touchLastUsed is best-effort and never throws (§ 6.3).
let _authedKeyId = null;
res.on('finish', () => {
auditCtx.status_code = res.statusCode;
auditCtx.latency_ms = Date.now() - startMs;
try { appendAuditEvent(auditCtx); } catch { /* audit is best-effort */ }
if (_authedKeyId && _authedKeyId !== ANONYMOUS_KEY_ID) {
// Anonymous + __env_owner__ have no on-disk manifest to touch; touchLastUsed
// itself early-returns for those identities (§ 6.3 wrapper). For
// filesystem-stored keys, fire-and-forget.
touchLastUsed(_authedKeyId).catch(() => { /* warned internally */ });
}
});
// ── Authentication (Phase 2 / D45, ADR 0007 § 5 + § 7) ───────────────────
// olpIdentity carries the OLP-side identity (keyId / owner_tier /
// providers_enabled). It is SEPARATE from `authContext` which is the
// per-provider OAuth/credential artifact passed to provider.spawn().
// Provider plugins treat `authContext === null` as "fall back to your
// own credential discovery (env / keychain / file)" — that contract is
// preserved at D45. Per-provider per-key credential mapping is a Phase
// 3+ concern (ADR 0007 § 12 Out of scope; v0.1 spec § 4.5 anticipated).
const authResult = authenticate(req);
if (!authResult.ok) {
auditCtx.error_code = authResult.code;
return sendError(res, authResult.status, authResult.message, authResult.code,
olpErrorHeaders({ startMs }));
}
const olpIdentity = authResult.authContext;
auditCtx.key_id = olpIdentity.keyId;
auditCtx.owner_tier = olpIdentity.owner_tier;
_authedKeyId = olpIdentity.keyId;
// Require JSON content-type // Require JSON content-type
const ct = req.headers['content-type'] ?? ''; const ct = req.headers['content-type'] ?? '';
if (!ct.includes('application/json')) { if (!ct.includes('application/json')) {
auditCtx.error_code = 'invalid_content_type';
return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error', return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error',
olpErrorHeaders({ startMs })); olpErrorHeaders({ startMs }));
} }
@@ -365,6 +754,7 @@ async function handleChatCompletions(req, res) {
try { try {
body = await readJSON(req); body = await readJSON(req);
} catch (e) { } catch (e) {
auditCtx.error_code = 'invalid_request_body';
return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error', return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error',
olpErrorHeaders({ startMs })); olpErrorHeaders({ startMs }));
} }
@@ -375,22 +765,22 @@ async function handleChatCompletions(req, res) {
ir = openAIToIR(body); ir = openAIToIR(body);
} catch (e) { } catch (e) {
if (e instanceof BadRequestError) { if (e instanceof BadRequestError) {
auditCtx.error_code = 'invalid_ir';
return sendError(res, 400, e.message, 'invalid_request_error', return sendError(res, 400, e.message, 'invalid_request_error',
olpErrorHeaders({ startMs })); olpErrorHeaders({ startMs }));
} }
throw e; throw e;
} }
auditCtx.model = ir.model;
// Auth context is null at D5/D9 — providers fall back to their own credential
// discovery (env var, keychain, credentials file). Phase 2 multi-key
// infrastructure will pass a real authContext carrying the per-key OLP token.
const authContext = null;
// ── Fallback engine: build chain (ADR 0004) ───────────────────────────── // ── Fallback engine: build chain (ADR 0004) ─────────────────────────────
// buildDefaultChain returns null if no enabled provider serves this model. // buildDefaultChain returns null if no enabled provider serves this model.
// Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the // Per ADR 0004 § D9: at v0.1, chain is single-hop (no fallback) unless the
// user has populated ~/.olp/config.json routing.chains. // user has populated ~/.olp/config.json routing.chains.
const chain = buildDefaultChain( //
// `let` (not `const`) because the chain may be filtered below per the
// authenticated key's providers_enabled allowlist (ADR 0007 § 10 #11).
let chain = buildDefaultChain(
ir.model, ir.model,
loadedProviders, loadedProviders,
_fallbackConfig.chains, _fallbackConfig.chains,
@@ -399,6 +789,7 @@ async function handleChatCompletions(req, res) {
if (!chain) { if (!chain) {
// ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec // ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec
auditCtx.error_code = 'no_enabled_provider';
return sendError( return sendError(
res, 503, res, 503,
`No enabled providers for model ${ir.model}. See README § Supported Providers.`, `No enabled providers for model ${ir.model}. See README § Supported Providers.`,
@@ -407,12 +798,39 @@ async function handleChatCompletions(req, res) {
); );
} }
// ── providers_enabled scope enforcement (Phase 2 / D45, ADR 0007 § 10 #11) ─
// Filter the chain to providers this key is authorized for. If the resulting
// chain is empty, return 403 — the model exists in the registry but no hop
// is reachable from this identity's allowlist.
const _originalChainProviders = chain.map(hop => hop.provider);
chain = chain.filter(hop => isProviderEnabled(olpIdentity, hop.provider));
if (chain.length === 0) {
auditCtx.error_code = 'key_no_provider_access';
auditCtx.tried_providers = _originalChainProviders;
const allowed = olpIdentity.providers_enabled === '*' ? '*' : (olpIdentity.providers_enabled ?? []).join(', ') || '(none)';
return sendError(
res, 403,
`This OLP key does not have access to any provider serving model "${ir.model}". Key's providers_enabled: [${allowed}]. Chain providers: [${_originalChainProviders.join(', ')}].`,
'key_no_provider_access',
olpErrorHeaders({ startMs, model: ir.model }),
);
}
// Auth context is null at D45 — providers fall back to their own credential
// discovery (env var, keychain, credentials file). The OLP-side identity
// (olpIdentity above) is consumed for cache namespacing + providers_enabled
// gating + audit attribution. Per-provider per-key credential mapping is a
// Phase 3+ deferral (ADR 0007 § 12).
const authContext = null;
const requestId = generateRequestId(); const requestId = generateRequestId();
// ── Cache layer (ADR 0005) ────────────────────────────────────────────── // ── Cache layer (ADR 0005, namespaced per OLP key per ADR 0007 § 7) ─────
// keyId: '__anonymous__' at D5/D9. Phase 2 multi-key infrastructure wires the // keyId is the authenticated identity's namespace token. For anonymous
// real OLP API key ID here for D1 per-key isolation. // (when auth.allow_anonymous=true) this is the legacy '__anonymous__'
const keyId = '__anonymous__'; // shared namespace; for filesystem keys it is the per-key id; for the
// OLP_OWNER_TOKEN env override it is the synthetic '__env_owner__'.
const keyId = olpIdentity.keyId;
// D2 bypass (per-hop, per ADR 0005 § D2): // D2 bypass (per-hop, per ADR 0005 § D2):
// cache_control markers bypass OLP's response cache ONLY when the active hop // cache_control markers bypass OLP's response cache ONLY when the active hop
@@ -425,6 +843,26 @@ async function handleChatCompletions(req, res) {
const hasCacheControlMarkers = const hasCacheControlMarkers =
hasCacheControl(ir) || extractCacheControlMarkers(body?.messages ?? []).length > 0; hasCacheControl(ir) || extractCacheControlMarkers(body?.messages ?? []).length > 0;
// D36 #2 (ADR 0005 § D2): when cache_control markers are present AND at least one
// hop in the chain is non-Anthropic, the markers are noop'd for those hops.
// Per ADR 0005 § Context: "for non-Anthropic targets, the bypass markers are
// noop'd (logged once per request at debug level so users can see they were
// ignored)." Fires at most once per request, gated on (markers AND mixed/non-anthropic
// chain). No log when no markers, or when every chain hop is Anthropic.
if (hasCacheControlMarkers && chain.some(hop => hop.provider !== 'anthropic')) {
// marker_count sums body-side and IR-side markers. At v0.1 the IR term is
// structurally 0 (openAIToIR strips cache_control). When a future ADR 0003
// amendment activates cache_control in the IR whitelist, both terms will be
// non-zero for the same logical marker set → revisit to avoid 2× counting.
const markerCount =
extractCacheControlMarkers(body?.messages ?? []).length +
extractCacheControlMarkers(ir.messages ?? []).length;
logEvent('debug', 'cache_control_partial_noop', {
chain: chain.map(hop => hop.provider),
marker_count: markerCount,
});
}
/** /**
* Returns true if OLP's response cache should be bypassed for the given hop. * Returns true if OLP's response cache should be bypassed for the given hop.
* Per ADR 0005 § D2: bypass only when provider is Anthropic AND markers present. * Per ADR 0005 § D2: bypass only when provider is Anthropic AND markers present.
@@ -483,43 +921,80 @@ async function handleChatCompletions(req, res) {
// conditions item 1 requires no truncation). A non-enumerable __truncated marker // conditions item 1 requires no truncation). A non-enumerable __truncated marker
// is set on the returned array so executeHopFn can evict the cache entry below. // is set on the returned array so executeHopFn can evict the cache entry below.
async function collectAllChunks() { async function collectAllChunks() {
// D38 (issue #1): maxConcurrent runtime enforcement.
// Authority: ADR 0002 Amendment 6 + ADR 0004 Amendment 4.
//
// Try to acquire a spawn slot for this provider BEFORE invoking spawn().
// If at limit, synthesise ProviderError(CONCURRENCY_LIMIT) which the
// fallback engine treats as a hard trigger — the chain advances to the
// next hop. validateProvider guarantees hints.maxConcurrent is present;
// DEFAULT_MAX_CONCURRENT_SPAWNS is a defense-in-depth fallback.
const maxConcurrent = hopProviderPlugin.hints?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_SPAWNS;
if (!tryAcquireSpawn(hopProvider, maxConcurrent)) {
const concurrencyErr = new ProviderError(
`provider ${hopProvider} at maxConcurrent (${maxConcurrent}) — advancing to next hop`,
'CONCURRENCY_LIMIT',
);
concurrencyErr.providerName = hopProvider;
concurrencyErr.maxConcurrent = maxConcurrent;
// activeSpawns reflects the live counter at the rejection moment,
// queried directly. Since tryAcquireSpawn returned false the value
// equals maxConcurrent — read it explicitly for diagnostic clarity
// rather than echoing the limit (avoids future-reader confusion).
concurrencyErr.activeSpawns = getActiveSpawnCount(hopProvider);
throw concurrencyErr;
}
const chunks = []; const chunks = [];
// try/finally: releaseSpawn MUST fire on every exit path — success
// (return at end), spawn throw (caught and re-thrown below), or the
// D16 truncation-salvage return. The finally is the only mechanism
// that guarantees release across all three.
try { try {
for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) { try {
// D16: check error chunks BEFORE pushing — preserves the invariant that for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) {
// chunks array contains only delta/stop chunks. Without this, the catch // D16: check error chunks BEFORE pushing — preserves the invariant that
// block's `chunks.length > 0` would mistake a single error chunk for // chunks array contains only delta/stop chunks. Without this, the catch
// "usable content streamed" (Case B) and synthesize a stop + return, // block's `chunks.length > 0` would mistake a single error chunk for
// sending an empty body to the client when the correct behavior is to // "usable content streamed" (Case B) and synthesize a stop + return,
// re-throw and let the fallback engine advance the chain. // sending an empty body to the client when the correct behavior is to
if (irChunk.type === 'error') { // re-throw and let the fallback engine advance the chain.
throw new ProviderError( if (irChunk.type === 'error') {
irChunk.error ?? 'Provider emitted error chunk', throw new ProviderError(
'SPAWN_FAILED', irChunk.error ?? 'Provider emitted error chunk',
); 'SPAWN_FAILED',
);
}
chunks.push(irChunk);
if (irChunk.type === 'stop') break;
} }
chunks.push(irChunk); } catch (spawnErr) {
if (irChunk.type === 'stop') break; if (spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0) {
} // Case B (ADR 0004 Amendment 1): provider emitted usable chunks then exited
} catch (spawnErr) { // non-zero. Synthesize a truncated stop and surface the partial response.
if (spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0) { chunks.push({ type: 'stop', finish_reason: 'length' });
// Case B (ADR 0004 Amendment 1): provider emitted usable chunks then exited logEvent('warn', 'spawn_failed_after_usable_chunks', {
// non-zero. Synthesize a truncated stop and surface the partial response. chunks_count: chunks.length - 1, // exclude the synthesized stop
chunks.push({ type: 'stop', finish_reason: 'length' }); provider: hopProvider,
logEvent('warn', 'spawn_failed_after_usable_chunks', { model: hopModel,
chunks_count: chunks.length - 1, // exclude the synthesized stop });
provider: hopProvider, // Mark as truncated so the caller can evict this entry from cache.
model: hopModel, Object.defineProperty(chunks, '__truncated', { value: true, enumerable: false });
}); return chunks;
// Mark as truncated so the caller can evict this entry from cache. }
Object.defineProperty(chunks, '__truncated', { value: true, enumerable: false }); // Case A (SPAWN_FAILED with no chunks) or any other error: re-throw.
return chunks; // Fallback engine fires hard trigger and advances chain as before.
throw spawnErr;
} }
// Case A (SPAWN_FAILED with no chunks) or any other error: re-throw. return chunks;
// Fallback engine fires hard trigger and advances chain as before. } finally {
throw spawnErr; // D38: spawn lifecycle ended (drain completed, stop chunk received,
// SPAWN_FAILED salvage returned, or any unexpected throw). Release
// the slot so the next caller can acquire it. Single-threaded JS
// guarantees no other caller has incremented this provider's count
// between our tryAcquireSpawn() above and this releaseSpawn().
releaseSpawn(hopProvider);
} }
return chunks;
} }
// D23: cacheable opt-out check (ADR 0002 Amendment 3 + ADR 0005 Amendment 3). // D23: cacheable opt-out check (ADR 0002 Amendment 3 + ADR 0005 Amendment 3).
@@ -568,10 +1043,26 @@ async function handleChatCompletions(req, res) {
lastHopWasCached = hopWasCached; lastHopWasCached = hopWasCached;
if (result.__truncated) { if (result.__truncated) {
// Evict the truncated entry so future requests get a fresh spawn. // Evict the truncated entry so future requests get a fresh spawn.
// ADR 0005 § "Cache write conditions" item 1: truncated responses must not // ADR 0005 § "Cache write conditions" item 1: truncated responses must
// persist in cache. Overwrite with ttlMs=0 so any subsequent get/peek // not persist in cache.
// finds the entry already-expired and deletes it. //
await cacheStore.set(keyId, hopCacheKey, result, 0); // D39 (issue #3 Part 1): use explicit cacheStore.delete() rather than
// the prior set(..., ttlMs=0) tombstone. delete() removes the entry
// from the namespace Map immediately (and removes the empty namespace
// entry from the outer Map if applicable), instead of waiting for the
// next get/peek to lazily purge a TTL=0 entry.
// Capture the boolean — false indicates a race (concurrent eviction or
// TTL purge already removed the entry). Surfaces in the log so the
// dashboard can distinguish "we evicted" from "we tried but it was gone."
const evicted = cacheStore.delete(keyId, hopCacheKey);
// D39 (issue #3 Part 2): observability — surface salvage frequency to
// dashboards. Provider + model identify which hop's truncated entry was
// evicted. cache_eviction_hit distinguishes actual-evict vs already-gone.
logEvent('info', 'cache_evicted_truncated', {
provider: hopProvider,
model: hopModel,
cache_eviction_hit: evicted,
});
} }
return result; return result;
} }
@@ -606,7 +1097,30 @@ async function handleChatCompletions(req, res) {
// //
// On success: write chunks to res AND cache so subsequent identical requests // On success: write chunks to res AND cache so subsequent identical requests
// hit the burst-replay path. // hit the burst-replay path.
// D38 (issue #1): pre-acquire a concurrency slot for the streaming path so
// saturation here behaves identically to saturation on the buffered path.
// If acquire fails, fall through (skip this branch) — the buffered path
// below also gates via tryAcquireSpawn and will surface a chain-exhausted
// error through executeWithFallback (correct behaviour: a single-hop chain
// at maxConcurrent has no other hop to advance to).
//
// Acquired here, released in the `finally` below. The gate intentionally
// lives BEFORE the streaming-branch entry check so the buffered fallthrough
// can re-attempt acquire from a clean slate.
// TODO(v1.x — ADR 0005 Amendment 8 / issue #16): replace this peek+spawn
// pattern with cacheStore.getOrComputeStreaming(...) to close the TOCTOU
// window between line ~782 peek and the spawn at line ~846, and to make N
// concurrent identical streaming requests share one spawn (tee-streaming).
// Design ratified in D42; see docs/v1x-roadmap.md #1 for the trigger and
// acceptance criteria. DO NOT remove this comment until the v1.x impl lands.
let streamingAcquired = false;
if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop && !preCheckHit && cacheableForFirstHop) { if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop && !preCheckHit && cacheableForFirstHop) {
const candidatePlugin = loadedProviders.get(chain[0].provider);
const candidateMax = candidatePlugin?.hints?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_SPAWNS;
streamingAcquired = candidatePlugin ? tryAcquireSpawn(chain[0].provider, candidateMax) : false;
}
if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop && !preCheckHit && cacheableForFirstHop && streamingAcquired) {
const streamProvider = chain[0].provider; const streamProvider = chain[0].provider;
const streamModel = chain[0].model; const streamModel = chain[0].model;
const streamCacheKey = computeCacheKey(streamProvider, streamModel, ir); const streamCacheKey = computeCacheKey(streamProvider, streamModel, ir);
@@ -614,10 +1128,22 @@ async function handleChatCompletions(req, res) {
if (!streamPlugin) { if (!streamPlugin) {
// Provider disappeared between chain build and here (edge case). // Provider disappeared between chain build and here (edge case).
// Release the slot we acquired above so the counter stays balanced.
auditCtx.provider = streamProvider;
auditCtx.error_code = 'no_enabled_provider';
releaseSpawn(streamProvider);
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider', return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
olpErrorHeaders({ startMs, model: ir.model })); olpErrorHeaders({ startMs, model: ir.model }));
} }
// D45 fold-in P1: populate audit ctx for the real-streaming path. Each
// exit below (success, error-after-first-chunk, pre-first-chunk-error,
// 503 above) leaves these fields representing the streaming attempt.
// Error paths amend `error_code`; success leaves it null.
auditCtx.provider = streamProvider;
auditCtx.tried_providers = [streamProvider];
auditCtx.cache_status = 'miss';
const streamHeaders = olpHeaders({ const streamHeaders = olpHeaders({
providerUsed: streamProvider, providerUsed: streamProvider,
modelUsed: streamModel, modelUsed: streamModel,
@@ -637,16 +1163,23 @@ async function handleChatCompletions(req, res) {
if (irChunk.type === 'error') { if (irChunk.type === 'error') {
// Error chunk from provider // Error chunk from provider
if (firstChunkEmitted) { if (firstChunkEmitted) {
// Past first-chunk boundary — can't fallback; truncate stream. // Past first-chunk boundary — can't fallback; emit truncation marker + [DONE]
// so clients can detect the incomplete response in-band (aligns D26 F19
// stop-less exhaustion behaviour and the catch-block fix in D35 #10).
logEvent('warn', 'streaming_error_after_first_chunk', { logEvent('warn', 'streaming_error_after_first_chunk', {
provider: streamProvider, provider: streamProvider,
model: streamModel, model: streamModel,
error: irChunk.error, error: irChunk.error,
}); });
auditCtx.error_code = 'streaming_error_after_first_chunk';
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
res.write(SSE_DONE);
res.end(); res.end();
return; return;
} }
// No bytes written yet — throw to surface a clean error. // No bytes written yet — throw to surface a clean error.
// auditCtx.error_code is set by the downstream catch handler (the
// outer streaming-path catch block fills it from the thrown error).
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED'); throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
} }
@@ -701,6 +1234,23 @@ async function handleChatCompletions(req, res) {
const truncMarker = { type: 'stop', finish_reason: 'length' }; const truncMarker = { type: 'stop', finish_reason: 'length' };
res.write(irChunkToOpenAISSE(truncMarker, requestId, ir.model)); res.write(irChunkToOpenAISSE(truncMarker, requestId, ir.model));
} }
// D35 #9: Zero-chunk empty-stream path — writeHead is still deferred
// (firstChunkEmitted===false) when the generator yields no chunks at all and
// exits cleanly. Without an explicit writeHead Node auto-emits 200 with the
// default Content-Type and none of the X-OLP-* headers.
// A provider that yielded nothing still constitutes an attempted call, so we
// emit the full olpHeaders (provider WAS attempted, just yielded zero chunks).
if (!res.headersSent) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
...streamHeaders,
});
}
res.write(SSE_DONE); res.write(SSE_DONE);
res.end(); res.end();
// Loop exhausted without stop chunk = truncation. The stop-chunk completion // Loop exhausted without stop chunk = truncation. The stop-chunk completion
@@ -716,12 +1266,17 @@ async function handleChatCompletions(req, res) {
} }
} catch (e) { } catch (e) {
if (firstChunkEmitted) { if (firstChunkEmitted) {
// Past first-chunk boundary — truncate silently. // Past first-chunk boundary — can't fallback; emit truncation marker + [DONE]
// so clients can detect the incomplete response in-band (aligns with D26 F19
// stop-less exhaustion behaviour). ADR 0004 § Fallback safety: no fallback
// after first-chunk boundary; truncation is the correct recovery.
logEvent('warn', 'streaming_error_after_first_chunk', { logEvent('warn', 'streaming_error_after_first_chunk', {
provider: streamProvider, provider: streamProvider,
model: streamModel, model: streamModel,
error: e.message, error: e.message,
}); });
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
res.write(SSE_DONE);
res.end(); res.end();
} else { } else {
// No bytes written — surface a clean JSON error. // No bytes written — surface a clean JSON error.
@@ -730,6 +1285,7 @@ async function handleChatCompletions(req, res) {
model: streamModel, model: streamModel,
error: e.message, error: e.message,
}); });
auditCtx.error_code = e?.code ?? 'provider_error';
if (!res.headersSent) { if (!res.headersSent) {
sendError(res, 502, e.message ?? 'Provider error', 'provider_error', sendError(res, 502, e.message ?? 'Provider error', 'provider_error',
olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 })); olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 }));
@@ -737,6 +1293,13 @@ async function handleChatCompletions(req, res) {
res.end(); res.end();
} }
} }
} finally {
// D38 (issue #1): streaming spawn lifecycle ended — drain completed,
// stop chunk seen, generator exhausted without stop, or any catch
// path returned via res.end(). Release the slot acquired before
// entering the streaming branch. The finally fires on every JS exit
// path including the `return;` statements inside the try/catch body.
releaseSpawn(streamProvider);
} }
return; return;
} }
@@ -760,6 +1323,7 @@ async function handleChatCompletions(req, res) {
fallbackHops, fallbackHops,
originalError, originalError,
triedProviders, triedProviders,
fallbackDetail, // D40 (issue #7): per-hop failure tuples for X-OLP-Fallback-Detail
} = fallbackResult; } = fallbackResult;
// ── Chain exhausted or non-trigger error ───────────────────────────────── // ── Chain exhausted or non-trigger error ─────────────────────────────────
@@ -809,18 +1373,35 @@ async function handleChatCompletions(req, res) {
fallbackHops: fallbackHops ?? 0, fallbackHops: fallbackHops ?? 0,
}); });
// Send error with standard OLP headers + optional exhausted header // Audit ctx capture for chain-exhausted / provider-error path (audit fires
// on res.on('finish'); fields populated here so the row reflects what we
// know at exhaustion time — provider is the chain[0] primary per ADR 0004
// step 4, tried_providers is the failed-hop trail from fallbackDetail).
auditCtx.provider = providerUsed ?? 'none';
auditCtx.fallback_hops = fallbackHops ?? 0;
auditCtx.tried_providers = Array.isArray(fallbackDetail)
? fallbackDetail.map(t => t?.provider).filter(Boolean)
: [];
auditCtx.cache_status = 'miss';
auditCtx.error_code = originalError?.code ?? 'provider_error';
// Send error with standard OLP headers + optional exhausted header +
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
// gating planned for Phase 2 with lib/keys.mjs.
const payload = JSON.stringify({ const payload = JSON.stringify({
error: { error: {
message: originalError?.message ?? 'Provider error', message: originalError?.message ?? 'Provider error',
type: 'provider_error', type: 'provider_error',
}, },
}); });
const detailHeader = withFallbackDetailHeader({}, fallbackDetail, olpIdentity);
res.writeHead(errStatus, { res.writeHead(errStatus, {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload), 'Content-Length': Buffer.byteLength(payload),
...errorOlpHeaders, ...errorOlpHeaders,
...exhaustedHeader, ...exhaustedHeader,
...detailHeader,
}); });
res.end(payload); res.end(payload);
return; return;
@@ -840,7 +1421,21 @@ async function handleChatCompletions(req, res) {
const cacheStatus = bypassCacheForServingHop ? 'bypass' const cacheStatus = bypassCacheForServingHop ? 'bypass'
: (lastHopWasCached || (preCheckHit && fallbackHops === 0)) ? 'hit' : (lastHopWasCached || (preCheckHit && fallbackHops === 0)) ? 'hit'
: 'miss'; : 'miss';
const headers = olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }); // D40 (issue #7): when at least one prior hop failed before this success,
// surface X-OLP-Fallback-Detail with the failure trail. Header is omitted
// when fallbackDetail is empty (single-hop success).
const headers = withFallbackDetailHeader(
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
fallbackDetail,
olpIdentity,
);
// Audit ctx capture for success path (audit fires on res.on('finish');
// status_code + latency_ms populated then).
auditCtx.provider = providerUsed;
auditCtx.fallback_hops = fallbackHops;
auditCtx.tried_providers = (Array.isArray(fallbackDetail) ? fallbackDetail.map(t => t?.provider).filter(Boolean) : []).concat([providerUsed]);
auditCtx.cache_status = cacheStatus;
if (ir.stream) { if (ir.stream) {
// Streaming response path: burst replay from buffered chunks. // Streaming response path: burst replay from buffered chunks.
+3186 -2
View File
File diff suppressed because it is too large Load Diff