Author SHA1 Message Date
taodengandClaude Opus 4.7 3551921f55 docs(readme): document client-side shell-tool routing limitation
OLP cannot fully prevent agentic clients with shell/fs tools from
reporting OLP-server-side state as their own "self-check" results.
This is an architectural property of spawn-CLI proxying.

Phase 6c's --system-prompt override (ADR 0009 Amendment 1) addresses
the prompt-side leak (claude CLI's <env>cwd=...</env> injection). But
if a client like OpenClaw is configured to route shell/fs tool calls
to the OLP server host (not the user's local machine), the agent will
correctly execute tools — and correctly report results — except the
model may describe those results as "my own state" when in fact they
describe the OLP server.

OLP is stateless and doesn't know which client is calling. The system
message is owned by the client. So this is documented as a known
limitation with per-client recommendations:

- OpenClaw client mode: configure shell/fs tools to route locally
- Hermes Agent: not affected (pre-processes tools on its host)
- Cline / Continue.dev / Cursor / Aider: not affected (local tools)
- Generic agentic clients: integrators document to their users

Cross-references ADR 0014 for the multi-tenant security counterpart
(sandbox-runtime prevents cross-client OAuth token reads even when
shell-tool routing is misconfigured — once PR-B HTTP-path activation
ships).

The IDENTITY.md hint that was added to the maintainer's Mac mini
OpenClaw workspace during the 2026-05-28 session was a temporary
client-side hack and is out of OLP scope per maintainer feedback —
it has been reverted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:36:45 +10:00
taodengandClaude Opus 4.7 b1e24b7cb0 fix(sandbox): add OLP_SANDBOX_DISABLED=1 env-var emergency disable
PR-B PI231 live verification surfaced a hard problem: HTTP-path anthropic
spawns produce no claude stdout when sandbox-wrapped, while manual exec
of the identical wrap script in the same node process DOES produce output.
Root cause not yet isolated — likely interaction between SandboxManager's
in-process proxy sockets (HTTP/SOCKS Unix sockets in /tmp) and OLP's
HTTP request-handler event loop.

Until the root cause is debugged and Suite 44-equivalent E2E tests cover
the full HTTP request → sandbox spawn → response pipeline, this commit
adds an env-var emergency disable: OLP_SANDBOX_DISABLED=1 in the server
environment causes bootstrapSandbox() to short-circuit immediately,
returning { active: false, reason: '...' }. Provider plugins continue
to spawn unsandboxed (matching pre-PR-B behavior).

Default remains sandbox-enabled. Only operators with broken HTTP-path
sandbox should set the env var (which is everyone on PI231 today,
until follow-up debugging completes).

Operational follow-up (PI231 right now):
  setsid env ... OLP_SANDBOX_DISABLED=1 node ~/olp/server.mjs ...
  → /health.sandbox.active=false
  → HTTP requests resume working

Future PR-B follow-up issues:
  1. Reproduce HTTP-path sandbox spawn failure in unit test
  2. Investigate in-process proxy lifecycle vs HTTP handler event loop
  3. Possibly: switch to per-spawn SandboxManager.initialize() lifecycle
  4. Re-enable sandbox by default after fix + Suite 44 HTTP-path coverage

Sandbox doctor (PR-A) unchanged. ADR 0014 unchanged (the disable is a
runtime gate, not a contract change). Suite 44 negative tests still
pass when enabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:52:07 +10:00
taodengandClaude Opus 4.7 497b2550e6 fix(anthropic): tolerate null exit code when result event was seen (PR-B sandbox)
The /bin/sh -c wrapping that sandbox-runtime applies (bwrap argv prefix +
inner claude command) can return exit code null on cleanup even when the
underlying claude process completed cleanly and emitted the `result`
event. The previous logic treated any non-zero exit as fatal and threw
ProviderError, causing the HTTP handler to discard the already-yielded
chunks and respond with content:null.

resultEventSeen=true is the authoritative success signal — if it's set,
the model completed and the stop chunk was yielded. Abnormal exit after
that is sandbox bookkeeping noise.

Live PI231 evidence (2026-05-28 commit 2864275 deploy):
  Direct provider test (bypasses HTTP):
    chunk: {"type":"delta","content":"DIRECT_PROOF",...}
    chunk: {"type":"stop","finish_reason":"stop"}
    ERR: claude exit null      ← throw after chunks yielded
  HTTP response: choices[0].message.content == null
                 (chunks lost when consumer received throw)

Fix: only throw on non-zero exit when resultEventSeen=false. With the
guard, the smoke `reply: SANDBOX_PROOF` request now returns the proper
content through the HTTP layer.

The pre-PR-B (non-sandbox) path is unaffected: that path runs claude
directly (no /bin/sh wrap), so exit is always 0 when the model
completes, and the resultEventSeen check is a no-op for that path.

Authority:
- live PI231 2026-05-28 transcript (direct vs HTTP path divergence)
- ADR 0009 Amendment 1 § "NDJSON event handling" — result event is
  the terminal-success indicator
- ADR 0014 § PR-B — sandbox wrap introduces /bin/sh layer

Tests: unchanged at 813 (this is a pure-defensive code path; Suite 44
on PI231 will now exercise the corrected path on E2E run).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:38:27 +10:00
taodengandClaude Opus 4.7 28642756b5 fix(sandbox): PR-B fold-ins — allow read ~/.claude + skip wrap under test mock
Live PI231 verification of d0dcd28 surfaced two real-runtime issues:

1. ~/.claude was denyRead, but claude CLI MUST read its own OAuth
   credentials file to authenticate. Result: 100% of anthropic requests
   on sandboxed PI231 failed with "Not logged in · Please run /login".
   Live transcript:
     {"event":"fallback_hop_error", "provider":"anthropic",
      "error":"Not logged in · Please run /login"}

   The cross-tenant risk for ~/.claude was a false security trade-off:
   (a) ~/.claude is the spawn's OWN auth, not cross-tenant material
       (all OLP clients share the same Anthropic OAuth — the file is
       not multi-tenant secret)
   (b) Real protection for the model accidentally exfiltrating creds
       via tool_use is Phase 6c's --system-prompt suppressing tool
       descriptions; sandbox-runtime can only blanket-deny a path, not
       distinguish "claude reads creds" from "model emits Read tool"
   Fix: remove ~/.claude from denyRead. Keep ~/.olp, ~/.ssh, ~/.config,
   ~/.codex (all genuinely cross-tenant).
   Suite 44a (cat ~/.olp/keys.json MUST fail) unchanged — still proves
   the core acceptance.
   Suite 44c added (regression guard): in-sandbox cat ~/.claude/.credentials.json
   MUST succeed when the file exists.

2. anthropic.mjs::_spawnAndStream called wrapSpawn() unconditionally,
   including when a test had set __setSpawnImpl(mockFn). The wrap
   rewrites bin to '/bin/sh -c <wrapped-string>' which breaks every HTTP
   integration test that asserts on spawn args. Result on PI231 (sandbox
   active): 16 production-shape tests failed because their mocks were
   never called with the expected bin/args.
   Fix: skip wrapSpawn when spawnImpl !== defaultSpawn (mock active).
   Mocks don't exec, so isolation is meaningless there anyway. Real-CLI
   spawns (production + Suite 44) still get wrapped.

Tests: 813 → 813 (44c is also PI231-gated, skips on Mac with file
absent; the fold-in does not change Mac test count).

Authority:
- @anthropic-ai/sandbox-runtime v0.0.52 — wrapWithSandbox() shell-string
  return shape is the proximate cause of (2)
- Live PI231 2026-05-28 transcript: /v1/chat/completions returning
  content=null + "Not logged in" from anthropic; OLP_E2E_SANDBOX=1 npm
  test showing 16 prior pass tests now fail
- ADR 0014 § 4.1 PR-B acceptance criteria — the negative test (44a)
  continues to pass; the false-positive denyRead (~/.claude) is corrected

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:34:36 +10:00
taodengandClaude Sonnet 4.6 d0dcd281ef feat(sandbox): Phase 7 PR-B — anthropic.mjs spawn wrapped in sandbox-runtime
Wraps the claude CLI spawn in @anthropic-ai/sandbox-runtime per ADR 0014
§ PR-B. Achieves multi-tenant filesystem + network isolation: the spawned
claude subprocess can no longer read ~/.olp/keys.json, ~/.claude/.credentials.json,
~/.ssh/, or any other per-client OAuth material. Only api.anthropic.com and
statsig.anthropic.com are reachable; only /tmp/olp-spawn/<uuid>/ is writable
per spawn (ephemeral, UUID-scoped to prevent cross-request contamination).

## Authority citations

- @anthropic-ai/sandbox-runtime v0.0.52
  https://github.com/anthropic-experimental/sandbox-runtime
  dist/sandbox/sandbox-manager.js — SandboxManager.initialize(), wrapWithSandbox()
  dist/sandbox/sandbox-utils.js  — getDefaultWritePaths()
- 2026-05-28 spike report at /tmp/sandbox-spike/report.md on PI231:
  spike-anthropic.mjs (wrapWithSandbox call signature + NDJSON proof),
  spike-deny.mjs (cat ~/.olp/keys.json MUST fail)
- OLP ADR 0014 § 2.1 PR-B, § 4.1 PR-B acceptance criteria
- OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation
- cc-mem incident 2026-05-27 § 3 (multi-tenant OAuth token exposure via
  prompt injection)

## Files changed

- lib/sandbox/manager.mjs (new): bootstrap + spawn-wrap layer.
  Exports: bootstrapSandbox(), isSandboxActive(), wrapSpawn(),
  __resetSandboxManagerForTests(). Config-at-boot model (one
  SandboxManager.initialize() at server start; per-request wrapWithSandbox()
  reads from already-initialized state). Transparent pass-through when inactive.

- lib/providers/anthropic.mjs: spawn site wrapped via wrapSpawn({
  bin, args, env, allowedDomains: ['api.anthropic.com','statsig.anthropic.com']
  }). ADR 0009 Amendment 1 spawn args unchanged — only execution is wrapped.

- server.mjs: bootstrapSandbox() called before server.listen(); startup banner
  logs sandbox.active state. /health.sandbox now includes active:boolean field
  (distinguishes "deps present" from "SandboxManager initialized and wrapping").

- test-features.mjs: Suite 43 (8 tests — manager unit: bootstrap state, idempotency,
  isSandboxActive, wrapSpawn passthrough, /health.active field) + Suite 44
  (2 PI231-gated tests: 44a security negative test + 44b positive echo test,
  skipped by default, run with OLP_E2E_SANDBOX=1 npm test).

- CHANGELOG.md, docs/adr/0014-sandbox-runtime-integration.md: PR-B status
  updated; ADR table + /health JSON example updated with active field.

## Test count

805 (pre-PR-B) → 813 (+8 Suite 43; Suite 44 skipped on macOS, runs on PI231)
All 813 pass on macOS dev machine. 0 regressions.

## PI231 validation (required before merge — Suite 44)

After apt-get install bubblewrap socat (ripgrep already present) + server restart:

1. curl /health → confirm sandbox.available=true AND sandbox.active=true
2. Real anthropic request → confirm stream-json still works end-to-end
3. OLP_E2E_SANDBOX=1 npm test → confirm Suite 44a (cat ~/.olp/keys.json MUST fail)
   and Suite 44b (echo SANDBOX_PROOF succeeds)

Reviewer: must SSH PI231, run Suite 44, and confirm the ADR 0014 § 4.1 criteria.
This commit is ready to push; do NOT push before Suite 44 transcript is captured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:26:34 +10:00
taodengandClaude Opus 4.7 07d9c8a6ae feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + doctor + ADR 0014
Lays the foundation for multi-tenant provider spawning isolation per
ADR 0014 § Decision. NO production wiring — PR-B (anthropic.mjs spawn
wrap) lands separately and requires bubblewrap + socat + ripgrep
installed on PI231 first.

Files:
- package.json: add @anthropic-ai/sandbox-runtime ^0.0.52
- lib/sandbox/doctor.mjs (new): preflight checkSandboxAvailability +
  describeSandboxStatus; pure module, no state, no initialize() call
- server.mjs: /health response gains 'sandbox' field with availability
  + missing deps + install hint; result memoized via _sandboxStatusCache;
  __resetSandboxStatusCache() test seam exported
- docs/adr/0014-sandbox-runtime-integration.md (new): 4-PR layered
  rollout decision per Iron Rule 11; PR-B/C/D acceptance criteria
  previewed; 6 spike pitfalls recorded; 282 lines
- CHANGELOG.md: Unreleased entry under Phase 7 PR-A
- test-features.mjs Suite 42 (+8 tests, 797 → 805, all pass)

Authority:
- @anthropic-ai/sandbox-runtime v0.0.52 — anthropic-experimental org
  https://github.com/anthropic-experimental/sandbox-runtime
- 2026-05-28 PoC spike (verdict YELLOW; report at /tmp/sandbox-spike/
  on PI231): clean arm64 install, dep check fail-closed behaviour
  confirmed, three PoC scripts parked
- cc-mem incident memory § 4 (prior-art search — ecosystem hasn't
  solved multi-tenant fs/tool isolation)
- ADR 0009 Amendment 1 § Caveats #3 — sandbox is cloud prerequisite
- docs/plans/cloud-deployment-family.md § 5

Spike note (macOS): on dev Mac mini with rg via Homebrew,
SandboxManager.isSupportedPlatform()=true and checkDependencies()
returns no errors — macOS uses built-in sandbox-exec, not bwrap.
/health.sandbox.available=true on macOS dev, false on PI231 until
apt install.

Operational follow-ups (NOT this PR):
1. sudo apt-get install -y bubblewrap socat ripgrep on PI231 (5-min window)
2. PR-B: lib/providers/anthropic.mjs spawn wrap + negative test
   (in-sandbox cat of OAuth token MUST fail)
3. PR-C: lib/providers/codex.mjs wrap with enableWeakerNestedSandbox:true
4. PR-D: cloud deployment plan update + unblock

Tests: 797 → 805 (all pass, 0 fail).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:09:37 +10:00
taodengandClaude Opus 4.7 e5cfc696da fix(anthropic): suppress stream-json log spam for system/* and user events
The NDJSON event parser matched only `system/init` and treated every
other system subtype as "unknown event type" — logged via console.error,
once per event. During the 2026-05-28 OAuth-expiry 401 cascade this
produced 2 spam lines per failed request (PI231 server log was
~50% noise).

Also caught: claude echoes {type:'user'} events back in some stream-json
modes (analogous to --replay-user-messages); these were also spammed
as unknown.

Both are now consumed silently — neither maps to an IR chunk.

Authority:
- empirical PI231 v2.1.104 transcripts 2026-05-28 (server log shows
  '[anthropic] unknown stream_json event type: system' and
  '[anthropic] unknown stream_json event type: user')
- ADR 0009 Amendment 1 § "NDJSON event handling" — generic "future-proof
  for unknown events" intent; the table was incomplete

Tests: 795 → 797
- 41e-5b: regression guard for system/<non-init-subtype>
- 41e-5c: user (echo) event consumption

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:56:52 +10:00
taodengandClaude Opus 4.7 dbac5f5521 fix(anthropic): stop injecting env.CLAUDE_CODE_OAUTH_TOKEN — let CLI auto-refresh
OLP was injecting accessToken from ~/.claude/.credentials.json into the
spawn env as CLAUDE_CODE_OAUTH_TOKEN unconditionally. This defeated
claude CLI's built-in OAuth refresh: when the env var is present, the
CLI reads it directly and never touches credentials.json, so expired
accessTokens were never swapped using the refreshToken sitting right
there in the file. Result: hard 401 cascade every ~8h (access token
TTL) requiring manual `security find-generic-password → scp → PI231`
cycle.

Empirical 2026-05-28 spike on PI231 v2.1.104:
- expiresAt = 1 min ago (simulated expiry)
- spawn claude -p WITHOUT CLAUDE_CODE_OAUTH_TOKEN env
- response succeeded, AND credentials.json.expiresAt advanced to
  ~8h in the future
- token refresh handled internally by claude CLI

Fix: remove the env injection in _spawnAndStream. buildSpawnEnv still
forwards process.env (minus ANTHROPIC_*), so if the operator explicitly
sets CLAUDE_CODE_OAUTH_TOKEN at OLP boot, it's still honored — for
users on ephemeral CI / no-file-creds setups. The readAuthArtifact()
call remains as a preflight check (verify creds exist before spawn)
but the read value is no longer forwarded.

Tests: 793 → 795
- 41h: regression guard — no CLAUDE_CODE_OAUTH_TOKEN in env when only
  file/keychain auth is available
- 41h-2: operator-set process.env passthrough still works

User-facing impact: bot-down-every-8-hours issue resolved. Hermes +
OpenClaw clients no longer require periodic keychain → PI231 copy.

Authority:
- empirical PI231 v2.1.104 spike 2026-05-28 (no public CLI doc cites
  refresh behavior; verified by simulating expiry + observing file
  update post-spawn)
- ADR 0009 Amendment 1 § "Caveats" — implementation must remain robust
  to OAuth flow changes (this fix removes a hard-coded assumption that
  was actively harmful)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:52:43 +10:00
taodengandClaude Opus 4.7 dd0c821272 docs(plans): cloud deployment plan (family testing phase) — draft
Plan-stage document for Oracle Cloud VM deployment with family-scale
hardening. Covers architecture overview, TLS termination, iptables /
OCI Security List policy, OAuth credential transfer, auth model
(allow_anonymous off, per-key tier mapping), audit visibility, and
the Phase 7 sandbox-runtime prerequisite.

Status: Draft, pending Phase 7 (sandbox-runtime integration) completion.
This commit makes the plan referenceable from ADR 0009 Amendment 1
§ Caveats #3, the 2026-05-27 incident memory (cc-rules), and the
forthcoming Phase 7 charter.

NOT a code change — no ALIGNMENT.md authority citation required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 07:38:21 +10:00
taodengandClaude Opus 4.7 65f945c16d fix(anthropic): assistant-aggregate fallback when claude emits no content_block_delta
Live PI231 v2.1.104 verification of 97e7d16 surfaced a real-runtime bug:
in stream-json mode WITHOUT -p (the Transport A path locked by ADR 0009
Amendment 1), claude emits ONLY the aggregate {type:"assistant"} event
for fast/short responses — NO content_block_delta events. The previous
implementation returned null for `assistant` unconditionally, so the
delta-chunk stream was empty and OpenAI-format responses came back with
content=null.

Empirical evidence (PI231 2026-05-27):
- Manual `claude --output-format stream-json --verbose --no-session-persistence
  --system-prompt "..."` < "reply: DIAG-PROOF" → NDJSON stream contains
  system/init + assistant(content:[{text:"DIAG-PROOF"}]) + rate_limit_event
  + result. NO content_block_delta events.
- OLP server response: choices[0].message.content == null
- Provider direct test: chunks = [{type:"stop"}], no delta yielded

Fix:
- anthropicStreamJsonEventToIR(event, isFirstDelta) now handles assistant:
  - isFirstDelta=true  → extract aggregate text from message.content, yield
    as single delta chunk (this is the "no streaming" path)
  - isFirstDelta=false → return null (duplicate of already-streamed deltas)
- Non-text content blocks (thinking) are filtered out
- Multi text blocks concatenate in order

4 new tests in Suite 41 covering: aggregate-when-streamed-null, aggregate-as-
fallback-delta, multi-block-concat, thinking-block-filter.

Tests: 790 → 793 (all pass).

This was a brief-implementation gap: ADR 0009 Amendment 1 § "NDJSON event
handling" mentioned "If we somehow get assistant without prior
content_block_delta events (no streaming), then yield content as single
delta" as a robustness fallback, but the original implementation treated
the case as unreachable. Live verification proved it's the COMMON case
when --include-partial-messages is not set (which we deliberately omit
per the ADR's locked flag set).

Authority:
- claude CLI v2.1.104 § --output-format stream-json (NDJSON shape)
- claude CLI v2.1.104 § --include-partial-messages (controls whether
  content_block_delta events are emitted; we omit it per ADR 0009
  Amendment 1's locked flag set, accepting aggregate-only output)
- ADR 0009 Amendment 1 § "NDJSON event handling" — `assistant` row already
  documents the fallback intent; this commit aligns implementation with intent
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:38:19 +10:00
taodengandClaude Opus 4.7 97e7d16585 feat(anthropic): stream-json + --system-prompt (ADR 0009 Amendment 1)
Replaces claude -p --output-format text spawn with claude (no -p)
--output-format stream-json --verbose --system-prompt.

Authority:
- claude CLI v2.1.104 § --output-format stream-json (verified 2026-05-27
  empirical test on PI231: NDJSON event stream emits without -p)
- claude CLI v2.1.104 § --verbose (required companion to stream-json)
- claude CLI v2.1.104 § --system-prompt (full default-prompt replacement,
  suppresses env-block + tool descriptions)
- claude CLI v2.1.104 § --no-session-persistence
- OLP ADR 0009 Amendment 1 — decision lock + value re-anchoring
- OLP ALIGNMENT.md Rule 1 — provider plugin authority citation

Four orthogonal values delivered:
1. Hallucination fix: model no longer claims server-side cwd / OS /
   tools (verified bot self-check produces "I don't have local env"
   instead of "/home/tlab/olp")
2. ~64% per-request cost reduction: empirical Sonnet 4.6
   $0.0216 → $0.0078, from ~30% input-token reduction (16,601 → 10,700)
3. NDJSON observability: rate_limit_event + usage + cache stats per
   request now available (future audit/dashboard work)
4. Possible 30-60 day bridge for Anthropic 2026-06-15 billing split
   (uncertain per P0 spike — third-party-app classification clause)

Per ADR 0009 Amendment 1 § "Caveats": this is NOT a substitute for
Phase 7 @anthropic-ai/sandbox-runtime work; sandbox remains required
for any cloud / multi-tenant deployment.

Cache key composition unchanged (ADR 0005 IR-based hash; on-the-wire
format is internal to the provider plugin).

Tests: 771 → 790 (+19 new in Suite 41; 9 existing mocks updated to
emit valid NDJSON stream_event format instead of raw text).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 22:29:17 +10:00
40f9453d88 fix(ir): accept OpenAI role=developer at entry surface, normalize to system (#65)
* fix(ir): accept OpenAI role=developer at entry surface, normalize to system

Hermes Agent (v0.13+) and other modern openai-completions clients (Cline,
Continue.dev) default to role=developer for the high-priority instruction
slot when the model id matches OpenAI o1/o3+ reasoning family. OLP IR's
validator rejected this with 400 IR validation failed: role must be one
of system|user|assistant|tool, got "developer".

Reproduced 2026-05-27 on PI230 Hermes v0.14.0 trying olp-codex/gpt-5.5
through PI231 OLP. olp-claude (Anthropic models) was unaffected because
Hermes sends system role for Claude models (no developer-role concept
on Anthropic side).

Fix: extend openai-to-ir.mjs normalizeRole to map developer to system at
the entry boundary. The IR canonical-four-roles invariant is preserved;
every provider plugin's role-handling stays unchanged.

Same pattern as the existing function-to-tool normalization that already
lives in normalizeRole (function role was OpenAI-deprecated). Normalize-at-
entry centralizes role-spec-evolution handling in one file rather than
bloating the IR schema and forcing every provider plugin to handle each
new role.

Why not add developer to VALID_ROLES: it would require branches in three
provider plugins (anthropic, codex, mistral) all mapping to system-style
annotation anyway, plus wider IR surface for any future OpenAI role
addition.

Tests: two new pin tests in Suite IR translation:
- developer role to system translation
- mixed-role array including developer validates cleanly

768 to 770 tests, 0 fail. Verified Hermes via olp-codex no longer hits the
IR rejection error after this fix (smoke run from PI230).

ADR 0003 Amendment 3 documents the rationale + future-role policy
(normalize-at-entry unless a role genuinely conveys
provider-distinguishable semantics).

Authority: OpenAI Responses API spec developer-role + Hermes Agent
v0.14.0 reproduction.

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

* test+docs: PR #65 fold-in reviewer N2 + N3

N2 — Negative control test added: unknown role (e.g. "admin") still raises
BadRequestError. Pins that the developer-to-system normalization is the
only entry-surface escape hatch; any future widening of normalizeRole that
returns role as-is for unknown inputs will be caught by this test.

N3 — ADR 0003 Amendment 3 gains a "Cache-key impact" bullet documenting
that a developer-form and system-form request with otherwise-identical
content now share the same IR and thus the same cache key (per ADR 0005).
This is by design and matches OpenAI's own backward-compat semantics; the
note exists so a future debug session investigating "why does my developer
request hit a cache entry from an old system request" finds the answer.

Test count: 770 to 771, all pass.

Skipped reviewer N1 (URL precision) — Amendment already cites multiple
authorities including the Hermes/Cline tracking convention and live
reproduction transcript; single-URL precision is over-spec.

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-27 18:55:11 +10:00
e2f41eb60e docs(openclaw): switch to canonical env-var-reference apiKey + olp-claude rename + /models menu-only note (#64)
Three corrections to PR #63 surfaced via live Telegram bot bring-up on Mac mini -> PI231 OLP:

1. headers.Authorization workaround does NOT work for openai-shape model ids.
   PI231 audit log showed key_id=__anonymous__ for every gpt-* model sent via
   olp-codex despite headers.Authorization being set. Canonical OpenClaw pattern
   is apiKey: dollar-brace VAR brace env-var reference (per docs.openclaw.ai).
   Verified end-to-end: this attributes both olp-claude/* and olp-codex/* traffic
   to the bot owner key.

   Root cause: two unresolved upstream openclaw bugs:
   - #41157 (Gemini openai-completions Authorization not sent)
   - #1669 (Ollama provider ignores apiKey)

2. claude-local renamed to olp-claude for naming symmetry with olp-codex.

3. /models is menu-only in OpenClaw. Typing /models olp-codex/gpt-5.5 does NOT
   directly switch -- only opens the picker.

Changes:
- Two-modes table: client-mode Auth row now recommends env-var-ref apiKey
- Mode B: NEW step walks through env var setup per OS
- Provider JSON examples use VAR ref pattern (no more headers.Authorization)
- Gotchas rewritten with three failure modes documented
- New gotcha for /models menu-only semantics
- Troubleshooting table: updated 401 row + new rows for anonymous attribution + menu-only

Authority: Live reproduction on Mac mini OpenClaw v2026.5.22 + PI231 OLP v0.5.1.
PI231 audit log verification. OpenClaw issues #41157, #1669, #29095.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:57:46 +10:00
5d60a0599f docs(openclaw): expand integration guide — server-co-located vs client-mode + codex-via-OLP recipe (#63)
The previous version of docs/integrations/openclaw.md assumed
loopback OpenClaw+OLP co-located on the same host. Two real gaps:

1. **Client-mode (OpenClaw on a different machine than OLP) is
   the common family-deployment shape**, and the loopback config
   recipe doesn't work there. Specifically:

   - apiKey field is silently overridden by OpenClaw's service-managed
     env (`OPENCLAW_SERVICE_MANAGED_ENV_KEYS=DEEPSEEK_API_KEY,OPENAI_API_KEY`).
     The OpenClaw gateway clobbers `process.env.OPENAI_API_KEY` with
     whatever ChatGPT key it's been authed against, so when an
     openai-completions provider falls back to env-var auth, the
     wrong token gets sent and OLP rejects with 401.

   - The escape hatch is `headers.Authorization: Bearer olp_...` in
     the provider config, which beats the env fallback because
     OpenClaw merges `providerConfig.headers` at request-build time
     (sanitizeModelHeaders → outgoing request).

2. **codex / OpenAI models via OLP routing wasn't documented at
   all.** OpenClaw's stock `openai-codex` provider talks to ChatGPT
   directly (bypasses OLP, no audit, no per-key tracking). To get
   gpt-5.5 / gpt-5.3-codex etc. routed through OLP for per-key
   observability, you have to register a custom `olp-openai`
   provider — recipe now included.

Changes to docs/integrations/openclaw.md:

- Restructured into Mode A (server-co-located) vs Mode B (client-mode)
  with explicit "pick yours" table up top.
- Mode B fully written out — proxyUrl with server IP, headers.Authorization
  override, claude-local provider listed with the three Claude IDs that
  OLP's /v1/models actually exposes.
- New § "Using codex / OpenAI models through OLP" with olp-openai
  provider recipe + agents.defaults.models alias examples + warning
  about why NOT to use OpenClaw's stock openai-codex provider.
- New § Gotchas covering: OPENCLAW_SERVICE_MANAGED_ENV_KEYS clobber,
  default-agent-model points-at-removed-provider, /new doesn't reset
  model selection, the openclaw.extensions schema-drift (was already
  there).
- New § Troubleshooting table mapping symptoms → root causes → fixes.

Discovered live on Mac mini 2026-05-27 during PI231-as-server topology
bring-up. The "apiKey shadowed by env" finding required reading
OpenClaw source (`/opt/homebrew/lib/node_modules/openclaw/dist/...`)
to confirm the `providerConfig.headers` precedence. Verified end-to-end:
Telegram bot now routes free-text through claude-local → PI231 OLP →
claude spawn → reply; and `/models olp-openai/gpt-5.5` switches to
codex via OLP with per-key audit visible on dashboard.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 16:10:55 +10:00
ea0392f744 fix(olp-plugin): add openclaw.extensions field for modern OpenClaw v2026.5+ install (#62)
Discovered 2026-05-27 while bringing up Mac mini as OpenClaw client of
PI231 OLP server: `openclaw plugins install ./olp-plugin/` fails with

  package.json missing openclaw.extensions; update the plugin package to
  include openclaw.extensions (for example ["./dist/index.js"])

OpenClaw v2026.5.22 enforces a stricter plugin-manifest validation than
earlier revisions. The olp-plugin/package.json still used the legacy
schema (`type: "plugin"` + `pluginManifest`) without the new
`extensions: [./index.js]` field that modern OpenClaw requires for the
gateway plugin-discovery loop.

Workaround until this fix lands: manually `cp -R olp-plugin
~/.openclaw/extensions/olp` (symlink also works per docs Option B).

Changes:
- olp-plugin/package.json: added `extensions: ["./index.js"]` to the
  openclaw block. Keeps legacy fields (`type`, `id`, `pluginManifest`)
  for backwards compat with pre-2026.5 OpenClaw revisions; the modern
  loader only reads `extensions`.

- docs/integrations/openclaw.md:
  - § 3 Configure: rewrote example to show the modern `plugins.allow` +
    `plugins.entries.olp.{enabled, config}` schema. The previous shape
    (`plugins.olp.{proxyUrl, apiKey}` at top level) doesn't match what
    OpenClaw actually picks up.
  - § Known issues: added a paragraph documenting the
    `openclaw.extensions` schema-drift event + recovery path (pull latest
    OLP, or symlink fallback).

Verified post-fix: `openclaw plugins install ./olp-plugin/` succeeds on
Mac mini OpenClaw v2026.5.22.

Authority:
- Reproduced live on Mac mini 2026-05-27 during PI231-as-server topology
  bring-up
- OpenClaw stock plugin format (sample: `/opt/homebrew/lib/node_modules/
  openclaw/dist/extensions/anthropic/package.json` uses `extensions:
  ["./index.js"]`)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:29:35 +10:00
cc250e71bf fix(olp-connect): line 547 referenced undefined \$remote_host (bash nounset crash) (#61)
bin/olp-connect declares \$host and \$port locally (line 436) but line 547
referenced an undefined \$remote_host. Under set -u (nounset) the script
aborted with "remote_host: unbound variable" on every successful /health
probe path that surfaced an anonymousKey, blocking the zero-config
client setup that D68-D70 + ADR 0011 designed.

Fix: use \${host}:\${port} (the actual local variables) for the error-log
context. The variable was only used as a message-context string passed
into validate_olp_token, so no behavior change beyond the message.

Discovered while bringing up MacBook as anonymous client of PI231 server
(2026-05-27). Reproduced cleanly on the v0.5.1 main branch.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 12:25:17 +10:00
9dc070bc53 docs: refresh dashboard screenshot with live MacBook v0.5.1 data (#60)
D82 originally rendered docs/img/dashboard-v0.5.0.png from synthetic
quota_v2 data (the live API wasn't probed at the time of capture).
After v0.5.1 hotfix shipped, the screenshot is replaced with a render
from a real /v0/management/dashboard-data response captured 2026-05-27
from the MacBook OLP server on commit fa2d1af (F4+#7 post-merge).

Changes:
- docs/img/dashboard-v0.5.0.png → docs/img/dashboard-v0.5.1.png
  (rename + content refresh; new content is the v0.5.1 live capture)
- README.md screenshot reference updated to v0.5.1 path + alt-text
  now includes the live utilization numbers (5h: 6%, 7d: 38%)
- docs/exit-gates/phase-5-e2e.json refreshed to reflect the post-v0.5.1
  test (different server version, different temp owner key, new
  utilization snapshot). Records the v0.5.1 contract verifications
  (status enum, failure null for healthy live, schema_version match).

Post-test cleanup verified:
- Temp owner key (id=0m6s2s97, name=v0.5.1-screenshot) revoked
- ~/.olp/config.json providers.anthropic.quota_probe_enabled flag
  removed (config restored to baseline)
- Test server (port 14567) terminated

No code or contract changes. Pure documentation refresh.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 11:28:16 +10:00
fa2d1af130 feat+test: F4 CLI/plugin quota_v2 migration + v1.x #7 AUTH_MISSING test pin (#59)
## F4 — bin/olp.mjs + olp-plugin/index.js migrate to quota_v2

Codex post-v0.5.0 review Q4: both `olp usage` CLI and `/olp usage`
plugin handler read legacy `body.quota` shape, which never has
`percent_used` or meaningful `available` data → display always fell
through to "no quota api" even when anthropic live quota was visible
on the dashboard (D81/D82).

Authority: codex review Q4 (PR #58); ADR 0008 Amendment 2 (quota_v2
shape: { provider, status, utilization, reset, representative_claim,
fallback_percentage, overage, failure?, failure_kind?, backoff_until? }).

### bin/olp.mjs cmdUsage

- When `body.quota_v2` present (non-empty array, server v0.5.0+): render
  per-provider rows with status badge (live/stale/unreachable/unavailable),
  5h + 7d utilization % (color-coded: green <50% / yellow 50-80% / red ≥80%),
  reset countdowns, binding claim, ⚠ stale /  unreachable annotations.
- When `body.quota_v2` absent: fall through to existing legacy `body.quota`
  rendering (preserves backwards compat with pre-v0.5.0 servers).
- Added `formatResetCountdown(epochSeconds)` — 5-range formatter (past /
  <1h / <24h / <7d / ≥7d), ported from dashboard.html D82. Exported for
  tests. Kept in bin/olp.mjs (not lib/) per spec guidance.
- Added `formatAgo(diffMs)` — small helper for stale-row age display.

### olp-plugin/index.js fmtUsage()

- Same quota_v2 migration. Plain-text output (no ANSI), one line per
  provider. Legacy body.quota fallback preserved.
- Added `pluginFormatResetCountdown(epochSeconds)` — intentionally
  duplicated from bin/olp.mjs (olp-plugin ships as a separate package
  and must not import from bin/). Exported for tests.
- Also fixed fmtUsage to read `w.request_count` (dashboard-data shape)
  in addition to legacy `w.requests` (OCP-era shape) for completeness.

### Backwards compat

- Pre-v0.5.0 server returns body.quota only → both surfaces use legacy
  display (unchanged behaviour).
- v0.5.0+ server returns both quota and quota_v2 → both surfaces prefer
  quota_v2.
- Legacy code paths kept (5-10 lines each, not deleted).

## v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — CLOSED

The dedicated test was already shipped at D56 (test-features.mjs line
6255: 'engine: AUTH_MISSING terminates chain, fallbackDetail tuple
records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'). This
commit closes the roadmap entry with a date stamp and PR reference.

Authority: docs/v1x-roadmap.md § "#7 — AUTH_MISSING tuple path test
coverage (D40 follow-up)".

## Tests

Suite 40 (9 new tests — 40a through 40i):
  40a — cmdUsage parses quota_v2 live rows (mock server)
  40b — cmdUsage falls back to legacy body.quota when quota_v2 absent
  40c — olp-plugin fmtUsage parses quota_v2 live row
  40d — olp-plugin fmtUsage falls back to legacy body.quota
  40e — formatResetCountdown covers all 5 time ranges
  40f — pluginFormatResetCountdown covers past/<1h/<24h/<7d/≥7d
  40g — cmdUsage renders quota_v2 stale row with ⚠ stale note
  40h — cmdUsage renders quota_v2 unreachable row with  indicator
  40i — cmdUsage renders quota_v2 unavailable rows correctly

759 → 768 tests, 0 fail.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:24:46 +10:00
bddf2cba1e release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) (#58)
* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review)

Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on
PR #57, reproduced with local mocks):

F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3)
  `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing
  `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff
  window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL
  consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified:
  "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through
  `quotaStatus()` and MUST NOT call `_probeOnce()` directly."

F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5)
  A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live).
  Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset +
  7d-utilization + 7d-reset present; absence → `failureKind: 'schema_drift'`, backoff
  scheduled, result NOT cached. ADR 0013 Rule 5 updated with the gate specification.

F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6)
  `aggregateProviderQuota()` collapsed all failure modes into `status: 'unavailable'`
  ("no public quota api or probe disabled") — same as providers with no API at all.
  Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return
  `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`.
  New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift |
  network | other. Dashboard renders `unreachable` with red border + failure detail.

Authority:
  ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency)
  ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status)
  ADR 0002 Amendment 8 unchanged (constitutional permission for the probe)
  Codex review findings F1–F3 (codex on PR #57)

Changes:
  - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind;
    _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract
    (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes
    through quotaStatus(); reset functions updated
  - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field;
    aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status
  - dashboard.html: unreachable CSS classes + render path + footer v0.5.1
  - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1;
    38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests
  - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract)
  - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus);
    Rule 5 min-viable-schema gate specification
  - package.json: 0.5.0 → 0.5.1
  - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased
  - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum)

Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking,
4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes).
Deferring #3 (test-only edge case in module-level seam restore) and #4
(pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy
quota shape; separate PR planned).

Nit #1 — ADR 0002 Amendment 8 documentation drift.

Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather
than throwing", and line 33 said "stale-cache-on-failure (`null` is
returned only when no cache entry exists; if a stale entry exists it's
returned with a `stale: true` marker)". v0.5.1 refined this contract:
`null` is now reserved STRICTLY for opt-in-off, and all failure modes
return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`.

The substantive idempotent-failure constraint (no throw to caller) is
unchanged. The operational description in Amendment 8 was stale — fixed
to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the
v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as
the regression coverage producing the new shape.

Nit #2 — `failureKind: 'opt_in_off'` declared but never produced.

The enum value was listed in both the code comment (anthropic.mjs:250)
and ADR 0008 Amendment 2 (line 30) but never actually assigned —
because when opt-in is off, `quotaStatus()` returns the literal `null`
BEFORE any state mutation happens. The enum value was dead.

Fix: removed `opt_in_off` from both enum declarations + added an
inline note explaining that consumers (audit-query, doctor) distinguish
opt-in-off by checking `quotaStatus() === null`, not via failureKind.

Deferred:

- Nit #3 (test-seam restore edge case): if a caller pre-sets
  `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`,
  the finally block restores to null clobbering pre-set value.
  Test-only impact, no production risk. Pure hygiene; defer.

- Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js
  still read legacy `body.quota` field, never consume `quota_v2`.
  Reviewer confirmed neither crashes — both gracefully fall through
  to "no quota api" branch. Out of scope for this hotfix per the
  hotfix dispatch contract; separate PR will migrate them.

Tests: 759/759 still pass post-fold-in. No test changes needed.

Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency)
+ ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:33:47 +10:00
65681ed7d2 release(v0.5.0): Phase 5 close — quota probe + dashboard enrichment (#57)
Promotes "Unreleased" → v0.5.0 — 2026-05-26. Maintainer-triggered per
CLAUDE.md release_kit.phase_close_trigger.

Phase 5 closed with 6 D-days shipped across 7 PRs, every PR through a
fresh-context opus reviewer per Iron Rule 10, 0 blocking findings, 720
→ 756 tests, no flakies, all CI green:

- D79 governance (PR #50): ADR 0012 charter + ADR 0002 Amendment 8 +
  ADR 0013 + ALIGNMENT.md Class-specific Exceptions §1
- D79 cleanup (PR #51): reviewer N6+N7+N9 fold-in
- D80 (PR #52): anthropic plan-usage probe port (~250 LOC; OCP
  server.mjs:842-1109 → lib/providers/anthropic.mjs:quotaStatus())
- D81 (PR #53): lib/audit-query.mjs aggregateProviderQuota() +
  /v0/management/{dashboard-data,quota} quota_v2 field +
  models-registry.json quota_probe block
- D82 (PR #54): dashboard.html Claude.ai-style Plan Usage panel
  (closes v1.x roadmap #8)
- D83 (PR #55): Suite 38 (20 probe unit tests) + Suite 39 (8 dashboard
  smoke tests) + 5 test seams
- Close-prep (PR #56): README § Plan Usage + § Supported Providers
  Quota-probe column + dashboard screenshot + docs/exit-gates/phase-5-e2e.json
  + 3-finding fold-in

Changes in this commit:
- package.json: 0.4.4 → 0.5.0
- CHANGELOG.md: "Unreleased" promoted to "## v0.5.0 — 2026-05-26"
  with comprehensive release notes spanning all Phase 5 deliverables;
  fresh empty "## Unreleased" added above for Phase 6
- CLAUDE.md release_kit.phase_rolling_mode:
    current_phase: Phase 5 → Phase 6
    current_pre_release_identifier: "0.5.0-phase5" → "0.6.0-phase6"

Tag push (git tag v0.5.0 && git push --tags) happens AFTER this merges
to main. Tag push fires .github/workflows/release.yml which auto-creates
the GitHub Release with notes derived from CHANGELOG.md.

Tests: 756/756 pass locally; no production-code changes beyond version
bumps.

Authority: CLAUDE.md release_kit overlay (Iron Rule 5.5); ADR 0012
§ Exit gate (all 9 items satisfied); Phase 5 D-day commits 1605400,
187e793, 82d2e1c, 5288493, a41420d, 2b07a3b, d872330 on main.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 06:16:04 +10:00
d872330c9e docs: Phase 5 close-prep — README § Plan Usage + supported-provider matrix + live E2E artifact (#56)
* docs: Phase 5 close-prep — README § Plan Usage + supported-provider matrix + live E2E artifact

Pre-close documentation pass per ADR 0012 § Exit gate items 3 + 9.
v0.5.0 close PR (package.json bump + CHANGELOG promotion + tag) is
maintainer-triggered per CLAUDE.md release_kit.phase_close_trigger.

What this PR ships:

(1) README § What you get — added Plan-usage probe bullet with anchor to
    the new § Plan Usage section.

(2) README § Supported Providers — table gained "Quota probe (v0.5.0+)"
    column. Anthropic  live (13 anthropic-ratelimit-unified-* headers,
    opt-in via quota_probe_enabled). Codex  no public quota API.
    Mistral  per D84 spike 2026-05-26 (no /v1/usage at docs.mistral.ai/api).
    Others TBD (Phase 8+).

(3) README new § Plan Usage (live quota probe) between § Configuration and
    § API Endpoints. Documents: how the probe works (POST /v1/messages
    with max_tokens:1 + headers-only parse), opt-in config block, OAuth
    token sources (env / .credentials.json / macOS Keychain), olp doctor
    integration, schema-drift protection (Path A `strings` over compiled
    binary + Path B live probe diff), full ADR cross-refs. Embeds the
    dashboard screenshot below.

(4) README § API Endpoints — updated /dashboard / /v0/management/dashboard-data
    / /v0/management/quota rows to reflect Phase 5 changes:
    - /dashboard: Claude.ai-style Plan Usage section + 60s auto-refresh + manual button
    - /v0/management/dashboard-data: new quota_v2 field alongside legacy quota
    - /v0/management/quota: mirrors dashboard-data quota_v2 for scripted monitoring

(5) docs/img/dashboard-v0.5.0.png — dashboard screenshot rendered from
    live MacBook server JSON (utilization_5h: 36%, utilization_7d: 34%,
    representative_claim: five_hour, overage: rejected) merged into D83
    dashboard.html. Identifying IPs / hostnames / Tailscale nodes redacted
    from rendered output per public-repo hygiene rule (cc-rules AGENTS.md).

(6) docs/exit-gates/phase-5-e2e.json — sanitized record of the Live
    MacBook E2E verification (exit-gate item 9). Confirms quota_v2 shape
    produced live, anthropic probe returned 12/13 fields (overage-reset
    absent per audit memory — only fires on active overage), opt-in
    mechanism verified end-to-end (config flag flipped → server probed
    → dashboard renders). Post-test cleanup noted: temp owner key
    revoked, config restored to baseline, test server terminated.

Remaining exit-gate items for the v0.5.0 close PR (maintainer-triggered):

- CHANGELOG.md "Unreleased" → "## v0.5.0 — <date>" promotion
- package.json 0.4.4 → 0.5.0
- CLAUDE.md release_kit.phase_rolling_mode: Phase 5 → Phase 6,
  0.5.0-phase5 → 0.6.0-phase6
- Tag v0.5.0 push (triggers .github/workflows/release.yml auto-release)

Authority cited:
- ADR 0012 § Exit gate items 3 + 9
- ADR 0012 Amendment 1 (D84 NO-GO rationale in Mistral row)
- ADR 0002 Amendment 8 + ADR 0013 (the constitutional context for the
  Plan Usage section's "how it works" framing)
- D80 commit 82d2e1c (probe producer cited in API Endpoints table)
- D81 commit 5288493 (quota_v2 shape producer)
- D82 commit a41420d (dashboard UI consumer)
- D83 commit 2b07a3b (test coverage)

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

* docs: PR #56 fold-in — 3 maintainer findings (F1 doctor kind / F2 Mistral / F3 SPOT)

Maintainer review of PR #56 surfaced 3 valid accuracy issues. Folding in.

F1 [P2] — README claim "fix_oauth on 401/403, fix_provider on 429/network"
is wrong. The anthropic.quota_probe_reachable check has category: 'provider'
(anthropic.mjs:999), so deriveKind() at lib/doctor.mjs:464 maps ALL failures
to fix_provider regardless of underlying HTTP status. Furthermore, _probeOnce
collapses 401/403 → null before reaching the doctor aggregate layer
(anthropic.mjs:451), so the HTTP status isn't observable to deriveKind at
all. README would have steered AI-repair loops toward an unreachable
discriminator.

Fix: README now accurately says all probe failures discriminate to
kind: fix_provider, with the actionable text inside human_steps[] being
auth-aware (re-login recipe for OAuth failures, wait-and-retry for
rate-limit). Note that splitting the check across the provider/auth
category boundary would let fix_oauth fire for OAuth-class failures
specifically; deferred to v1.x pending consumer-reported ambiguity.

F2 [P2] — README + ADR 0012 Amendment 1 said Mistral has "no programmatic
quota API; only web console". Maintainer pointed to Mistral's Admin API
(https://docs.mistral.ai/admin/security-access/admin-api) which DOES
expose Billing and Usage queries — just gated to org-admin-scoped keys.

The D84 NO-GO conclusion is still correct: OLP's deployment posture
(maintainer's personal Le Chat Pro account, trusted-LAN per ADR 0011)
uses Vibe / Le Chat member / La Plateforme keys, NOT org-admin keys.
Provisioning + storing an org-admin token raises the credential-scope
ceiling beyond the trusted-LAN design.

Fix: tightened wording in 3 places (README Supported Providers table,
README Plan Usage provider coverage table, ADR 0012 Amendment 1) to
say "no public quota endpoint accessible to Vibe / Le Chat member /
La Plateforme API keys" + acknowledge the Admin API surface + cite it
as a forward re-entry point if OLP scope expands to org-admin context.

F3 [P3] — Supported Providers table claims it's re-generated from
models-registry.json, but the new "Quota probe (v0.5.0+)" column has
values for OpenAI/Mistral/TBD that aren't in the registry (registry only
had quota_probe.anthropic block before this PR).

Fix: closed the SPOT drift formally by adding quota_probe.openai and
quota_probe.mistral entries to the registry with {status, reason,
re_entry_point} for each, plus an admin_api_reference for Mistral. Also
added explicit "status": "live" field to quota_probe.anthropic for
symmetry. Tightened README's source-of-truth statement to name BOTH
the providers.<key> block (model metadata) and quota_probe.<key> block
(probe status/reason/source).

This makes the column fully derivable from the registry — when D84 ever
becomes GO (Mistral Admin API integration, or OpenAI publishes a quota
endpoint), the registry is the single edit point.

Test impact: 756/756 still pass. Suite 37g (registry presence + 13 fields
for anthropic) unchanged; the new openai/mistral quota_probe entries are
additive and don't break any consumer.

Authority:
- F1: lib/doctor.mjs:464 deriveKind logic + anthropic.mjs:999 category
- F2: https://docs.mistral.ai/admin/security-access/admin-api + ADR 0011
  trusted-LAN deployment context for the "out of scope" framing
- F3: CLAUDE.md release_kit overlay § "Supported Providers table sourced
  from models-registry.json" — same SPOT discipline applies to the new
  D81 column

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-26 20:59:24 +10:00
2b07a3bd1b test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5) (#55)
* test: D83 — Suite 38 (quota probe) + Suite 39 (dashboard smoke) (Phase 5)

ADR 0012 D83 row: comprehensive test coverage for the Phase 5 quota probe
machinery (D80) and dashboard rendering (D82).

## Authorities cited

- ADR 0012 D83 (D-day specification)
- ADR 0013 Rules 2–6 (quota probe constraints being tested)
- D80 PR #52 (anthropic.quotaStatus() + _probeOnce + _parseRateLimitHeaders)
- D81 PR #53 (aggregateProviderQuota quota_v2 shape)
- D82 PR #54 (dashboard.html Claude.ai-style restructure)
- Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md

## Test seams added to lib/providers/anthropic.mjs

Minimal test seams added — 0 production-logic changes:
- `_setQuotaUrlsForTest(apiUrl, oauthUrl)` — redirects probe HTTP to local
  mock server (auto-detects http: vs https: to switch transport module)
- `_resetQuotaProbeStateForTest()` — resets cache + backoff + URLs + auth seam
- `_resetQuotaStateOnlyForTest()` — resets cache + backoff only (URLs stay)
- `_getQuotaProbeStateForTest()` — returns direct reference to quotaProbeState
  for test assertions and controlled state mutation
- `_setQuotaAuthReadFnForTest(fn)` — injects a mock auth reader into quotaStatus()
  so tests are not affected by real ~/.claude/.credentials.json on the machine
  (fixes 38f: no-auth test was finding real keychain credentials)

## Suite 38 — 20 quota-probe unit tests (38a–38t)

38a: all 13 ratelimit-unified-* headers parsed correctly (numeric types, null defaults)
38b: missing overage-reset → overage_reset: null
38c: missing all three overage fields → all three null
38d: new 5h-status + 7d-status fields (NEW vs OCP 2026-04) parsed correctly
38e: quota_probe_enabled: false → null without HTTP call
38f: auth returns null → quotaStatus returns null without HTTP call
38g: 200 + all 13 headers → full shape with stale:false + backoff reset
38h: cache hit within 5min TTL → no second HTTP call (request count stays 1)
38i: expired cache (6min > 5min TTL) → fires fresh HTTP probe
38j: 401 with no refreshToken → null (idempotent-failure per ADR 0002 Amendment 8 §3)
38k: 429 + stale cache → returns stale cache with stale:true + last_fresh_at
38l: 429 + no cache → null + backoff scheduled (backoffUntil in the future)
38m: exponential backoff growth: 60s→120s→240s→cap at 3600s
38n: successful probe resets backoffMs to 60s + backoffUntil to 0
38o: schemaVersion from models-registry.json (falls back to constant)
38p: doctor quota_probe_reachable disabled → ok with opt-in advisory
38q: doctor probe enabled + probe succeeds → ok with utilization in message
38r: doctor probe enabled + fails + stale cache → warn
38s: doctor probe enabled + fails + no cache → fail with fix_commands
38t: doctor probe enabled + no creds → fail with human_steps only

## Suite 39 — 8 dashboard rendering smoke tests (39a–39h)

39a: /dashboard with owner token → 200 + text/html
39b: /dashboard without token → 401
39c: /dashboard with guest key → 401 (owner-only_block enforcement per ADR 0008 §8)
39d: HTML contains "Plan Usage" header (D82 panel)
39e: HTML contains ↻ Refresh button (D82 manual refresh)
39f: HTML contains QUOTA_POLL_INTERVAL_MS = 60000 (D82 1-min refresh)
39g: HTML contains visibilitychange / visibilityState guard (D82 ADR 0012)
39h: HTML contains quota_v2 consumer + legacy renderQuota fallback code

## Test count: 727 → 755 (+28)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: D83 fold-in — fix 38j misleading name + add 38j2 positive-path + fix 38o ESM require

Fresh-context reviewer (PR #55) flagged one blocking issue + 1 nit:

BLOCKING (Q5) — 38j misleading name + uncovered 401→refresh→retry positive path.
The original 38j test description said "401 → refresh succeeds → retry probe
succeeds" but the test actually asserted the OPPOSITE behavior (401 with no
refreshToken → null, no retry). The most important non-trivial control-flow
branch in _probeOnce (anthropic.mjs:451-457 the refresh-and-retry on 401) was
uncovered, and the misleading name actively masked the gap.

Fix:
- Renamed 38j to accurately describe what it tests: "401 with no refreshToken
  → null (idempotent-failure, no refresh attempt)". The test pins valuable
  behavior — idempotent-failure when refreshToken is absent — but now its
  name matches.
- Added 38j2 (NEW) to exercise the actual positive-path: inject creds with
  refreshToken via _setQuotaAuthReadFnForTest, mock returns 401 on first API
  call + 200 with 13 headers on retry. Asserts: 2 API calls, 1 OAuth call,
  retry parsed full shape, OAuth body contains the injected refreshToken
  (Rule 1 credential reuse verification).

NIT (Q5 dead assertion) — 38o tried to read models-registry.json via
require('fs') which is undefined in ESM. The assert was silently never
exercised. Replaced with the already-imported readFileSync from node:fs
(added _readFileSync38 import). Also tightened: schemaVersion MUST equal
the registry's quota_probe.schema_version (no longer conditional on
"if expected" which was always falsy).

Other reviewer nits deferred (non-blocking per reviewer):
- N2: seam naming convention (_ vs __) — defer
- N3: commit message "zero production changes" — note for v0.5.0 close
- N4: _getQuotaProbeStateForTest returns mutable ref — defer
- N5: test seam runtime guard — defer
- N6: coverage gaps (concurrent probes, registry-missing fallback,
       network error path) — v1.x roadmap follow-ups
- N7: Suite 39 stray error listener — defer (benign)

Test count: 755 → 756 (+1 net, +2 new minus existing 38j rename).
All 756 pass; 0 fail. Local re-run: stable.

Authority: PR #55 review thread (D83 fresh-context opus reviewer)
+ ADR 0013 Rule 1 (credential reuse via refresh).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:47:15 +10:00
a41420d0fc feat: D82 — dashboard UI Claude.ai-style (Phase 5) (#54)
* feat: D82 — dashboard UI Claude.ai-style per-provider rows (Phase 5)

Implements ADR 0012 D82: restructures dashboard.html to render
quota_v2 data (produced by D81 / PR #53) in a Claude.ai-style
per-provider row layout. Closes v1.x roadmap #8.

## What changed

### dashboard.html (A–G)

A. New "Plan Usage" section at top (full-width, above the 2-col grid):
   - Per-provider rows rendered from `data.quota_v2`
   - Each row: provider badge (colored chip), status dot + chip
     (live/stale/unavailable), schema version tag
   - Two utilization bars (5h + 7d) with:
     - Rounded gradient bar (green <50% / amber 50-80% / red >80%)
     - Label "Current 5-hour session: 49%" / "Weekly all-models: 31%"
     - Right-side reset countdown (see B)
   - Bottom chips: representative-claim badge (purple), overage chip
     (amber/green), fallback-percentage chip, last-fresh "Updated N
     min ago" tag; stale rows show amber ⚠ stale data chip with tooltip
   - Unavailable rows: provider badge + reason text only; no bars

B. formatResetCountdown(epochSeconds):
   - < 1 hour:   "Resets in 23 min"
   - 1–24 hours: "Resets in 12hr 30min"
   - < 7 days:   "Resets Sun 9:00 PM"  (weekday + 12h time)
   - >= 7 days:  "Resets May 31 9:00 PM" (month day + time)
   - past:       "Resetting now…"
   Uses toLocaleString('en-US', { hour12: true }).

C. 60s auto-refresh (quota_v2 only) with visibilityState guard:
   - Separate timer (quotaRefreshTimer); does NOT replace the 30s poll
   - Pauses on 'hidden'; resumes + immediate re-fetch on 'visible'
   - Other 3 panels (24h, 30d, top fallback) keep 30s cadence unchanged

D. Manual refresh button (↻ Refresh) in Plan Usage header:
   - 2-second spam guard (button disables post-click)
   - Spinning ⟳ icon during fetch
   - Re-enables after fetch completes (success or error)

E. Graceful quota_v2 / legacy quota fallback:
   - If data.quota_v2 is present and non-empty → render Plan Usage rows;
     hide legacy "Quota (per provider)" panel
   - If data.quota_v2 is absent/empty → show note in Plan Usage area;
     surface legacy data.quota in the original table panel
   - Guards operator running an older OLP build (pre-D81)

F. Visual polish: rounded bars, gradient fills, airy whitespace, mobile-
   responsive (bars reflow on narrow viewports via flex-wrap). Color
   palette: #10b981 (green), #f59e0b (amber), #ef4444 (red) matching
   Tailwind emerald/amber/red-500 per spec.

G. Other 3 panels (24h, 30d, top fallback) and their 30s poll cadence
   are IDENTICAL to D51. Only the Quota panel restructures.

### docs/v1x-roadmap.md (H)

Marks entry #8 as " CLOSED (D82, v0.5.0)". Adds closure status,
PR ref, and a brief note inside the entry body. Updates reading-order
header paragraph to include #8 in the closed list.

## Authority + citations

- ADR 0012 D82 — Claude.ai-style restructure D-day spec
  (docs/adr/0012-phase-5-charter-quota-probes-dashboard.md § D-day table)
- D81 PR #53 — quota_v2 shape producer (commit 5288493);
  ProviderQuotaEntry shape per ADR 0008 Amendment 1 § 3
- Maintainer reference 2026-05-26 — claude.ai/settings/usage screenshot;
  "Resets in 1hr 6min" / "Resets Sun 9:00 PM" string format
- v1.x roadmap #8 — closed by this commit
  (docs/v1x-roadmap.md #8 — Dashboard enrichment)

## Test impact

npm test: 727 pass / 0 fail (unchanged). dashboard.html is frontend-only;
D83 ships Suite 38/39 (probe unit tests + dashboard smoke tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(dashboard): D82 reviewer Nit #4 — gate overage chip on real status

When entry.overage = { status: null, disabled_reason: null } (audit-query's
default shape when the provider doesn't supply overage info), the chip rendered
as amber "Overage: —" which falsely suggests a warning state.

Now: chip only renders when entry.overage.status is truthy (i.e., Anthropic
actually returned an overage-status header). Truly-missing overage info shows
no chip at all, matching the maintainer's intent.

Identified by D82 fresh-context reviewer (PR #54 thread) as the only
maintainer-visible nit worth folding in pre-merge. 1-line change. All 727
tests continue to pass.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:20:49 +10:00
5288493f19 feat: D81 — dashboard-data quota_v2 shape + models-registry schema_version (Phase 5) (#53)
Authority citations (required per CLAUDE.md § Hard requirements):
  1. ADR 0012 D81 — the D-day being implemented (Phase 5 charter, audit-query
     + dashboard-data extension row in § D-day table)
  2. ADR 0013 Rule 5 — schema_version in models-registry.json mandate. D80
     used a local constant QUOTA_SCHEMA_VERSION = '2026-05-26' (reviewer nit
     #4 at PR #52). D81 folds in Rule 5 compliance: adds quota_probe.schema_version
     to models-registry.json and has anthropic.mjs read from there with the
     constant as fallback via _resolveSchemaVersion().
  3. ADR 0008 — the audit-query design being amended (Amendment 1 added at D81
     to docs/adr/0008-dashboard-and-audit-query.md documenting: quota_probe in
     registry, aggregateProviderQuota() API shape, quota_v2 key, deprecation
     timeline for legacy quota key).
  4. D80 PR #52 (commit 82d2e1c) — producer of the quotaStatus() shape that
     D81 normalizes. The 13-field anthropic-ratelimit-unified-* shape from
     _parseRateLimitHeaders() is consumed by _normalizeAnthropicQuota() here.

Changes (A–G per D81 spec):

A. models-registry.json — new top-level quota_probe key
   - quota_probe.schema_version = '2026-05-26'
   - quota_probe.anthropic.{ source, endpoint, fields_pinned[13] }
   - fields_pinned is load-bearing for drift detection (ADR 0013 Rule 5)

B. lib/providers/anthropic.mjs — _resolveSchemaVersion() helper
   - Reads quota_probe.schema_version from modelsRegistryRaw at call time
   - Falls back to QUOTA_SCHEMA_VERSION constant if registry field absent
   - quotaStatus() now uses _resolveSchemaVersion() instead of raw constant

C. lib/audit-query.mjs — aggregateProviderQuota() export
   - Normalizes per-provider quotaStatus() returns to ProviderQuotaEntry shape
   - Live → { status:'live', utilization, reset, representative_claim, … }
   - Stale → { status:'stale', … }
   - null return → { status:'unavailable', reason:'no public quota api or probe disabled' }
   - throw → { status:'unavailable', reason:<error.message> }
   - getQuotaStatus injection point for full test isolation (D81 §F)

D. server.mjs handleManagementDashboardData — quota_v2 added
   - Calls auditAggregateProviderQuota({ providers: loadedProviders })
   - Both legacy quota (backwards compat) and quota_v2 in response
   - Graceful degradation: quota_v2 failure → warn log + empty array, rest of
     payload unaffected

E. server.mjs handleManagementQuota — quota_v2 added (mirrors D)

F. test-features.mjs — Suite 37 (7 new tests)
   - 37a: live shape normalization (status=live, utilization, reset, etc.)
   - 37b: stale shape (status=stale)
   - 37c: null returns → unavailable entries
   - 37d: throw path → unavailable with reason
   - 37e: mixed providers (live + unavailable)
   - 37f: getQuotaStatus injection verified
   - 37g: models-registry.json has quota_probe.schema_version + 13 fields_pinned

G. docs/adr/0008-dashboard-and-audit-query.md — Amendment 1 added

Test delta: 720 → 727 (+7), 0 failures.

Live quota_v2 JSON sample (illustrative — probe is opt-in per ADR 0013 Rule 4;
real values require quota_probe_enabled:true + valid OAuth credentials):

  GET /v0/management/dashboard-data (owner-only)
  {
    "quota_v2": [
      {
        "provider": "anthropic",
        "status": "live",
        "schema_version": "2026-05-26",
        "last_fresh_at": 1748300000000,
        "utilization": { "5h": 0.49, "7d": 0.31 },
        "reset": { "5h": 1748290000, "7d": 1748500000, "overall": 1748300000, "overage": null },
        "representative_claim": "five_hour",
        "fallback_percentage": 0.5,
        "overage": { "status": "rejected", "disabled_reason": "org_level_disabled_until" },
        "raw_available": true
      },
      {
        "provider": "openai",
        "status": "unavailable",
        "reason": "no public quota api or probe disabled",
        "schema_version": null,
        "last_fresh_at": null,
        "utilization": null,
        "reset": null,
        "representative_claim": null,
        "fallback_percentage": null,
        "overage": null,
        "raw_available": false
      }
    ]
  }

When probe is disabled (default), anthropic entry also returns unavailable:
  { "provider": "anthropic", "status": "unavailable",
    "reason": "no public quota api or probe disabled", ... }

NOT modified: dashboard.html (D82), codex.mjs, mistral.mjs, any quotaStatus()
return shape from anthropic.mjs (D80 contract unchanged — normalization is in
the new audit-query layer only).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:06:41 +10:00
82d2e1cbea feat: D80 — anthropic plan-usage probe port (Phase 5) (#52)
Port OCP server.mjs:842-1109 plan-usage probe to lib/providers/anthropic.mjs:quotaStatus().

## Authority citations (CLAUDE.md hard requirement #1)

1. Schema pin: ~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md
   — 13-field canonical schema verified live 2026-05-26; 3 fields new vs OCP
   2026-04 capture (5h-status, 7d-status, overage-reset).

2. OCP port source: OCP server.mjs:842-1109 — usageCache, oauthRefreshBackoff,
   getOAuthCredentials, refreshOAuthToken, fetchUsageFromApi, parseRateLimitHeaders.
   Claude Code CLI uses this same POST /v1/messages internally (verified 2026-05-26
   by `strings` on @anthropic-ai/claude-code v2.1.142 / v2.1.150 Mach-O binary — see
   audit memory). This is observed CLI behaviour, not invention.

3. ADR 0002 Amendment 8 — READ-ONLY exemption for quotaStatus() direct-API access.
   Three constraints satisfied: READ-ONLY (max_tokens:1, body discarded),
   subscription-scope (same readAuthArtifact() creds as spawn path),
   idempotent-failure (returns null / stale on any error, never throws).

4. ADR 0013 — OAuth READ-ONLY consumption rules. All 7 rules satisfied:
   Rule 1: credential reuse via readAuthArtifact() (env → .credentials.json → keychain).
   Rule 2: only POST /v1/messages (no other endpoints). Body discarded; headers-only.
   Rule 3: 5min TTL cache; 60s–3600s exponential backoff; stale-on-failure.
   Rule 4: opt-in via ~/.olp/config.json providers.anthropic.quota_probe_enabled (default false).
   Rule 5: schema pin committed to memory file; drift detection protocol in place.
   Rule 6: doctor check anthropic.quota_probe_reachable surfaces probe status.
   Rule 7: does not govern spawn-path refresh (separate concern).

5. ADR 0012 D80 — Phase 5 charter: this commit is the D80 deliverable.

## Live probe transcript (2026-05-26 from MacBook keychain OAuth credentials)

Path B verification per ADR 0013 Rule 5:
  curl -s -i -m 20 -X POST https://api.anthropic.com/v1/messages \
    -H "Authorization: Bearer <token>" \
    -H "anthropic-beta: oauth-2025-04-20" \
    -H "anthropic-version: 2023-06-01" \
    -H "Content-Type: application/json" \
    -d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"."}]}'

Response (header lines only):
  HTTP/2 200
  anthropic-ratelimit-unified-status: allowed
  anthropic-ratelimit-unified-5h-status: allowed
  anthropic-ratelimit-unified-5h-reset: 1779794400
  anthropic-ratelimit-unified-5h-utilization: 0.09
  anthropic-ratelimit-unified-7d-status: allowed
  anthropic-ratelimit-unified-7d-reset: 1780225200
  anthropic-ratelimit-unified-7d-utilization: 0.32
  anthropic-ratelimit-unified-representative-claim: five_hour
  anthropic-ratelimit-unified-fallback-percentage: 0.5
  anthropic-ratelimit-unified-reset: 1779794400
  anthropic-ratelimit-unified-overage-disabled-reason: org_level_disabled_until
  anthropic-ratelimit-unified-overage-status: rejected
  (no anthropic-ratelimit-unified-overage-reset — expected: only present on active overage)

12/13 fields present. overage-reset absent = no active overage (expected per audit memory).
All fields parsed correctly by _parseRateLimitHeaders(). Confirmed via D80 smoke test.

## Implementation

A. quotaStatus() — full probe implementation replacing D4 null stub:
   - _readProviderConfig('anthropic') gate (Rule 4 opt-in)
   - 5min module-level cache check (quotaProbeState.cache)
   - 60s–3600s exponential backoff check (quotaProbeState.backoffUntil / backoffMs)
   - readAuthArtifact() credential read (env → .credentials.json → macOS keychain)
   - _probeOnce() → POST /v1/messages with 4 required headers; body discarded
   - 401/403 → single refresh-and-retry via _refreshAccessToken()
   - On success: cache { fetchedAt, data } + reset backoff to MIN
   - On failure: _scheduleBackoff() (doubles backoffMs, caps at MAX) + return stale or null
   - Return shape: { probedAt, source, schemaVersion, stale, fields:{...13}, raw:{...} }

B. _parseRateLimitHeaders() — all 13 fields (3 new vs OCP):
   - status, representative_claim, reset, fallback_percentage (aggregate)
   - status_5h, utilization_5h, reset_5h (5h window)
   - status_7d, utilization_7d, reset_7d (7d window)
   - overage_status, overage_disabled_reason, overage_reset (overage)
   - Numeric strings → numbers; missing fields → null (not 0 or "unknown")

C. _refreshAccessToken() — uses Node.js built-in https (no fetch/3rd-party deps).
   Shared backoff state via quotaProbeState. Max one refresh per backoff window.

D. _probeOnce() — uses Node.js built-in https. 15s timeout. Drains + discards body.

E. _readProviderConfig() — reads ~/.olp/config.json providers.<name> block.
   OLP_HOME respected (same as lib/keys.mjs). Never throws; returns {} on error.

F. doctorChecks() — new anthropic.quota_probe_reachable check (ADR 0013 Rule 6):
   - status: ok when probe disabled (returns advisory message)
   - status: ok when probe succeeds (shows utilization %)
   - status: warn when stale cache exists (probe failed but cache present)
   - status: fail when no cache + probe failed (fix_commands + human_steps recipe)

G. docs/v1x-roadmap.md — #8 Dashboard enrichment entry (D79 follow-up) added.

## Tests

- All 720 existing tests pass (npm test).
- Suite 33j updated to include anthropic.quota_probe_reachable in the expected
  probe set (3 probes total, previously 2).
- D83 (Suite 38) will add quota-probe unit tests with mock HTTP server.

## What NOT changed

- dashboard.html — untouched (D82)
- lib/audit-query.mjs — untouched (D81)
- lib/providers/codex.mjs, mistral.mjs — untouched (D84 NO-GO per ADR 0012 Amendment 1)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:51:01 +10:00
187e79321f docs: D79 cleanup — ALIGNMENT.md N9 + ADR 0012 Amendment 1 (D84 NO-GO) (#51)
Two governance-layer cleanup items bundled per Iron Rule 11 (same layer +
same severity). Both are docs-only, both close-loop on D79 reviewer + spike.

(1) ALIGNMENT.md N9 cross-reference — the third outside-PR nit from the D79
fresh-context reviewer (PR #50). Class-specific Exceptions section gains its
first numbered exception (Anthropic plan-usage probe via direct /v1/messages).
Previously the section said "(none at project founding)" + invited "future
Rule 3 deviation"; this entry is a Rule 2 deviation, so the section header
text was updated to "Any Rule 2 or Rule 3 deviation".

(2) ADR 0012 Amendment 1 — D84 Mistral NO-GO per 2026-05-26 spike. Per the
D79 reviewer N5 fold-in, the Mistral GO/NO-GO decision was scheduled for
D79 close (before D80 starts). Spike completed 2026-05-26 with verdict NO-GO:

- docs.mistral.ai/api has no usage/quota/credits endpoint
- Direct probe /v1/usage returns 404
- Mistral's "Limits and Usage" help points only at web console UI
- No x-ratelimit-* response headers documented on /v1/chat/completions
- OLP mistral.mjs DL-7 comment already records this from independent
  D8 investigation

Disposition: D84 row struck through in D-day plan. Mistral dashboard row in
D82 will show "spend tracking only" badge from audit-query aggregates. DL-7
remains as the documented re-entry point. Phase 5 total D-day budget revised
~6 → ~5 (anthropic-only quota probe).

Outside-PR nits N6 + N7 already addressed in ~/.cc-rules commit 9fa533a
(audit memory chronology + D-day mapping fixes).

Authority:
- N9: PR #50 review thread (D79 fresh-context opus reviewer)
- D84 NO-GO: docs.mistral.ai/api spike 2026-05-26; OLP DL-7 precedent

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:35:59 +10:00
1605400052 docs: D79 — Phase 5 constitutional layer (ADR 0012 + ADR 0002 Amendment 8 + ADR 0013) (#50)
* docs: D79 — Phase 5 constitutional layer (ADR 0012 + ADR 0002 Amendment 8 + ADR 0013)

Three coupled governance documents land together as the Phase 5 constitutional
layer (Iron Rule 11 IDR — reviewing them separately cannot verify
consumer-producer alignment). Phase 5 opens 2026-05-26; D79 is governance-only,
no code changes.

- ADR 0012 (Phase 5 charter) — port OCP's plan-usage probe to
  `lib/providers/anthropic.mjs:quotaStatus()` (D80) + extend
  `/v0/management/dashboard-data` for new shape (D81) + Claude.ai-style
  dashboard restructure with 1-min auto-refresh + manual refresh (D82) +
  Suite 38/39 tests (D83) + optional mistral probe at D84 (codex skipped —
  no public API) + v0.5.0 close (maintainer-triggered). ~6 D-days.

- ADR 0002 Amendment 8 (direct-API READ-ONLY exemption) — plugin contract
  amendment permitting quotaStatus() to call provider HTTP APIs directly,
  subject to three constraints: READ-ONLY (no mutating calls),
  subscription-scope (reuses spawn-path credentials), idempotent failure
  (returns null on any error, never throws). No other contract method gains
  this permission.

- ADR 0013 (OAuth READ-ONLY consumption + schema-drift mitigation) —
  implementation discipline for ADR 0002 Amendment 8. Seven rules: (1)
  credential reuse via plugin's readAuthArtifact(), (2) READ-ONLY at wire
  (max_tokens:1, headers-only parse, body discarded), (3) cache TTL 5min +
  60s-3600s exponential refresh backoff + stale-cache-on-failure, (4)
  opt-in via `~/.olp/config.json providers.<name>.quota_probe_enabled`
  (default false), (5) schema-drift mitigation via dual-path verification
  (compiled-binary `strings` + live API probe diff), (6) failure
  transparency through `olp doctor` + dashboard staleness markers, (7)
  explicit out-of-scope clarifications.

Pre-flight institutional-knowledge audit (Iron Rule 12 prior-art search)
captured at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`
(cross-machine git-sync). Findings:

- OCP probe (server.mjs:842-1109) still works against current
  api.anthropic.com — tested live from PI231 OAuth credentials 2026-05-26.
- 13 `anthropic-ratelimit-unified-*` response headers confirmed
  (3 new since OCP 2026-04 capture: 5h-status, 7d-status, overage-reset;
  no removals or renames).
- Claude Code v2.1.x is now distributed as compiled native binary
  (Mach-O on macOS, ELF on Linux) — OCP's "grep cli.js" verification is no
  longer applicable. ADR 0013 Rule 5 replaces with dual-path verification
  (`strings` over the binary + live API probe diff).
- OAuth refresh path (platform.claude.com/v1/oauth/token + client_id
  9d1c250a-...) all unchanged.

Authority:
- ALIGNMENT.md Rule 1 (citation): audit memory + OCP server.mjs:842-1109 +
  live `/v1/messages` probe transcript 2026-05-26.
- ALIGNMENT.md Rule 2 (provider-CLI-as-authority): Amendment 8 documents the
  exemption; the probe mirrors observed CLI behaviour.
- ALIGNMENT.md Rule 5 (CI alignment.yml): not triggered (docs/ excluded by
  workflow `paths:` filter); blacklisted `/api/oauth/usage` token referenced
  only as meta-references ("must continue to blacklist").
- CLAUDE.md release_kit overlay: Phase 5 open; D-day commits stay under
  "Unreleased" until maintainer-triggered v0.5.0 close.

Iron Rule 10: fresh-context reviewer required before merge per CLAUDE.md
hard requirement #3.

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

* docs: D79 fold-in — 6 in-PR nits from fresh-context reviewer (PR #50)

Reviewer verdict: APPROVE_WITH_MINOR (0 blocking, 9 nits — 6 in-PR, 3 outside-PR).
Folding in the 6 in-PR nits here; the 3 outside-PR ones (audit-memory chronology,
audit-memory D80/D81 mapping, ALIGNMENT.md cross-ref to Amendment 8) are deferred.

Folded-in nits:

1. ADR 0002 Amendment 8: added a 5th "does NOT permit" bullet making per-endpoint
   containment explicit. Amendment 8 permits the kind of call; ADR 0013 Rule 2
   enumerates which specific endpoint. Re-opening per-endpoint scope requires an
   ADR 0013 amendment, not a Amendment-8-only interpretation.

2. ADR 0013 Rule 5: added "Path A prerequisites" paragraph documenting that
   strings (GNU/BSD binutils/coreutils) + Claude Code v2.1.x install are required
   for compiled-binary verification. Windows reviewers need WSL or binutils-mingw.

3. ADR 0013 Rule 5: added "Trigger for re-running the diff" paragraph naming three
   explicit hooks for major-version-bump detection: Annual Alignment Audit
   (14 May), olp doctor anthropic.quota_probe_reachable failure, manual
   maintainer attention. Documented graceful-degradation failure mode.

4. ADR 0012 D80 estimate: 1.5d → 2d. Reviewer flagged 1.5d as optimistic
   compared to D61-D63 (2.5d for narrower SSE heartbeat scope). Aligning.

5. ADR 0012 D84: moved Mistral GO/NO-GO spike to D79 close (before D80 starts),
   not mid-phase. Reduces mid-phase scope drift risk. Outcome will be amended
   into this charter as a D79-close amendment.

6. ADR 0012 Authority + cross-references: replaced "Claude Code <version> §
   OAuth bearer + ratelimit headers" with "compiled-binary strings evidence
   per audit memory § Path A". Claude Code v2.1.x has no traditional section
   structure because it is a Mach-O / ELF compiled binary.

Deferred (outside-PR) nits documented in PR review thread:
- Audit memory historical-table chronology error (cb6c2a8 placed last; was
  second chronologically — narrative arc still holds, dates need correction).
- Audit memory D80/D81 mapping mismatch (memory says D81 adds new fields;
  ADR 0012 says D80 parses all 13).
- ALIGNMENT.md cross-reference to Amendment 8 (Class-specific Exceptions
  subsection should name Amendment 8 explicitly).

All three outside-PR items are docs-only and not load-bearing for D80
implementation. Will fold in either at D80 commit (audit-memory updates)
or as a tiny constitutional cleanup PR (ALIGNMENT.md cross-ref).

Iron Rule 10: reviewer was a fresh-context opus subagent; their full review
is recorded in PR #50 thread.

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-26 16:30:09 +10:00
704d4fc8a0 fix+docs+release(v0.4.4): D78 — olp-connect stale strings + CDN-safe README URL + pre-publish audit (#49)
* fix+docs+release(v0.4.4): D78 — olp-connect stale strings + README CDN-safe tag URL + repo public-flip pre-audit

Patch release on top of v0.4.3. Three small issues caught when running
olp-connect for real on MacBook (D77 client-install verification):

## G11: repo visibility flip

Repo dtzp555-max/olp flipped PRIVATE -> PUBLIC during this session.
Closes the original G11 finding (anonymous curl can't fetch raw URL from
private repos). Pre-publish audit (per cc-rules pre-publish-audit.md
checklist):
- Identity scrub: 0 hits — no taodeng, no 老大, no /Users/.../ paths,
  no personal hostnames, no personal emails, no real LAN IPs (only RFC
  documentation placeholders 192.168.1.10 + 10.0.0.5)
- Credential scrub: gitleaks 'no leaks found' — all olp_ matches are
  placeholder (olp_XXXX...) or test fixtures (olp_not-a-real-key-...)
- Git history: maintainer accepted Option A (GitHub-account email already
  verified-public on profile; flip exposes nothing new)

## G11 mitigation: README curl URL CDN-cache-safe

GitHub raw CDN serves a stale 404 for /main/<file> for ~5-15min after a
private->public visibility flip (negative-cache TTL). Tag-pinned URLs
bypass this because the tag ref was never queried while private.

D78 makes README's primary olp-connect curl URL tag-pinned:
  bash <(curl -fsSL .../v0.4.4/bin/olp-connect) <ip>
with /main/ listed as alternative for trusted-head users.

## G12: detect_openclaw claimed plugin not shipped

bin/olp-connect's OpenClaw detection block said "The OpenClaw OLP plugin
(D71-D73) is NOT YET SHIPPED" — but D71-D73 shipped olp-plugin/ at
v0.4.0. Replaces stale text with real install instructions:

  git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo
  openclaw plugins install /tmp/olp-repo/olp-plugin
  # or symlink: ln -sf .../olp-plugin ~/.openclaw/extensions/olp

Points at docs/integrations/openclaw.md for the full setup with
dedicated bot apiKey + restart-gateway notes.

## G13: olp-connect self-version hardcoded literal

Pre-D78 the script declared OLP_CONNECT_VERSION="0.4.0-phase4" as a
hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3.
D78 derives the version at runtime from sibling package.json via
python3. When invoked from a checked-out repo, version resolves to the
actual value; when curl-piped (no on-disk package.json next to script),
falls back to "unknown".

  bash bin/olp-connect --version  # -> olp-connect 0.4.4 (automatic)

## Test count

717 (v0.4.3) -> 720 (v0.4.4). +3 D78 regression tests in Suite 36:
- 36v: pins absence of NOT YET SHIPPED text + presence of real install path
- 36w: pins runtime version derivation from package.json
- 36x: pins README tag-pinned URL recommendation

## Authority

- D77 MacBook client-install verification session (2026-05-26)
- ~/.cc-rules/docs/guides/pre-publish-audit.md (the checklist that
  preceded the visibility flip)
- Process learning: every README that includes a `curl raw-URL | bash`
  install pattern should pin to a release tag (not /main/) for CDN-
  cache resilience.

## Out of D78 scope (deferred)

- F6 (doctor client-side limitation) — Phase 5 ADR amendment
- D75 reviewer P2-1 (ADR 0004 per-hop schema) + P2-2 (defensive type
  assert) — non-blocking
- scripts/migrate-from-ocp.mjs — Phase 7

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

* fix: D78 reviewer P2 fold-in — _resolve_version defensive guards (require /bin suffix + env-var path passthrough + nounset default)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 14:21:20 +10:00
6605b7b14a feat+docs+release(v0.4.3): D76 — README install-path overhaul + OLP_BIND env + AI-install prompt + ADR 0011 amendment (#48)
10 README install gaps catalogued and fixed in one D-day after v0.4.2's
PI231 E2E session exposed that the v0.4.0-v0.4.2 README Quick Start was
fictional (npm package isn't published; olp setup/start commands don't
exist). F5 (OLP_BIND env) ships in the same patch so the documented LAN
onboarding flow actually works. AI-driven install prompt added per Phase 4
charter brainstorm Top-5 inheritance candidate #2 (D64-D67 built the
doctor framework; D76 closes the README half).

## G1-G7: README Quick Start now real

Rewrote § "Manual install" from placeholder text to the empirically-verified
sequence:
- Prerequisites (Node >= 18 + provider CLI install matrix)
- git clone + npm test verify
- olp-keys keygen --owner FIRST (allow_anonymous=false default needs a key)
- Per-provider OAuth (claude setup-token / codex login --device-auth /
  MISTRAL_API_KEY)
- ~/.olp/config.json with the minimum that actually serves traffic
- npm start
- Smoke-test via curl + olp doctor
- IDE pointing

## G8 / F5: OLP_BIND env shipped

server.mjs:
- const BIND = process.env.OLP_BIND ?? '127.0.0.1' (safe default unchanged)
- server.listen(PORT, BIND, ...) replaces hard-coded '127.0.0.1'
- New startup warn anonymous_key_advertised_with_lan_bind fires when
  OLP_BIND is non-loopback AND auth.advertise_anonymous_key: true
  (operator visibility into trust-context overlap)

Pre-D76 the server only accepted loopback connections, so the documented
olp-connect <ip> family-onboarding flow was unreachable from LAN without
SSH tunneling. F5 makes ADR 0011 operational instead of aspirational.

## G10: AI-driven install prompt

README § "Install with your AI (the fast path)" — verbatim prompt the
operator pastes into Claude Code / Cursor / Copilot / Aider. The AI
follows README + uses olp doctor --json next_action.ai_executable[] for
self-repair, stopping only when human_required[] is non-empty (provider
OAuth dances). Closes the Phase 4 brainstorm #2 inheritance candidate.

## Opening compressed

§ "Why OLP" (3 paragraphs of Anthropic 2026-06-15 billing history)
removed from the top. The OCP-trigger context moved to § "Migration
from OCP" at the bottom, condensed into a single paragraph. New users
land on value-prop + § "What you get" + install paths without needing
to digest 2026-05-14 billing history first. OCP users get a one-line
pointer at the top.

## Configuration + Env Variables sections updated

- § Configuration: placeholder replaced with full ~/.olp/config.json
  schema documentation, every field cross-referenced to its ADR
- § Environment Variables: added OLP_BIND, OLP_API_KEY, OLP_OWNER_TOKEN,
  OLP_PROXY_URL rows that were used in the manual-install flow but
  previously undocumented

## ADR 0011 § Deployment configurations amendment

Codifies the three deployment trust contexts:
- 127.0.0.1 (loopback only) — safe with any auth posture
- RFC1918 / tailnet / specific LAN IP — anonymous_key OK (the documented
  trusted-LAN zero-config family onboarding flow)
- Public IP — incompatible with advertise_anonymous_key: true

Documents the new anonymous_key_advertised_with_lan_bind startup warn
event. Closes ADR 0011's pre-D76 dangling reference to a non-existent
BIND_ADDRESS concept.

## Test count

714 (v0.4.2) -> 717 (v0.4.3). +3 D76 regression tests in Suite 36
(36s/36t/36u) pinning OLP_BIND wiring + safety warn + ADR amendment.

## Process learning

Every D-day reviewer rubric should add "open README §-Quick-Start and
verify the commands literally exist + work in the current repo" — would
have caught G1-G7 at v0.4.0 close. Combined with D74 (review-against-spec)
+ D75 (review-without-deployment), D76 (review-without-following-README)
codifies the third tier of review discipline.

## Out of D76 scope (deferred)

- F6 (doctor client-side vs server-side check separation) — needs design
  ADR for --remote mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive
  typeof hopModel === 'string') — both genuine follow-ups, neither
  blocking.
- scripts/migrate-from-ocp.mjs — Phase 7.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:32:02 +10:00
6edf6e0b94 fix+release(v0.4.2): D75 — codex CLI v0.133.0 schema + per-hop model override (#47)
Patch release fixing 5 bugs caught by real-machine E2E testing on PI231 +
Mac mini (2026-05-26 session). Prior D-day reviewers + the post-v0.4.0
maintainer review all missed these because they reviewed against spec text
and against the local OLP install's cached codex CLI shape, not against a
fresh `npm install -g @openai/codex` on a remote operator host getting
v0.133.0 for the first time.

F1 — codex auth.json schema pin (lib/providers/codex.mjs readAuthArtifact)
  Real codex CLI v0.133.0 nests the access token under `tokens.access_token`,
  not at top-level access_token / token / accessToken. Pre-D75 readAuthArtifact
  returned null → OLP reported "auth artifact missing" even for fully
  logged-in users. Fix: prepend creds?.tokens?.access_token to the precedence
  chain at both override + default branches. Legacy fields preserved as
  fallback. Authority: codex CLI v0.133.0 on-disk auth.json shape verified
  empirically on PI231 2026-05-26 E2E session.

F2 — codex spawn args + --skip-git-repo-check (lib/providers/codex.mjs irToCodex)
  codex CLI v0.133.0 trusted-directory sandbox refuses with "Not inside a
  trusted directory" outside git repos. OLP deploys typically outside a git
  repo. Fix: add '--skip-git-repo-check' to args before '--model'. Authority:
  codex CLI v0.133.0 reference (`codex exec --help` documents the flag).

F3 — codex NDJSON event shape pin (lib/providers/codex.mjs codexChunkToIR)
  Real v0.133.0 stream: thread.started → turn.started → item.completed
  (item.type='agent_message', item.text=<response>) → turn.completed.
  D6 defensive parser only recognised top-level content/delta/text +
  type:'stop'/done:true → every chunk silently dropped → response body had
  content: null. Fix: add three new recognisers (item.completed → delta;
  turn.completed → stop; turn.failed → error) before the legacy fallback
  chain. Legacy recognisers preserved for backward/forward compat.

F4 — `olp status` reads body.stats.cache.size, not body.cache.entries
  (bin/olp.mjs cmdStatus). Server payload nests stats under stats.cache;
  CacheStore.stats() exposes {hits, misses, size, inflightCount} — there is
  no `entries` field. D74 P2-3 fixed cmdUsage + cmdCache for the same bug
  class but missed cmdStatus.

F7 — per-hop chain `model` overrides IR model in provider.spawn()
  (server.mjs executeHopFn + streaming sourceFactory). Pre-D75 executeHopFn
  used hopModel for cache key + audit ctx but passed the original irReq
  (with irReq.model = user's request) to provider.spawn(). Chain config
  [{anthropic, claude-X}, {openai, gpt-5.5}] would always spawn BOTH plugins
  with --model claude-X — openai rejected the unknown model and the chain
  died. This broke the core OLP value prop (cross-provider fallback with
  provider-appropriate model substitution). Fix: build per-hop IR variant
  with { ...irReq, model: hopModel } and pass to spawn. Conditional skips
  clone when hopModel === irReq.model. Applied to BOTH buffered path AND
  streaming path. Authority: ADR 0004 § Chain advancement step 1 (per-hop
  config supplies provider AND model — contract always specified, code
  didn't complete it).

Out of scope (deferred to Phase 5):
- F5 (server bind / OLP_BIND env) — needs anonymous-key trust review
- F6 (doctor client-vs-server-side limit) — needs trigger-taxonomy ADR

Test count: 704 → 714 (+10 Suite 36 D75 regression tests: 36i–36r).
Files touched: lib/providers/codex.mjs, bin/olp.mjs, server.mjs,
test-features.mjs, package.json, CHANGELOG.md.

Phase 5 process learning: every provider plugin D-day must include a
real-CLI E2E on a remote operator host before merging — not on the
maintainer workstation (which may have an older CLI cached from a prior
install). D6/D7 codex E2E was deferred and that deferral compounded across
3 layers. F7 reinforces a separate lesson: when a function signature takes
(provider, model, ir), reviewers must check that `model` is consumed
everywhere downstream — not just at the call site they happened to look at.

Authority: ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback
engine — per-hop model contract), lib/providers/codex.mjs D6 assumption
A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex
CLI v0.133.0 on-disk schema + `codex exec --help` output verified
empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律
evidence-over-should-work; CLAUDE.md release_kit.phase_rolling_mode
cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump
patch, tag, release before next push").

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:50:41 +10:00
f3716a19fd fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — 5 maintainer-review findings (#46)
* fix+release(v0.4.1): D74 post-Phase-4 hotfix batch — maintainer-review findings

Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent
review (main / v0.4.0 / commit ee4d945). All five are real runtime bugs
the per-D-day fresh-context opus reviewers missed because they checked
spec text instead of runtime contracts (default auth.allow_anonymous:
false, real /health payload shape, real /cache/stats payload shape, real
/v0/management/dashboard-data payload shape).

Phase 4 process learning: every implementation D-day MUST include at
least one test that boots the server with default production config and
exercises the new feature end-to-end. D74 Suite 36 pins the wire-
contract shapes so a future D-day refactor can't silently re-break the
CLI / plugin / docs.

## P1-1 — olp doctor false-negative on auth-required /health

lib/doctor.mjs: buildBuiltinChecks() accepts opts.authHeaders and passes
to httpGet for both server.running + server.version probes. The
server.running check now distinguishes 401/403 ("server up, bearer token
missing or invalid — set OLP_API_KEY") from "server unreachable" so the
kind discriminator routes to a clean fix-auth path instead of fix_server
when operator just forgot to export the env var.

bin/olp.mjs cmdDoctor: threads authHeaders() through to runDoctor.

## P1-2 — olp-connect token validation + shell-quoting

bin/olp-connect: validate_olp_token() enforces ^olp_[A-Za-z0-9_-]{43}$
(per ADR 0007 § 3 token format) at THREE input sites: --key arg,
/health.anonymousKey server-advertised consumption, interactive prompt
fallback. shell_quote() POSIX-single-quote-wraps with embedded-quote
escape per:
  foo'bar  →  'foo'\''bar'
Applied to all rc-file writes + dry-run output. systemd
environment.d/olp.conf write additionally rejects embedded newlines.
Hostile or malformed keys can no longer persist as shell startup
injection.

## P2-3 — olp usage + olp cache human formatter wire-contract fix

bin/olp.mjs cmdUsage previously read body.usage_24h.requests /
body.providers / body.top_fallback_chains — none of which exist in the
real server payload (server.mjs:2027 + lib/audit-query.mjs). Users saw
"requests: ?" + missing per-provider quota + missing top-chains. Now
reads body.window_24h.request_count / body.cache_hit_24h.hit_rate /
body.quota / body.top_fallback_chains_24h.

bin/olp.mjs cmdCache previously read body.entries / body.bytes /
body.maxBytes (OCP-era field names). Real CacheStore.stats() returns
{hits, misses, size, inflightCount}. Now reads body.size /
body.inflightCount + computes hit rate from hits/(hits+misses).

## P2-4 — olp-plugin/ fmtHealth iterates providers.status

olp-plugin/index.js: previously walked Object.entries(body.providers)
which surfaced `enabled` / `available` / `status` as pseudo-providers
(chat showed "🟢 status" instead of "🟢 anthropic"). Now extracts the
real provider map from body.providers.status, renders enabled/available
counts in a header line, lists per-provider names with activeSpawns when
present. Falls back to flat body.providers.* for older OCP shape
(backwards compat).

## P3-5 — stale v0.3.0-era doc strings updated

README.md: header status line + Implementation Status § now reflect
v0.4.0 shipped + Phase 5 open. Known-limitations Phase 3 line moved out
of "pending v0.3.0" state; new Phase 4 line added with full deliverable
list.

server.mjs: startup banner no longer hardcodes "Phase 1 in progress"
(now just lists version + provider count). Banner derives state from
VERSION so future Phase boundaries don't need touch-ups here.

## Test count

696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36:

- 36a: runDoctor accepts authHeaders + threads to checks
- 36b: server.running distinguishes 401 (auth) from "server down"
- 36c: olp-connect rejects malformed --key (validator fires before rc write)
- 36d: olp-connect accepts properly-formed olp_ token
- 36e: CacheStore.stats() shape pin + cmdCache source pin
- 36f: dashboard-data payload shape pin + cmdUsage source pin
- 36g: olp-plugin fmtHealth iterates providers.status not providers.*
- 36h: server.mjs banner doesn't hardcode stale phase

## Authority

- Maintainer independent review of main / v0.4.0 / commit ee4d945
  (2026-05-26 session — 5 findings P1×2 + P2×2 + P3×1)
- Iron Rule 第二律 (evidence over "should work") — runtime smoke
  against default production config now mandatory per Suite 36 pattern
- CLAUDE.md release_kit.phase_rolling_mode cross-Phase discipline
  ("hotfix to a shipped Phase N deliverable → bump patch, tag, release
  before next push")
- ADR 0007 § 3 (token format ^olp_[A-Za-z0-9_-]{43}$) — D74 P1-2
  validator authority

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

* fix: Suite 36 paths use import.meta.dirname for CI portability (was hardcoded /Users/taodeng/olp/)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:43:12 +10:00
ee4d9459aa release(phase-4-close): v0.4.0 — Operator + Client UX (D60 → D73) (#45)
Closes Phase 4. Maintainer triggered the close per CLAUDE.md
release_kit.phase_rolling_mode.phase_close_trigger ("explicit maintainer
action — not automated") this session, 2026-05-26.

## Scope

5 D-day groups, ~13 D-days, all merged to main with fresh-context opus
reviewer per Iron Rule 10:

- D60   (PR #40) — Phase 4 charter (ADR 0010) + default port 3456→4567
- D61-D63 (PR #41) — SSE heartbeat + recentErrors[20] + /v0/management/status
- D64-D67 (PR #42) — olp Node CLI + olp doctor + ADR 0002 Amendment 7
- D68-D70 (PR #43) — olp-connect + /health.anonymousKey + ADR 0011
- D71-D73 (PR #44) — olp-plugin/ Telegram+Discord + 6 IDE docs + README

Test count arc: 623 (v0.3.2) → 696 (v0.4.0). +73 tests.

## Strategic decision recorded in ADR 0010

Phase 4 explicitly DEFERS /v1/messages (Anthropic-shape entry surface).
Re-open strictly gated on ADR 0009 P0 success AND maintainer-named
family CC user. README posture: Claude Code listed as NOT supported as
an OLP client; recommended alternative "Cline + OLP" (same fallback
chain available, better cross-provider compatibility because OpenAI
tool schema is the multi-provider lingua franca; Anthropic tool_use /
cache_control / computer_use / thinking blocks lack clean cross-provider
mapping).

## What this commit actually changes

- package.json: 0.3.2 → 0.4.0
- CHANGELOG.md: Unreleased promoted to "## v0.4.0 — 2026-05-26" with
  full D60-D73 entries (test counts, authority chains, reviewer P2
  fold-ins documented). New Unreleased: "(empty — Phase 5 entries land
  here once Phase 5 opens)"
- CLAUDE.md release_kit.phase_rolling_mode:
    current_phase: Phase 4 → Phase 5
    current_pre_release_identifier: "0.4.0-phase4" → "0.5.0-phase5"

## Phase 4 close checklist (per ADR 0010 § Exit gate)

- [x] All 5 D-day groups landed on main with reviewer APPROVE
- [x] CI green on every D-day merge + this release commit head
- [x] package.json bumped 0.3.2 → 0.4.0
- [x] CHANGELOG Unreleased promoted to v0.4.0 — 2026-05-26
- [x] CLAUDE.md release_kit phase_rolling_mode advanced
- [x] README § IDE Setup + § Telegram/Discord Usage + § Operator CLI surfaces
- [x] ADR 0010 + ADR 0011 + ADR 0002 Amendment 7 on disk
- [ ] Tag v0.4.0 pushed (next lifecycle step)
- [ ] release.yml triggers + GitHub Release auto-created on tag push

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:52:02 +10:00
53afea47ca feat+test+docs: D71+D72+D73 — olp-plugin/ (OpenClaw /olp Telegram+Discord) + docs/integrations/*.md + README cross-refs (#44)
Final Phase 4 substantive D-day group. 3 D-days bundled per Iron Rule 11
IDR (plugin consumes existing endpoints; integration docs reference plugin
+ olp CLI + olp-connect together; README index links all).

After this PR merges, Phase 4 has shipped all 5 D-day groups (D60 charter
+ port / D61-D63 SSE heartbeat+ring+/status / D64-D67 olp CLI+doctor /
D68-D70 olp-connect+anonymous-key+ADR0011 / D71-D73 plugin+docs). The
v0.4.0 close PR is maintainer-triggered per CLAUDE.md release_kit overlay.

## D71 — olp-plugin/ (OpenClaw gateway plugin)

Port OCP ocp-plugin/index.js (311 lines) → OLP olp-plugin/index.js (482
lines) as the /olp Telegram+Discord slash command, but MINUS mutations
(no /olp keys keygen, no /olp keys revoke, no /olp restart, no /olp logs
— all of these require SSH out of chat for security).

Plugin shape:
- olp-plugin/index.js — registers /olp command via OpenClaw api.registerCommand
- olp-plugin/openclaw.plugin.json — manifest, apiKey REQUIRED, proxyUrl
  default http://127.0.0.1:4567 (matches D60)
- olp-plugin/package.json — minimal: name/version/type:module + OpenClaw
  discovery block
- olp-plugin/README.md — install + configure + use docs; documents the
  "no mutations from chat" security stance and the dedicated-bot-key
  pattern (don't share maintainer's personal key with the bot)

Subcommand parity with olp CLI (D64-D67):
- /olp status   → GET /v0/management/status         (owner-only)
- /olp health   → GET /health                       (public-ok)
- /olp usage    → GET /v0/management/dashboard-data (owner-only)
- /olp models   → GET /v1/models                    (public-ok)
- /olp cache    → GET /cache/stats                  (owner-only)
- /olp providers → local cross-ref                  (public-ok)
- /olp chain show [<model>] → local                 (public-ok, advisory
  if no FS access — defer to ssh + olp chain show)
- /olp doctor   → informational (HTTP doctor endpoint deferred; advisory
  to ssh + olp doctor for live use)
- /olp help     → usage text

Port resolution: OLP_PROXY_URL env → OLP_PORT env → plugin config
proxyUrl → http://127.0.0.1:4567. Output: Telegram/Discord monospace
code block with status icons (🟢🟡🔴). Long responses truncated for the
4096-char message limit.

No npm deps. OpenClaw provides Telegram/Discord transport; plugin uses
fetch + node builtins only.

## D72 — docs/integrations/*.md (6 IDE pages + index)

Per the Phase 4 brainstorm prior-art survey + ADR 0010 § Out-of-scope
posture for Claude Code:

- continue.md    — config.yaml (NOT config.json); apiBase; requestOptions.headers
- cline.md       — "OpenAI Compatible" provider; Cline #7128 base-URL UI bug warning
- cursor.md     ⚠️  — known base-URL fragility; only enable models OLP serves
- aider.md       — OPENAI_API_BASE env + openai/ prefix; .env support
- claude-code.md  — explicitly NOT supported per ADR 0010 § /v1/messages defer
                     rationale; recommended alternative: Cline + OLP
- openclaw.md    — install olp-plugin via CLI or symlink; configure apiKey;
                    restart gateway

Each ~60-120 lines: status / quick setup / known issues / OLP-specific
notes / test-it command. docs/integrations/README.md is the index.

## D73 — README cross-references

- New § "IDE Setup" links to docs/integrations/README.md
- New § "Telegram / Discord Usage" — install + configure + restart + use
- Quick Start mentions olp-connect <ip> as family-onboarding command
- package.json `files` field extended to include olp-plugin/ so the
  published tarball ships the plugin

## Test count

672 → 696 (+24 D71-D73 tests in Suite 35: helpers / formatters /
dispatch / error paths). All 696 pass locally.

## Scope discipline

- server.mjs UNTOUCHED (plugin consumes EXISTING endpoints)
- No new npm deps (no Telegram or Discord SDK — OpenClaw provides transport)
- No /v1/messages (out of Phase 4 per ADR 0010)
- No CHANGELOG / package.json version bump (Phase 4 close handles versioning;
  only package.json `files` extended for olp-plugin/ publication)

## Implementor flagged for reviewer

1. /olp doctor returns SSH advisory (no HTTP doctor endpoint yet). When
   future phase exposes /v0/management/doctor, swap advisory branch for
   real fetchJSON + fmtDoctor (already implemented + tested).
2. /olp providers + chain show have no FS access (plugin runs in OpenClaw
   gateway process); registry read via lazy-imported models-registry.json
   from repo root. For live enabled-state visibility users still need
   /olp status (owner-tier) or ssh + olp providers / olp chain show.
3. No live-server wire test in Suite 35 — existing Suites 31/32 already
   cover the integration path against the same endpoints; mock-fetch in
   Suite 35 is sufficient signal for the plugin layer.

## Authority

- ADR 0010 § Phase 4 D-day plan D71-D73 line
- OCP ocp-plugin/index.js (port reference)
- ADR 0010 § Out-of-Phase-4-scope (claude-code.md  rationale)
- 2026-05-26 brainstorm (Top OCP inheritance candidates + prior-art
  survey IDE-specific quirks for cline/cursor/continue docs)

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:35:23 +10:00
0bdecd1235 feat+test+docs: D68-D70 — bin/olp-connect + /health.anonymousKey + ADR 0011 (#43)
* feat+test+docs: D68+D69+D70 — bin/olp-connect + /health.anonymousKey + ADR 0011

Third substantive Phase 4 implementation. 3 D-days bundled per Iron Rule
11 IDR — olp-connect consumes /health.anonymousKey for zero-config
client setup, both governed by ADR 0011's trusted-LAN-only invariant.

## D68 — bin/olp-connect (zero-config client setup)

Ports OCP ocp-connect (721 lines) → OLP olp-connect (564 lines, pure bash).
Bash over Node (per ADR 0010 § Notes) because client machines may lack
recent Node; bash + curl + python3 = max portability.

CLI: `olp-connect <host-ip> [--port PORT] [--key API_KEY] [--no-system-env]
                            [--dry-run] [--help] [--version]`

Workflow:
1. Connectivity probe (curl /health, 5s timeout, distinguishes TCP
   unreachable from auth-required)
2. Auth resolution: --key flag → /health.anonymousKey (D69) → interactive
   prompt fallback
3. Smoke test (GET /v1/models with bearer)
4. IDE detection + per-IDE config:
   - Claude Code: detect + warn (NOT supported as OLP client per ADR 0010)
   - Cline: detect + print manual VSCode-settings snippet
   - Continue.dev: detect (extension OR ~/.continue/config.yaml) + write
     idempotent models: entry
   - Cursor: detect + print snippet + WARNING (per prior-art known
     base-URL fragility)
   - Aider: detect + write OPENAI_API_BASE + OPENAI_API_KEY to rc files
   - OpenClaw: detect + print "install /olp plugin (D71-D73 deliverable)"
5. System-level env: macOS launchctl setenv / Linux ~/.config/environment.d
   (so VSCode/Cursor started via Dock inherit)
6. Summary + test command

Idempotent (bracketed `# OLP LAN (added by olp-connect)` ... `# /OLP LAN`
blocks in rc files). --dry-run exercises every state-change site without
modifying anything. Exit 0/1/2 conventions.

Installed via package.json bin so `npx olp-connect` works.

## D69 — /health.anonymousKey + auth.advertise_anonymous_key

server.mjs handleHealth emits OPTIONAL `anonymousKey: "olp_..."` field
when ALL THREE prerequisites hold:
1. config.json auth.advertise_anonymous_key === true
2. config.json auth.allow_anonymous === true (per ADR 0007 § 7)
3. At least one non-revoked key has plaintext_advertise field set

Default-off: field is ABSENT (not null) — preserves v0.3.x /health shape;
existing tests don't regress.

bin/olp-keys.mjs new flags: `keygen --anonymous --advertise` writes the
plaintext into the manifest's optional `plaintext_advertise` field AND
prints a WARNING about disk-storage + /health exposure + ADR 0011
pointer. Owner-tier --advertise rejected at BOTH CLI + lib layers.

Implementation note: reused existing guest tier (no new owner_tier:
'anonymous'); plaintext_advertise is a forward-compat optional manifest
field per ADR 0007 § 4 unknown-fields-allowed convention. Cleaner than
introducing a new tier.

anonymousKey appears in BOTH trimmed AND full /health payloads — the
trimmed payload's purpose is to be readable by anonymous clients so they
can self-bootstrap. Tested.

Startup warns on prereq failure (anonymous_key_advertised_but_denied /
anonymous_key_advertised_but_no_anonymous_key_exists) so the relaxed-
posture failure mode is observable. Graceful-degrade: server still
boots; handleHealth re-checks at request time and silently omits the
field when any prereq fails (defense-in-depth).

## D70 — ADR 0011 (anonymous-key deployment-context limits)

New ADR codifying the trusted-LAN-only invariant.

Trade-off documented: anonymous key advertised via /health = anyone who
can reach the server can read /health and use the key. Acceptable ONLY
when "anyone who can reach the server" ≈ "trusted family on the LAN".
Public-internet deployment = instant compromise.

Soft enforcement: server logs startup warn if BIND_ADDRESS resolves to
a public IP AND advertise_anonymous_key: true. No hard allowlist (TLS-
fronted private networks indistinguishable from public from server's
perspective).

Re-evaluation trigger: any time OLP gains "expose to public internet"
deployment mode (e.g., Cloudflare Tunnel guidance in README), revisit.

References ADR 0007 § 7 (identity classes), ADR 0010 § Phase 4 charter
D68-D70 line, OCP server.mjs:148/1454/1488/1555 (PROXY_ANONYMOUS_KEY
reference).

## Test count

658 → 672 (+14 D68-D70 tests across Suite 34: 5 keys.mjs unit + 6 /health
HTTP integration + 3 CLI integration).

## Scope discipline

NO /v1/messages entry surface (out of Phase 4 per ADR 0010).
NO olp-plugin/ Telegram plugin (D71-D73).
NO docs/integrations/*.md files (D71-D73).
NO CHANGELOG / package.json version bump (Phase 4 close handles versioning;
only package.json bin entry for olp-connect added).
NO new npm deps.

## Authority

- ADR 0010 § Phase 4 D-day plan D68-D70 line
- ADR 0011 (this commit — new ADR)
- ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity
  classes) + § 9 (keygen flow) — extended by D69 plaintext_advertise
- OCP ocp-connect /Users/taodeng/ocp/ocp-connect (port reference)
- OCP server.mjs:148, 1454, 1488, 1555 (PROXY_ANONYMOUS_KEY reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 3:
  /health.anonymousKey + olp-connect zero-config UX)

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

* fix: D68-D70 reviewer P1 + P2 fold-in — README impact note + listKeys redaction + schema_version note

Reviewer APPROVE WITH MINOR — 0 P0, 1 P1, 2 P2; all three folded in.

P1 — README impact note for new Phase 4 user-visible surfaces.

Per CLAUDE.md release_kit.new_feature_doc_expectations:
- new env / config knob → README § Environment Variables
- new endpoint or response field → README § API Endpoints
- new CLI surface → dedicated §

README now documents:
- /health.anonymousKey optional field (in API Endpoints table) with cross-
  ref to ADR 0011 + the three-prereq gate
- streaming.heartbeat_interval_ms config (D61) + auth.advertise_anonymous_key
  config (D69) under new "config.json keys introduced at Phase 4" subsection
- Operator CLI surfaces summary: olp / olp-connect / olp-keys keygen
  --anonymous --advertise, with cross-refs to ADR 0010 + 0002 Amendment 7

P2-1 — lib/keys.mjs listKeys() now strips plaintext_advertise alongside
token_hash. Callers wanting the advertised plaintext for the /health
publication path MUST go through findAdvertisedKey() — the only sanctioned
read site. Defends against a future caller of listKeys() leaking the
plaintext into logs / HTTP responses / dashboards. Tests still pass
(no in-repo caller of listKeys depends on plaintext_advertise being
present).

P2-2 — ADR 0011 now documents the schema_version-stays-at-1 decision
explicitly. Additive optional fields don't require bump per ADR 0007 § 4,
but a future archaeologist asking "why didn't D69 bump schema_version?"
now has a one-line answer. Same paragraph documents the listKeys()
redaction policy in plain text alongside the manifest-field contract.

672/672 tests still pass.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:15:09 +10:00
e69e908dae feat+test+docs: D64-D67 — olp Node CLI + doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 (#42)
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7

Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule
11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via
the new contract method; ADR amendment authorizes the contract change.
Single PR is the minimum reviewable unit for "does plugin amendment +
plugin impl + doctor consumer line up?"

## D64 — bin/olp.mjs Node CLI scaffold

Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with
python3 JSON parsing is a known fragile point; OLP standardizes on Node).

Subcommands:
- status / health / usage / models / cache — HTTP calls to existing endpoints
- providers — local: cross-references models-registry.json + config.json
- chain show [<model>] — local: prints routing.chains from ~/.olp/config.json
- logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query
- restart — launchctl (macOS) / systemctl --user (Linux), best-effort
- keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated)
- doctor [--check <id|category>] [--json] — D65 framework
- help / --help / -h

Token / URL resolution:
- OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default)
- OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are
  one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces
  helpful 401 message pointing at olp-keys keygen)

Output:
- Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed
  under --json)
- --json: raw JSON for scripting
- Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth

No npm deps. Built-ins only.

Installed via package.json bin entry so `npx olp <subcommand>` works.

## D65 — lib/doctor.mjs framework

Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the
OCP audit's #2 inheritance candidate). Machine-readable next_action so a
Claude Code / Cursor / etc. agent can self-repair OLP.

Check shape:
  { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }

Built-in checks: server.running, server.version, config.exists,
config.providers_enabled, config.chains_configured, auth.owner_key_exists,
system.node_version. Per-provider checks collected dynamically via
provider.doctorChecks() per D67.

--json output:
  { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install|
    fix_server|fix_provider, next_action: { ai_executable: [],
    human_required: [], verify: 'olp doctor' }, summary }

--check <id-or-category> for tight repair-loop fast paths.

## D66 — Per-provider doctorChecks() implementations

Each shipped plugin contributes its own checks (lives in plugin file so the
provider's maintainer updates it naturally):

- anthropic.mjs: cli_available (claude --version) + oauth_token_present
  (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env)
- codex.mjs: cli_available (codex --version) + auth_present
  (~/.codex/config.json)
- mistral.mjs: cli_available (vibe --version) + api_key_present
  (MISTRAL_API_KEY env OR ~/.vibe/.env)

Each fail returns evidence.fix_commands (for ai_executable[]) or
evidence.human_required (e.g., 'run: claude auth login').

## D67 — ADR 0002 Amendment 7

New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the
Provider contract. Backwards compatible — plugins without doctorChecks()
contribute no provider checks (default behavior). Validator extended in
lib/providers/base.mjs validateProvider.

## Test count

636 → 658 (+22 tests across Suites 32, 33).

- Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE,
  unknown-subcommand, providers local + --json, chain show, status via
  ephemeral server with owner token, ECONNREFUSED → exit 2,
  resolveBearerToken precedence
- Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches
  (noop / fresh_install / fix_server / fix_oauth / fix_provider),
  collectProviderChecks reads doctorChecks(), throwing plugin captured,
  --check filter, built-in checks against temp HOME, anthropic plugin
  probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units

## Scope discipline

server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints.
No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram
plugin. No IDE docs bundle. No CHANGELOG / package.json version bump
(Phase 4 close handles versioning; only package.json bin entries updated).

## Known limitations (flagged for reviewer)

- olp restart not unit-tested (would require mocking child_process.spawn
  in invasive way; manual smoke-test only at this D-day)
- olp logs --level filtering matches optional level field if present in
  audit-event objects; appendAuditEvent already populates it where
  meaningful — no schema change needed in this bundle
- olp usage panel shape inferred from lib/audit-query.mjs exports; if
  /v0/management/dashboard-data wire shape differs in subtle ways,
  formatter degrades to '?' but --json always works

## Authority

- ADR 0010 § Phase 4 D-day plan D64-D67 line
- ADR 0002 Amendment 7 (this commit — new amendment)
- OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference,
  translated to Node)
- OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs
  (framework reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2:
  olp doctor machine-readable next_action)

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

* fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat

Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in.

P2-1 — Shell-quote interpolated paths in fix_commands.

lib/doctor.mjs config.exists fix_commands previously interpolated
${olpHome} / ${configPath} unquoted into the printf template. A
malicious OLP_HOME env value containing shell metacharacters could
inject commands into the suggested-fix string an AI agent (or human)
pastes back into a terminal.

Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for
embedded single quotes per POSIX shell rules). Risk surface is narrow
at family scale (operator local env, single-user proxy), but hardening
cost is one helper.

P2-2 — Document launchctl kickstart -k env-stale pitfall.

cmdRestart header now carries an explicit caveat that `launchctl
kickstart -k` does NOT re-read the plist EnvironmentVariables block —
launchd uses cached env from the most recent bootstrap. This is a
known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The
comment documents the bootout/bootstrap dance for env reloads and
notes that the Phase 4 installer (post-D73) will expose `olp restart
--full` for the safer reload path.

658/658 tests still pass; the _shellQuote change is invisible to
existing tests because the test fixtures use safe paths.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:48:20 +10:00
e6701ff698 feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] + /v0/management/status (#41)
* feat+test: D61+D62+D63 — SSE heartbeat + recentErrors[20] ring + /v0/management/status

First substantive Phase 4 implementation. Bundle of 3 D-days per Iron Rule 11
IDR rationale: all three converge on the same observability surface (status
endpoint reads recentErrors + provider stats + heartbeat-related counters;
heartbeat shares the streaming branch with recentErrors emission; all live
in server.mjs).

## D61 — SSE heartbeat

Ported from OCP server.mjs:660-685 startHeartbeat() with the OCP db11105
"eager-headers-post-spawn" fix folded in from day one.

- New config field streaming.heartbeat_interval_ms in ~/.olp/config.json
  (default 0 = disabled, matching OCP's safe default)
- When enabled (>0), streaming branch emits `: keepalive\n\n` SSE comment
  every interval_ms ms during silent windows
- Timer resets on every real chunk written
- Cleanup on stream end / error / abort / client disconnect
- SSE_DEFAULT_HEADERS constant centralizes Content-Type / Cache-Control /
  Connection / X-Accel-Buffering: no (the last was the missing OCP lesson
  that broke long streams behind nginx 60s idle)
- Per-attached-client lifecycle (each tee output gets its own timer)
- One heartbeat_active log per stream on first fire; no per-fire log noise

Note: heartbeat NOT wired in the buffered-replay streaming branch because
that branch writes the burst synchronously into the socket buffer — no
silent windows exist there. Inline comment notes this.

## D62 — recentErrors[20] ring buffer

Module-scope bounded ring, surfaced via /v0/management/status at D63.

- _pushError({ error, provider, path, statusCode }) entry shape:
  { time (ISO8601), message (200-char cap), code, provider, path, status_code }
- Filter: only ProviderError OR statusCode >= 500 (401/403 brute-force noise
  excluded — protects ring from auth-probe flooding)
- Path sanitization via .replace(/\/[\w./-]+/g, '[path]') ported from
  OCP server.mjs:1395 — strips internal paths before they leave the proxy
- Wired into 5 server-side error paths: chain-exhausted, pre-first-chunk
  streaming error, mid-stream IR error chunk, fallback-engine programming
  error, router-level unhandled error
- In-memory only (not persisted across restart) per OCP precedent
- Test seam __clearRecentErrors / __snapshotRecentErrors

## D63 — /v0/management/status combined endpoint

OCP /status equivalent, OLP-namespaced per stricter discipline.

- New route GET /v0/management/status, owner-only_block (matches ADR 0007
  § 7 + ADR 0008 Phase 3 management endpoint gating pattern)
- Returns { ok, version, uptime_ms, uptime_human, started_at,
  providers: {enabled, available, status},
  stats: {total_requests, active_requests, cache: cacheStore.stats()},
  recent_errors: [<ring>], generated_at }
- _totalRequests + _activeRequests module-scope counters incremented at top
  of handleChatCompletions; _activeRequests decremented in res.on('close'/
  'finish') with idempotent guard
- Counters NOT exposed via /health (owner-trim intentional there); only via
  /v0/management/status (owner-only_block)
- Reuses _runOwnerOnlyManagementEndpoint helper from D50 Phase 3 work

## Test count

623 → 636 (+13 D61-D63 tests across Suites 29, 30, 31). All 636 pass locally.

## Scope discipline

server.mjs + test-features.mjs + lib/fallback/engine.mjs only (engine.mjs
touched only to extend loadFallbackConfigSync to surface the new
streaming.heartbeat_interval_ms field; no engine behavior change).

Untouched: provider plugins, IR, cache layer, dashboard.html, audit-query,
README, CHANGELOG, package.json. /health payload unchanged. None of the
existing 623 tests regressed.

## Authority

- ADR 0010 § Phase 4 D-day plan (D61-D63 line)
- OCP server.mjs:660-685 (startHeartbeat reference impl)
- OCP commit db11105 (eager-headers-post-spawn fix)
- OCP server.mjs:301, 354-358 (recentErrors ring pattern)
- OCP server.mjs:1151-1188 (/status combined endpoint pattern)
- OCP server.mjs:1395 (error path sanitization)
- ADR 0007 § 7 (identity classes — owner-only_block gating)
- ADR 0008 (management endpoints pattern reused)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, items 1 + 4)

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

* fix: D61-D63 reviewer P2 fold-in — explicit 401/403 filter + null status_code for post-headers

Reviewer APPROVE WITH MINOR — 0 P0/P1, 2 P2 (both about _pushError filter
clarity / defense-in-depth).

P2-1 — explicit 401/403 reject at function level. The current call sites
never invoke _pushError from authenticate() failures (call-site discipline),
but a future contributor passing a ProviderError tagged statusCode=401
would slip past the isProviderError branch and flood the ring under
brute force. Added explicit `if (statusCode === 401 || statusCode === 403)
return;` as defense-in-depth.

P2-2 — pass `statusCode: null` for the two streaming-error-after-first-
chunk _pushError sites instead of `statusCode: 200`. Headers are already
sent so any numeric status is misleading; null + record-by-error-code is
the explicit intent. Avoids a future filter-refactor accidentally
dropping these entries because they look like 200-OK.

Test count unchanged 636/636 pass (filter behavior identical from
call-site perspective; the changes are defensive + intent-clarifying).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:23:17 +10:00
0048481764 feat+docs: D60 — Phase 4 charter (ADR 0010) + default OLP_PORT 3456 → 4567 (#40)
* feat+docs: D60 — Phase 4 charter (ADR 0010) + default OLP_PORT 3456 → 4567

Opens Phase 4 (Operator + Client UX) end-to-end. Per release_kit.phase_rolling_mode,
the version bump fires at Phase 4 close (v0.4.0), not at this D-day; D60 only
ships governance + the port default change.

## ADR 0010 — Phase 4 Charter

Phase 4 scope = 5 D-day groups (~13 D-days total):
- D60 (this commit): charter + default port
- D61-D63: SSE heartbeat + recentErrors[20] ring + /status combined endpoint
- D64-D67: olp Node-based CLI scaffold + olp doctor next_action framework
- D68-D70: olp-connect zero-config IDE setup + /health.anonymousKey + ADR 0011
- D71-D73: olp-plugin/ OpenClaw gateway plugin + docs/integrations/*.md bundle

Charter records the EXPLICIT DECISION to DEFER /v1/messages (Anthropic-shape
entry surface) on the rationale: under ADR 0009 P0 failure it provides no
billing benefit AND degrades worse on fallback than OpenAI-shape clients
(because OpenAI tool schema is the cross-provider lingua franca; Anthropic-
specific features cache_control / computer_use / text_editor / thinking
blocks have no clean fallback mapping). Re-open trigger: (a) ADR 0009 P0
success AND (b) maintainer-named family CC user.

README posture updated: Claude Code listed as NOT SUPPORTED as an OLP
client; recommended alternative is "Cline + OLP" (same fallback chain
available, better cross-provider compatibility).

## Default port 3456 → 4567

server.mjs:74 default value moves so OLP and OCP (which stays on 3456) can
co-host on the same machine without OLP_PORT env override. Existing
deployments wanting the pre-D60 default can set OLP_PORT=3456 in launchd
plist / shell env.

Verified port-change invariants:
- All test-features.mjs suites use port: 0 (ephemeral) — 0 test-surface impact
- Cache / fallback / provider plugins port-agnostic
- Dashboard 30s poll + management endpoints use relative paths
- 623/623 tests pass on D60 branch HEAD

## ADR amendments

- ADR 0001 § "Decision" port-conflict paragraph: struck + amended (co-host
  is now possible via 3456 → 4567 + launchd labels dev.olp.proxy vs
  dev.ocp.proxy)
- ADR 0008 § 6.6 default-port reference updated
- docs/adr/README.md index gains ADR 0010 row

## Authority

- ADR 0010 (this commit)
- ADR 0009 (interactive-mode placeholder — /v1/messages defer rationale)
- 2026-05-26 brainstorm: OCP comprehensive feature audit (subagent output)
  + multi-provider proxy / IDE integration prior-art survey (subagent
  output, both this session)
- docs/v1x-roadmap.md (Phase 4 was the named destination)
- CLAUDE.md release_kit.phase_rolling_mode (current_phase already Phase 4;
  this charter formalizes contents)

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

* fix: D60 reviewer P2-1 — soften ADR 0001 launchd-label assertion to forward-tense

Reviewer flagged the ADR 0001 amendment's launchd-label collision claim
("avoided via dev.olp.proxy vs dev.ocp.proxy") as present-tense fact when
the OLP plist generator hasn't shipped yet (lands D64-D70 per ADR 0010).
Soften to forward-tense with explicit cross-reference. The factual
claim ("co-host is possible") still stands because plist label is
controlled by the OLP project anyway; this is precision, not correction.

P2-2 (README "since v0.4.0" forward-dated branding) explicitly accepted
as prior-art-consistent with D44+ Phase 2 mid-window doc conventions —
no change.

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-26 07:59:11 +10:00
ba69a3c13b release(v0.3.2): patch release — post-Phase-3 cleanup batch #2 (streaming-path singleflight + TOCTOU close, D57+D58+D59) (#39)
Patch release closing v1.x roadmap #1 end-to-end. ADR 0005 Amendment 8
§§1-14 implemented across 3 D-days (PR #36, #37, #38, all merged):

- D57 (PR #36): cache layer — cacheStore.getOrComputeStreaming(...) +
  tee fan-out + late-joiner replay + per-client backpressure +
  AbortController propagation. New STREAM_BACKPRESSURE error code (not
  a hard trigger). Suite 27 = 12 unit tests.
- D58 (PR #37): server wiring — streaming branch swap; tryAcquireSpawn
  moved into sourceFactory; X-OLP-Streaming-Inflight: source|attached
  header; cache_status: 'streaming_attached' audit value;
  audit-query gauge reconciliation. Suite 28 = 8 HTTP integration tests.
- D59 (PR #38): docs polish — README § Known limitations inverted;
  v1.x roadmap #1 closed; issue #16 closed.

Test count: 603 (v0.3.1) → 623 (v0.3.2). +20 tests across the SF arc.

Patch-release classification per release_kit.phase_rolling_mode +
maintainer release-cut decision (this session, 2026-05-25): the new
wire surface (X-OLP-Streaming-Inflight header + streaming_attached
cache_status) is semver-wise a minor bump, but this is roadmap-cleanup
work — NOT Phase 4 product scope. The reserved 0.4.0 identifier stays
for the formal Phase 4 close. v0.3.2 ships as a patch under the Phase 4
pre-release banner.

Authority: ADR 0005 Amendment 8 (design ratified at D42 2026-05-25;
implementation gated on maintainer "go" — fired 2026-05-25 post-v0.3.1).
docs/v1x-roadmap.md #1 (closed). GitHub issue #16 (closed). ADR 0002
Amendment 6 (D38 tryAcquireSpawn/releaseSpawn semantics, now invoked
from sourceFactory closure).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:10:32 +10:00
679e3b367d docs: D59 — streaming SF docs polish + v1.x roadmap #1 + #6 status updates (#38)
Third of three D-days for v1.x roadmap #1 (streaming-path singleflight +
TOCTOU close). D57 (PR #36) shipped the cache layer; D58 (PR #37) shipped
the server wiring + integration. D59 polishes docs and closes the
roadmap entry.

## README.md § Known limitations

Inverted the streaming-path-singleflight-not-implemented bullet to a 
shipped marker. New text documents:
- D4 singleflight now wired end-to-end on streaming path via
  cacheStore.getOrComputeStreaming(...).
- Two concurrent identical streaming requests share one CLI spawn via
  tee fan-out.
- Late joiners receive accumulated replay + the live tail.
- Per-client backpressure (PER_CLIENT_QUEUE_CAP=1MB) protects against
  slow consumers.
- Full-disconnect aborts source CLI via AbortController propagation.
- New X-OLP-Streaming-Inflight: source | attached header annotates role.
- New cache_status: 'streaming_attached' audit value tracks singleflight
  wins.
- Authority: ADR 0005 Amendment 8, v1.x roadmap #1.

## docs/v1x-roadmap.md

#1 entry rewritten to closed-state with:
- Three D-day breakdown (D57 cache layer + D58 server wiring + D59 docs).
- Final test count delta (603 → 623).
- Deferred sub-items NOT blocking #1 closure (solo wire-value,
  streaming_inflight_join from cache layer, isFirst unused API).

#6 entry updated to note that the implementation chose NOT to bundle
streaming SPAWN_FAILED salvage with #1 (D57 tee writes cache only on
clean source completion; SPAWN_FAILED mid-stream rejects + does not
persist). #6 now needs its own ADR amendment when triggered.

Reading-order paragraph updated to reflect that #1, #2, #4, #7 are
closed and only #3, #5, #6 remain — all trigger-gated.

## Issue #16

Closed via PR squash-merge of D57 (#36) + D58 (#37). D59 docs reflect
the closure.

## Scope

Pure docs. No code change, no test change. 623/623 still pass.

## Authority

- ADR 0005 Amendment 8 (the spec D57+D58 implemented).
- docs/v1x-roadmap.md (rewritten for #1 closure + #6 unbundling).
- GitHub issue #16 (closed at merge of D57+D58).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:03:17 +10:00
9b66326e72 feat+test: D58 — server.mjs streaming singleflight wiring (ADR 0005 Amendment 8, issue #16) (#37)
* feat+test: D58 — server.mjs streaming singleflight wiring (ADR 0005 Amendment 8 §§7,11,12, issue #16)

Second of three D-days for v1.x roadmap #1. D57 landed the cache-layer
primitive (cacheStore.getOrComputeStreaming); D58 wires it into the
streaming branch of server.mjs and adds the X-OLP-Streaming-Inflight
header. D59 closes issue #16 + polishes README known-limitations.

## What

server.mjs — replaced the streaming branch (formerly the peek+spawn
pattern at lines 1138-1327) with cacheStore.getOrComputeStreaming(...):
- tryAcquireSpawn moves INSIDE sourceFactory closure (§7). Only first
  caller acquires; attached joiners share the slot. releaseSpawn lives
  in the source generator try/finally so it fires once on source
  completion / error / abort.
- CONCURRENCY_LIMIT thrown by the factory triggers fallthrough to the
  buffered path (preserving today's behaviour); any other pre-stream
  factory error surfaces a 502.
- X-OLP-Streaming-Inflight: source | attached header per §11 (cache_hit
  role omits — X-OLP-Cache: hit already says it). The §11 'solo' value
  is deferred to a future amendment — observable only post-stream via
  the streaming_inflight_source_done log event's attached_count.
- auditCtx.cache_status: 'miss' for source, 'streaming_attached' for
  joiners, 'hit' for the TTL-race cache_hit branch.
- res.on('close', () => stream.return?.()) propagates client disconnect
  into the tee's attachedClients accounting (§9). Note: Node 25 emits
  'close' on ServerResponse, NOT on IncomingMessage — empirically
  verified in test 28g.
- Cache writes now happen inside the cache layer's tee task on source
  completion (§4). Server still issues cacheStore.delete on stop-less
  exhaustion to preserve D16 truncated-not-cached invariant — the cache
  layer is IR-agnostic and writes accumulatedChunks unconditionally;
  the IR-aware server deletes the entry if no stop chunk was observed.
- The pre-cache-store-acquire and matching releaseSpawn-on-503 branches
  are gone — they were vestigial once the factory owns acquire+release.

lib/audit.mjs — JSDoc cache_status enum extended with 'streaming_attached'.
Free-form string at the wire (no schema validator on append); the JSDoc
is the source of truth for the consumer enum.

test-features.mjs Suite 28 — 7 HTTP integration tests:
- 28a single SSE request (source role + cache populated + second request
  → X-OLP-Cache: hit)
- 28b 2 concurrent identical SSE → one spawn, source + attached roles,
  identical chunk sequences delivered
- 28c TOCTOU regression — pre-populated cache + 2 concurrent → both hit
  buffered replay path, no streaming branch entry, no
  X-OLP-Streaming-Inflight header
- 28d mid-stream join — late joiner receives accumulated burst + live tail
- 28f one-of-N disconnect — source NOT aborted; survivor completes
- 28g ALL clients disconnect → source aborted; no cache write; subsequent
  request gets fresh source spawn
- 28h CONCURRENCY_LIMIT fallthrough — factory throws at maxConcurrent=1;
  buffered path's chain-exhausted 502 surfaces

(28e backpressure deferred to Suite 27g unit-level coverage.)

## Scope

server.mjs + lib/audit.mjs + test-features.mjs. Untouched: cache/store.mjs
(D57 frozen), provider plugins, fallback engine, IR. CHANGELOG and
package.json bump fires at D59 close.

## Authority

- docs/adr/0005-cache-cross-provider.md Amendment 8 §§7, 8, 9, 11, 12
- docs/v1x-roadmap.md #1
- GitHub issue #16 (TOCTOU window; closed in D59)
- ADR 0002 Amendment 6 (D38 tryAcquireSpawn/releaseSpawn semantics that
  §7 now invokes inside the sourceFactory closure)

## Test count

615 → 622 (+7 D58 integration tests). Local: 622/622 pass.

## Iron Rule 10 follow-up notes for the reviewer

- res.on('close') vs req.on('close'): switched to res after empirical
  verification (28g fails on req under Node 25). Comment in code.
- Cache-layer write + server-layer delete for stop-less exhaustion is
  cosmetically inconsistent with streaming_inflight_source_done's
  cache_written: true log. Functionally correct; flagged for future
  amendment.
- 'solo' header value deferred — would need trailer mechanics or
  post-stream emission.

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

* fix: D58 reviewer follow-ups — P2-1 (audit-query gauge drift) + P2-2 (stop-less HTTP test)

Fold-in for D58 PR #37 fresh-context opus reviewer findings (APPROVE
WITH MINOR — 0 P0/P1, 4 P2). P2-3 (`isFirst` unused) and P2-4 (`solo`
not emitted) are design-acceptable per ADR 0005 Amendment 8 §11 and
left as-is.

P2-1 — `lib/audit-query.mjs` gauge reconciliation. `aggregateRequests`
and `cacheHitRateWindow` previously counted `streaming_attached` rows
in `total` / `pe.total` without contributing to hit/miss/bypass
numerators, breaking the invariant that the cache_status breakdown
should sum to the total. Added an explicit `streaming_attached` field
to both the global return shape and the `by_provider` shape; the
counter is excluded from `hit_rate` numerator AND denominator (joiners
did not hit a literal cache so they don't belong in either side of
the ratio). Test count is unchanged for D49 suites — they only
assert presence + non-negative + reconciliation invariants that the
new field preserves; if a test asserted exact value equality on a
fixture with NO streaming_attached rows, the new field defaults to 0
and the assertion still passes.

P2-2 — Suite 28 stop-less HTTP coverage gap. Test 28i fires an SSE
request to a fake provider whose source generator returns WITHOUT a
{type:"stop"} chunk; asserts (a) the synthetic truncation marker
appears in the body (D26 F19 in-band signal), (b) [DONE] terminator
follows, (c) a subsequent identical request triggers a fresh spawn
(cache was NOT populated by the truncated stream). Pins the D58
`cacheStore.delete` path at server.mjs:1344-1346 end-to-end.

622 → 623 (+1 D58 follow-up test). 623/623 pass locally.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:59:54 +10:00
1062e88e77 feat+test: D57 — cache layer streaming singleflight (ADR 0005 Amendment 8, issue #16) (#36)
* feat+test: D57 — cache layer streaming singleflight (ADR 0005 Amendment 8, issue #16)

First of three D-days implementing the v1.x roadmap #1 streaming-path
singleflight. D57 lands the cache-layer coordination primitive only;
server.mjs wiring is D58, docs polish is D59.

## What

lib/cache/store.mjs — new method `getOrComputeStreaming(keyId, cacheKey,
sourceFactory, opts) → { stream, isFirst, role }`. Three outcomes per
Amendment 8 §1: cache_hit (no spawn), attached (joins existing inflight),
source (first caller, spawns via factory). Backed by `_streamingInflight`
Map keyed by `${keyId}\\0${cacheKey}` with synchronous check+insert per
Amendment 8 §1 + §6 atomicity invariant.

Internals (Amendment 8 §§2-10, §14):
- StreamingInflightEntry + AttachedClient typedefs
- Tee fan-out loop: single reader drains source, pushes to accumulatedChunks
  + every client's queue, fires per-client resolveNext promises
- Late-joiner replay buffer (synchronous drain on attach; reject with
  synthetic STREAM_BACKPRESSURE terminator if drain exceeds cap)
- Per-client backpressure (PER_CLIENT_QUEUE_CAP=1MB default, overridable)
- Replay buffer cap (ACCUMULATED_REPLAY_CAP=10MB default, overridable;
  cache write skipped if exceeded)
- AbortController propagation: when attachedClients.size === 0 after
  client iterator return(), source.return() + abort.signal fire
- D38 coordination via sourceFactory closure (factory wraps tryAcquireSpawn
  internally; cache layer just invokes it once)

lib/providers/base.mjs — `'STREAM_BACKPRESSURE'` added to
PROVIDER_ERROR_CODES per Amendment 8 §8. NOT in HARD_TRIGGER_CODES (engine
update lands in D58; whitelist-only map gives correct default).

test-features.mjs Suite 27 — 12 new tests (27a-27l) covering: solo stream,
2-concurrent dedup, mid-stream join + post-completion cache_hit, per-client
disconnect with other clients continuing, full disconnect → abort, source
error propagation, per-client backpressure, replay cap, TTL race during
inflight, sourceFactory throw, stats accuracy, composite key isolation.

## Scope

Strictly cache layer + base.mjs PROVIDER_ERROR_CODES entry. Untouched:
server.mjs, providers/{anthropic,codex,mistral}.mjs, fallback/engine.mjs,
IR, dashboard.html, README, CHANGELOG, package.json. D58 will wire server.

## Authority

- docs/adr/0005-cache-cross-provider.md Amendment 8 (2026-05-25, design
  ratified at D42, implementation gated on maintainer "go" — fired
  2026-05-25 post-v0.3.1)
- docs/v1x-roadmap.md #1 (streaming SF + TOCTOU close)
- GitHub issue #16 (round-6 cold-audit F13 sibling TOCTOU window)
- ADR 0002 Amendment 6 (D38 tryAcquireSpawn/releaseSpawn — invoked via
  sourceFactory closure at server layer, not directly by cache)

## Test count

603 → 615 (+12 D57 tests). Local: 615/615 pass.

## Iron Rule 11 (IDR)

D57 is the cache-layer minimum reviewable unit. D58 wires server.mjs +
adds X-OLP-Streaming-Inflight header + integration tests through HTTP
layer. D59 polishes README + closes issue #16.

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

* fix: D57 reviewer follow-ups — P2-2 (constant cleanup) + P2-3 (join-event deferral note)

Fold-in for D57 PR #36 fresh-context opus reviewer findings (APPROVE WITH
MINOR — 0 P0/P1, 3 P2). P2-1 is the D58 split (already planned); this
commit addresses P2-2 + P2-3.

P2-2 (cosmetic) — replace `Object.freeze({ value: X }).value` baroque
declaration of PER_CLIENT_QUEUE_CAP_DEFAULT + ACCUMULATED_REPLAY_CAP_DEFAULT
with a plain `export const X = 1*1024*1024`. The freeze-then-extract pattern
freezes a throwaway wrapper, which the `.value` immediately discards — does
nothing useful. Const declaration already gives binding immutability.

P2-3 (observability event parity deferral) — ADR 0005 Amendment 8 §11
lists `streaming_inflight_join` as one of four log events. The cache
layer cannot emit it correctly because provider/model identity lives in
the sourceFactory closure (server-layer concern). Added TODO note in
`_attachClient` pointing at D58 server wiring where the event will fire
on the consumer of `role: 'attached'`. The other three §11 events
(stream_backpressure_disconnect / streaming_inflight_source_done /
streaming_inflight_abort) ARE emitted from the cache layer with
{client_id, composite_key, ...} payloads; provider/model is enriched at
the server-side wrapper.

No test-surface change. 615/615 still pass.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 20:35:03 +10:00
1661f336cd release(v0.3.1): patch release — post-Phase-3 cleanup batch #1 (D56) (#35)
Patch release closing two XS v1.x-roadmap deferrals that became actionable
once Phase 3 management endpoints existed:
- v1.x #4 — /health activeSpawns integration (ADR 0002 Amendment 6 forward note)
- v1.x #7 — AUTH_MISSING tuple test (D45 P3 deferral)

No feature surface change; D56 already merged at 5ebe3dc. This release commit
only bumps package.json 0.3.0 → 0.3.1 and promotes CHANGELOG.md Unreleased's
D56 entry to "## v0.3.1 — 2026-05-25".

Patch-release classification per release_kit.phase_rolling_mode cross-Phase
discipline: D56 landed on main after v0.3.0 was tagged, so this is a hotfix-
class patch. Bump patch, tag, release before next push. Tag push triggers
release.yml.

Test count: 601 (v0.3.0) → 603 (v0.3.1). 603/603 pass locally on release-v0.3.1
branch head.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:47:22 +10:00
5ebe3dc77c feat+test+docs: D56 — v1.x cleanup batch #1: AUTH_MISSING tuple test + /health activeSpawns (#34)
Post-Phase-3 cleanup batch #1. Bundles two small v1.x-roadmap deferrals (#4
and #7 in docs/v1x-roadmap.md) into one D-day. No new user-facing feature;
pins existing behaviour into tests + finally wires the ADR-documented
activeSpawns field on /health.

(1) AUTH_MISSING tuple test (v1.x #7, D45 P3 deferral)

New engine-level test in Suite D40 asserts that an AUTH_MISSING hop produces
a fallbackDetail tuple with trigger_type: 'auth_missing' AND that the engine
does NOT advance past it (HARD_TRIGGER_CODES[AUTH_MISSING]=false). Pre-D56 the
behaviour was implicit through neighbouring tests; D56 makes it explicit so a
future refactor that moves the tuple-push past the auth_missing branch fails
this test directly.

Authority: ADR 0004 § Decision (hard-trigger table) + Amendment 5 (tuple shape).

(2) /health activeSpawns integration (v1.x #4, ADR 0002 Amendment 6 forward
note)

handleHealth now surfaces providers.status.<name>.activeSpawns (sourced from
D38 getActiveSpawnCount(name) — already imported at server.mjs:39). The field
is computed BEFORE healthCheck() is awaited so it remains present even when
healthCheck() throws — getActiveSpawnCount is a cheap in-memory counter read.
New Suite 21c-extra test pins the field presence + numeric + non-negative for
every enabled provider in the fixture.

Authority: ADR 0002 Amendment 6 § "Forward note — exposing the counter via
HTTP" — names the path providers.status.<name>.activeSpawns and pins it as
the Phase 1 deferral that becomes due once management endpoints exist (which
they now do, post-Phase-3).

Test count: 601 → 603 (+2 D56 tests; 603/603 pass locally).

Release-kit: under Unreleased per phase_rolling_mode (Phase 3 closed at v0.3.0;
Phase 4 entries also land here once Phase 4 opens — D56 is a cleanup-D-day
that ships under v0.3.x, not a Phase 4 deliverable).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:11:36 +10:00
179b4707a7 docs: ADR 0009 — Anthropic Interactive-Mode Path Placeholder (blocked on OCP P0) (#33)
Records OLP-side decision for the cross-project work OCP started in
ADR 0007 (interactive-mode execution pool to address post-2026-06-15
Anthropic billing split).

OPTION 3 — wait + port:

  - No OLP code change to lib/providers/anthropic.mjs until OCP
    ADR 0007 P0 experiment outcome lands (>= 2026-07-15).
  - Avoid duplicating the P0 risk by running an independent OLP
    experiment against the same Anthropic billing pool.
  - If P0 confirms Transport A (stdio NDJSON) or Transport B (PTY)
    bills as subscription rather than Agent SDK credit, port the
    validated pattern to OLP at that time.
  - If both transports fail, shelve this ADR; OLP Anthropic users
    fall back to Agent SDK $100 / month credit OR shift to multi-
    provider routing (Codex / Mistral) per Phase 1 design.

DECISION TREE recorded in § 3 so a future Phase 4 brief can act
mechanically once OCP P0 lands:

  Transport A wins → Option 1 (parallel impl) likely
  Transport B wins → Option 1 with PTY adapter (node-pty native dep
                     triggers engines-bump prior PR)
  Both fail        → Shelve ADR 0009
  Unobservable     → Extend wait

IMPLEMENTATION LANES (§ 4) — informational, not selected:

  Option 1 — OLP parallel implementation in lib/providers/anthropic.mjs
  Option 2 — Chain OCP as backend (OLP → OCP → Claude)
  Option 3 — Hybrid (prefer OCP backend, fallback to local pool)

NO PHASE 4 D-DAY scheduled. This is a decision-record placeholder
ONLY. Phase 4 standing-autopilot grant ("Phase 4+ requires new
authorization") still applies. When OCP P0 lands, maintainer issues
explicit Phase 4 "go" naming this ADR to trigger implementation
selection + D-day work.

DOCUMENTATION:

  - docs/adr/0009-interactive-mode-path-placeholder.md (new, ~200 lines)
  - docs/adr/README.md index: ADR 0009 row added
  - Cross-machine cc-rules memory (~/.cc-rules/memory/learnings/
    ocp_adr_0007_interactive_mode_pool.md, committed in cc-rules
    a16b775) bridges OCP ADR 0007 + OLP ADR 0009 so future Phase 4
    planning sessions in either repo pull the shared context.

Test count: 601 → 601 (docs-only; no test or .mjs file touched).

AUTHORITY:

  - OCP ADR 0007 (~/ocp/docs/adr/0007-interactive-mode-pool.md,
    Draft 2026-05-25) — the triggering external work.
  - OLP ADR 0001 (Project Founding) — the 2026-06-15 billing-split
    motivation that this ADR reaffirms.
  - OLP ADR 0006 (Provider Inclusion) — the anthropic plugin's tier
    that this ADR would amend if/when implemented.
  - CLAUDE.md release_kit overlay phase_rolling_mode (current_phase:
    Phase 4) — this ADR explicitly does NOT consume a Phase 4 D-day.
  - Standing autopilot grant — Phase 4+ requires new authorization;
    this placeholder is a decision-tree pre-record, not Phase 4
    implementation.

ALIGNMENT.md scope check: docs-only commit (new ADR + index update).
No provider plugin / entry surface / IR change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:49:16 +10:00
b1afcde929 release(phase-3-close): v0.3.0 — Dashboard + audit query layer + daily audit rotation (D48 → D54) (#32)
Closes Phase 3. All 15 ADR 0008 § 10 acceptance criteria shipped +
tested across Suite 23/24/25/26/20h-extra-audit. 7 D-day commits
(D48 ADR + D49-D54 implementation) shipped between 2026-05-25 under
the standing-autopilot grant.

Per CLAUDE.md release_kit.phase_close_trigger this PR is the explicit
maintainer-triggered close action (user "go" to start; this commit
to ship).

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

  package.json:
    - version 0.2.0 → 0.3.0

  CLAUDE.md release_kit.phase_rolling_mode:
    - current_phase: Phase 3 → Phase 4
    - current_pre_release_identifier: "0.3.0-phase3" → "0.4.0-phase4"

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

  README.md:
    - Status header v0.2.0+v0.3.0-in-progress → v0.3.0 shipped;
      Phase 4 next.
    - Implementation status note: Phase 3 in-progress → closed at
      v0.3.0; ADR 0008 § 10 all 15 acceptance criteria shipped.
    - Phase plan Phase 3 marker 🟡 Shipped (D48 → D54).

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

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

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

ACKNOWLEDGEMENTS:

  - Phase 3 executed under maintainer's standing autopilot grant
    (~/.cc-rules/memory/auto/standing_autopilot_phase_2.md in
    cc-rules bf0ed9a); D-day cadence: 6 implementation D-days + ADR
    + multiple opus-reviewer fold-ins, all in a single session.
  - ADR 0008 was authored via D48 with fresh-context opus review
    finding 3 P-class items (1 P2 owner_only_block formalization + 2
    P3 citation/shape gap). 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 0008 (Phase 3 design contract) — § 10 acceptance criteria
    #1–#15 covered.
  - CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for
    every implementation phase + design ADR (executed on D49, D50,
    D51, D52, D53, D54, and D48 ADR draft).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:27:39 +10:00
6d9ab1f334 docs: D54 — README Phase 3 polish (docs-only) (#31)
* docs: D54 — README Phase 3 polish (docs-only, no code change)

Seventh Phase 3 D-day. Documentation polish ahead of Phase 3 close
(D55, maintainer-triggered). Brings README status header /
Implementation Status / API Endpoints / Known limitations / Phase plan
up to date with Phase 3 work shipped to main through D48-D53.

CHANGES:

  - Status header: v0.2.0 shipped → v0.2.0 shipped; v0.3.0 in progress
    + lists D48-D54 highlights.
  - Implementation status note: Phase 3 from "next milestone" → "shipped
    to main through D54; v0.3.0 release pending maintainer-triggered
    close (D55)".
  - Implementation Status table — 4 row updates:
    * lib/audit.mjs: 🟡 D45-only →  D45 append + D52 rotation
    * lib/audit-query.mjs: NEW row (D49 shipped, 5-fn aggregate query)
    * dashboard.html: 📋 Planned (Phase 6) →  D50 stub + D51 full UI
    * bin/olp-audit-rotate.mjs: NEW row (D52 shipped)
  - API Endpoints table: /cache/stats, /v0/management/quota, /dashboard
    (📋 Planned →  Phase 3 Shipped); new /v0/management/dashboard-data
    row; /health row clarified to spell out owner-only-trim semantic.
    Removed "placeholder" stub.
  - Known limitations: Phase 2 paragraph kept; new Phase 3 paragraph
    summarizing D48-D54 shipped + D55 close pending.
  - Phase plan: Phase 3 description "next" → "🟡 In progress — D48 +
    D49-D54 shipped to main 2026-05-25; v0.3.0 close awaits maintainer
    trigger." Added Phase 4+ entry covering deferred items.

NOT IN D54:

  - E2E browser smoke (manual per ADR 0008 § 10 #12; not automated at
    Phase 3)
  - Phase 3 close → v0.3.0 (D55; maintainer-triggered per CLAUDE.md
    release_kit.phase_close_trigger)

Test count: 601 → 601 (docs-only; no test or .mjs file touched).

AUTHORITY:

  - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased.
  - ADR 0008 § 13 sprint shape (D54 = "E2E + AGENTS / README polish").
  - Standing autopilot grant.

ALIGNMENT.md scope check: docs-only commit; no provider plugin / entry
surface / IR change. No ALIGNMENT.md citation requirements apply.

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

* docs: D54 fold-in — Phase 4 vs Phase 4+ bullet disambiguation (opus P3)

Fresh-context opus reviewer (PR #31) flagged stylistic duplication: two
consecutive Phase 4+ bullets in the Phase plan section. Disambiguated:

- Phase 4 (planned) — concrete deferrals from Phase 2 + Phase 3 ADRs
  (per-key per-provider auth, audit rotation/retention, SQLite hybrid,
  provider-cost weights).
- Phase 4+ (v1.x roadmap, triggered as needed) — items in docs/v1x-
  roadmap.md (streaming SF, soft triggers, etc.).

No code or test change. 601/601 still pass.

Authority: PR #31 fresh-context opus reviewer P3.

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 16:40:08 +10:00
68e50da68a fix+test+docs: D53 — tried_providers schema semantic fix (D45 P2 deferral closed) (#30)
Sixth Phase 3 D-day. Small focused fix for the D45 fresh-context opus
reviewer P2 finding that was deferred: auditCtx.tried_providers on the
key_no_provider_access 403 path was being stamped with the ORIGINAL
chain (which was filtered out, never dispatched), distorting downstream
audit queries like "which providers did key X actually call".

CHANGES:

  - server.mjs handleChatCompletions ~L815: on key_no_provider_access
    403, auditCtx.tried_providers = [] (was _originalChainProviders).
    The configured-but-blocked chain still appears in the human-
    readable error message body — the audit just doesn't claim those
    providers were "tried" when the server's filter dispatched zero.

  - docs/adr/0007-multi-key-auth.md § 8 amendment: new paragraph
    spelling out the tried_providers semantic. "The list of providers
    the server actually dispatched a spawn against. A provider that
    was configured in the chain but filtered out by providers_enabled
    gating is NOT included — the key didn't try the provider, the
    gate did. On the 403 path tried_providers is the empty array."
    Plus a forward note that audit log rotation moved to Phase 3 /
    ADR 0008 § 5.

  - test-features.mjs Suite 20h-extra-audit (+1 test — 600 → 601):
    creates guest key with providers_enabled: ['mistral']; fires
    request for Anthropic-routed model; asserts 403
    key_no_provider_access; reads audit row from audit.ndjson;
    asserts tried_providers === []. Pins the D53 semantic against
    regression.

  - CHANGELOG.md: D53 entry under Unreleased.

NOT IN D53:

  - E2E + docs polish (D54)
  - Phase 3 close → v0.3.0 (D55; maintainer-triggered)

Test count: 600 → 601 (+1). Verified locally via npm test.

AUTHORITY:

  - ADR 0007 § 8 amendment (D53, 2026-05-25).
  - D45 fresh-context opus reviewer P2 deferral note.
  - CLAUDE.md release_kit overlay phase_rolling_mode — under Unreleased.
  - Standing autopilot grant.

ALIGNMENT.md scope check: small entry-surface change (audit context
field assignment on one error path) + ADR amendment + new test. Per
ALIGNMENT.md Rule 1 the ADR amendment is the authority citation for
the server change. No provider plugin / IR / models-registry change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:32:59 +10:00
408d5a839a feat+test+docs: D52 — daily audit rotation (lib/audit.mjs + bin/olp-audit-rotate.mjs) (#29)
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to lib/audit.mjs
per ADR 0008 § 5 + ships an external cron tool. Rotation is
SYNCHRONOUS at v0.3.0 — synchronous design eliminates the race that
an async wrapper would create between date-change-detection and the
append.

lib/audit.mjs EXTENSIONS:

  - New _maybeRotateAudit({ olpHome, logEvent }) (sync): probes live
    audit.ndjson; if it holds events from past UTC date, renames it
    to audit-YYYY-MM-DD.ndjson. Idempotent. If target file exists
    (cron beat in-server check), logs warn + skips per § 5.3.

  - appendAuditEvent extended: cheap fast-path date check via module-
    cached _lastSeenUtcDate. On date change, calls _maybeRotateAudit
    synchronously BEFORE appendFileSync — so old-date events land in
    the rotated file and new-date events land in the fresh live file.
    No event straddles the boundary.

  - Why SYNCHRONOUS: an async wrapper would let the sync
    appendFileSync race the not-yet-completed renameSync, landing
    today's event in the about-to-be-renamed file. Sync rotation is
    the only correct ordering at the append-fired-from-many-routes
    scale OLP runs. (Test 26b-1 caught this during local run; the
    initial async-wrapper implementation failed because the live
    file at assertion time didn't exist.)

  - New exports: _maybeRotateAudit (sync), getAuditRotateCount,
    getAuditRotateFailCount, __resetAuditRotateState,
    __setLastSeenUtcDateForTesting.

  - First-event-date discovery: when probing the live file's date,
    reads only the first ndjson line + parses its ts. Falls back to
    file mtime if events absent (corrupt/empty edge).

bin/olp-audit-rotate.mjs (~95 lines): external cron tool per § 5.2.

  Calls _maybeRotateAudit once + reports outcome. Exit codes 0
  (success or no-op), 1 (bad usage), 2 (rotation failed). Installed
  via package.json bin so `npx olp-audit-rotate [--olp-home=<path>]`
  works. Example cron line in file header.

CONCURRENT-SAFETY (§ 5.3):

  In-process sequential appends after the first date-change detection
  short-circuit via the updated _lastSeenUtcDate cache → exactly 1
  rename even under N sequential appends. Cross-process (cron + server)
  coexistence handled by the "target already exists → skip + warn"
  branch.

TESTS — Suite 26, +12 (588 → 600):

  26a-1..5: _maybeRotateAudit (no live file / today already /
    yesterday→rotate / idempotent re-call / cron-race target-exists
    warn)
  26b-1: appendAuditEvent past UTC date change triggers sync rotation
    + append lands in fresh live file
  26c-1: 10 sequential appendAuditEvent across date change → exactly
    1 rotation + all 10 events in new live file
  26d-1..4: bin/olp-audit-rotate.mjs CLI (--help / no-live-file /
    yesterday-file-rotates / unknown-flag exit 1)
  26e-1: rotated files queryable via lib/audit-query.mjs
    discoverAuditFiles + readAuditWindow cross-file read

package.json: bin.olp-audit-rotate + scripts.olp-audit-rotate entries
added.

DOCUMENTATION:

  - AGENTS.md: lib/audit.mjs marker promoted  (D45 append + D52
    rotation both shipped); new bin/olp-audit-rotate.mjs entry.
  - CHANGELOG.md: D52 entry under Unreleased per release_kit overlay.

NOT IN D52:

  - tried_providers schema fix (D53; D45 P2 deferral)
  - E2E + docs polish (D54)
  - Phase 3 close → v0.3.0 (D55; maintainer-triggered)

Test count: 588 → 600 (+12). Verified locally via npm test.

AUTHORITY:

  - ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger).
  - ADR 0008 § 5.2 (external cron alternative).
  - ADR 0008 § 5.3 (concurrent-rotation safety + cron-coexistence).
  - ADR 0008 § 5.4 (renamed-file query path consumed by D49 lib/
    audit-query.mjs).
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Standing autopilot grant.

ALIGNMENT.md scope check: extends lib/audit.mjs (a Phase 2 internal
module) + adds new bin/ CLI + small package.json bin/scripts entries.
No provider plugin / entry surface / IR change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:27:26 +10:00
251b578114 feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3) (#28)
* feat+test+docs: D51 — dashboard.html full multi-panel UI (Phase 3)

Fourth Phase 3 D-day. Replaces the D50 dashboard.html placeholder with
the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no
build step, no framework, no CDN (Lane 1 = A). 30s page poll with
document.visibilityState pause/resume (Lane 4 = A).

4 PANELS (all rendered from /v0/management/dashboard-data — single
backing endpoint per Lane 2 in-memory query model):

  Panel 1 — Per-provider quota
    Table: { Provider | Available | Status }. Null available → "n/a"
    pill; provider.quotaStatus() error → red status pill (graceful
    degradation per ADR § 9).

  Panel 2 — Last 24h: request count + cache hit + fallback rate
    Per-provider row: { Requests | Cache hit % | Fallback rate % }.
    Cache hit from cache_hit_24h.by_provider[p].hit_rate; fallback
    rate computed from window_24h.by_provider[p].fallback_count/count.

  Panel 3 — Request count last 30 days (SVG sparkline)
    Vanilla SVG bar chart with <title> tooltips showing per-day per-
    provider breakdown. Y-axis: requests per day (max-scaled). X-axis:
    30 daily UTC buckets.

  Panel 4 — Top fallback chains (last 24h)
    Numbered table: { # | Chain (monospace, arrow-joined) | Count |
    First seen | Last seen }.

POLL + VISIBILITYCHANGE PAUSE (ADR 0008 § 6.5):

  - setInterval(refresh, 30000) after initial fetch.
  - document.addEventListener('visibilitychange') → stopPolling() on
    hidden / refresh()+startPolling() on visible.
  - Prevents 2880 background polls/day per owner when tab hidden.

ERROR HANDLING:

  - 401 from dashboard-data → in-page error banner explains owner-tier
    requirement + suggests SSH-tunnel + header-injection workaround.
  - Other HTTP errors → generic "HTTP <code>" banner; console.warn
    for operator debugging.
  - Per-panel empty states ("Loading…", "No requests in window.",
    "No fallback chains triggered.").

CRITICAL CORRECTNESS INVARIANTS (ADR 0008 § 6 + Lane 1 = A):

  - No <script src> — entire JS inline (Suite 25d asserts).
  - No <link rel="stylesheet" href> — all CSS in <style> (25d).
  - Only one backing endpoint hit: /v0/management/dashboard-data
    (Suite 25e asserts).
  - 401 path keeps panels in last-good state rather than clearing —
    operator sees the error banner + can debug.

TESTS — Suite 25, +6 (582 → 588):

  25a: owner /dashboard response contains all 4 panel container IDs
  25b: JS declares POLL_INTERVAL_MS = 30000 + setInterval/clearInterval
  25c: visibilitychange listener + document.visibilityState check
  25d: NO external script src / NO external stylesheet href (Lane 1 = A
    pinning)
  25e: dashboard JS fetches /v0/management/dashboard-data only
  25f: 401 in-page error banner mentions owner-tier guidance

MANUAL SMOKE (ADR 0008 § 10 #12):

  Dashboard renders without console errors in a real browser when
  served by a running OLP instance + owner-tier Bearer via SSH-tunnel
  + header-injection extension. Not automated at Phase 3.

DOCUMENTATION:

  - AGENTS.md: dashboard.html marker promoted 🟡 D50 placeholder → 
    D51 full UI.
  - CHANGELOG.md: D51 entry under Unreleased per release_kit overlay.

NOT IN D51:

  - Daily audit rotation (D52)
  - tried_providers schema fix (D53; D45 P2 deferral)
  - Phase 3 close (D55; v0.3.0; maintainer-triggered)

Test count: 582 → 588 (+6). Verified locally via npm test.

AUTHORITY:

  - ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll +
    visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A
    (30s poll) + Lane 5 = B (full 4-panel scope).
  - ADR 0008 § 9 (graceful degradation surfaced in Panel 1).
  - ADR 0008 § 10 #12 (HTML smoke criterion satisfied at server-side
    level).
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Standing autopilot grant.

ALIGNMENT.md scope check: this PR replaces an existing entry-surface
static file (dashboard.html). Per Rule 5: management surface, not
OpenAI-spec-compatible — outside /v1/* spec scope. No code change in
server.mjs / lib/ / providers / IR / models-registry.

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

* fix: D51 fold-in — dashboard.html cosmetic polish (opus P3)

Fresh-context opus reviewer (PR #28) flagged 4 P3 cosmetic findings;
addressing the 2 trivial ones inline. The other 2 (visibilitychange
race + defensive date null-check) are negligible at family-scale per
reviewer; deferred to Phase 4 if UX feedback warrants.

- Line ~193: deleted orphan empty <text> SVG element (no textContent,
  rendered nothing — debris from initial pass).
- Line ~200 comment: was "Date labels (first / mid / last)" but only
  first + last rendered. Tightened to clarify intent + note mid label
  deferred to Phase 4 if needed.

No behavior change. 588/588 tests pass.

Authority: PR #28 fresh-context opus reviewer P3 findings (2 of 4
addressed; remaining 2 documented as negligible).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:11:40 +10:00
f9f2eaa059 feat+test+docs: D50 — server.mjs management endpoints (Phase 3 dashboard wire-up) (#27)
* feat+test+docs: D50 — server.mjs management endpoints (Phase 3 dashboard wire-up)

Third Phase 3 D-day. Wires the D49 lib/audit-query.mjs aggregate query
layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8.
Ships a placeholder dashboard.html at repo root (D51 lands the full
multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit
+ touchLastUsed pattern.

4 NEW ENDPOINTS — all owner_only_block per ADR 0008 § 8:

  GET /dashboard
    Serves dashboard.html (text/html). D50 stub explains state +
    lists backing endpoints. D51 replaces with full UI.

  GET /v0/management/dashboard-data
    Full aggregate per § 7.2:
      { generated_at, window_24h, cache_hit_24h, quota,
        spend_trend_30d, top_fallback_chains_24h, cache_stats }

  GET /v0/management/quota
    Quota subset only (per-provider provider.quotaStatus + error
    capture per § 9 graceful degradation).

  GET /cache/stats
    Live in-memory cacheStore.stats() with generated_at wrapper.

HELPER:

  _runOwnerOnlyManagementEndpoint(req, res, method, path, inner)
    Factors common auth + audit ctx + owner-block + res.on('finish')
    wire. inner is async (req, res, olpIdentity, auditCtx) → void.
    Eliminates 4× boilerplate.

OWNER_ONLY_BLOCK MODE (ADR 0008 § 8 D48-fold-in):

  authenticate → if owner_tier !== 'owner' → 401 owner_required.
  Distinct from owner_only_trim (Phase 2 /health pattern). Anonymous
  identity (when allow_anonymous: true) REACHES the handler and is
  401'd by the owner check (Suite 24c). Allow_anonymous: false + no
  header → 401 auth_required at middleware (Suite 24d).

PROVIDER QUOTASTATUS ERROR CAPTURE:

  Dashboard-data + quota endpoints catch per-provider throws and
  surface { provider, error, available: null } so one bad provider
  doesn't fail the whole panel (ADR 0008 § 9 graceful degradation).

DASHBOARD.HTML PLACEHOLDER (~50 lines at repo root):

  Explains D50 state, lists backing endpoints with curl example.
  Cached in memory at first /dashboard request via _loadDashboardHtml
  with module-scope _dashboardHtmlCache; falls back to in-memory stub
  if file missing (defensive for test imports from non-repo cwd).

AUDIT ON MANAGEMENT ENDPOINTS (ADR 0008 § 7.5):

  Every management request appends audit row including 401 paths
  (verified by Suite 24j). Touch wire skips anonymous + env-owner
  identities (matches Phase 2 pattern).

TESTS — Suite 24, +11 (571 → 582):

  24a-d: /dashboard owner_only_block matrix (owner 200 / guest 401
    / anonymous-with-allow_anonymous=true 401 / no-auth-with-
    allow_anonymous=false 401)
  24e: dashboard-data owner → 200 JSON with all § 7.2 fields
    (asserts spend_trend_30d.length === 30)
  24f: dashboard-data guest → 401 owner_required
  24g: quota owner → 200 JSON with quota array
  24h: cache/stats owner → 200 JSON shape
  24h-401: cache/stats guest → 401
  24i: successful dashboard-data appends audit row with status 200
    + key_id + correct path
  24j: 401 (guest blocked) dashboard-data appends audit row with
    error_code: 'owner_required' + owner_tier: 'guest'

DOCUMENTATION:

  - AGENTS.md: dashboard.html new entry (D50 placeholder); lib/audit-
    query.mjs marker note unchanged.
  - CHANGELOG.md: D50 entry under Unreleased per release_kit overlay.

NOT IN D50 scope:

  - Full dashboard UI (D51 — replaces dashboard.html with the real
    4-panel layout + 30s poll JS)
  - Daily audit rotation (D52)
  - tried_providers schema fix (D53)
  - Phase 3 close (D55; v0.3.0; maintainer-triggered)

Test count: 571 → 582 (+11). Verified locally via npm test.

AUTHORITY:

  - ADR 0008 § 7 (endpoint definitions) + § 8 (owner_only_block
    mode) + § 9 (graceful degradation) + § 7.5 (audit on management
    endpoints).
  - ADR 0007 § 7 (auth model reused).
  - ADR 0002 § Provider contract (quotaStatus).
  - ADR 0005 (cacheStore.stats source of truth).
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Standing autopilot grant.

ALIGNMENT.md scope check: this PR adds 4 new entry-surface endpoints
under owner-only_block gating + a new lib/audit-query consumer surface.
Per Rule 5: management endpoints are owner-only operational surface,
not OpenAI-spec-compatible — they exist outside the /v1/chat/completions
+ /v1/models spec scope. No provider plugin / IR change.

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

* docs: D50 fold-in — AGENTS.md dashboard.html duplicate (opus P3)

Fresh-context opus reviewer (PR #27) flagged a duplicate dashboard.html
entry: my D50 addition was added directly above a stale
"Planned (Phase 6) — not yet authored" line that should have been
removed. The file contradicted itself.

Fix: merge into single entry — keep the original line phrasing and
attach the D50 status update.

No code change, no test change.

Authority: PR #27 fresh-context opus reviewer P3 finding.

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:02:47 +10:00
686794e316 feat+test+docs: D49 — lib/audit-query.mjs (Phase 3 audit aggregate query layer) (#26)
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory
ndjson scan; cross-file walk over audit.ndjson (live) +
audit-YYYY-MM-DD.ndjson (rotated). No server.mjs integration in this
D-day (D50 wires the consuming endpoints).

NEW lib/audit-query.mjs (~370 lines): 5 public API functions per
ADR 0008 § 4.1:

  - discoverAuditFiles({ olpHome }): filesystem scan; returns
    Map<date|'live', path>.
  - readAuditWindow({ startMs, endMs, olpHome, logEvent }): generator
    over events in half-open window [startMs, endMs). Walks rotated
    date files + live file. Skips malformed lines + logs warn.
  - aggregateRequests({ windowMs, olpHome }): counts + status buckets
    + by_provider + by_owner_tier + by_path + median/p95 latency over
    rolling window.
  - topFallbackChains({ windowMs, limit, olpHome }): top-N chains by
    trigger count from events with fallback_hops > 0. Tied-count
    tiebreak: ascending first_seen.
  - spendTrendDaily({ days, olpHome }): daily series ending today
    with sparse-fill for zero-request days. Per-day request_count +
    median latency + by_provider breakdown.
  - cacheHitRateWindow({ windowMs, olpHome }): audit-derived cache
    hit rate (bypass excluded from denominator); per-provider + overall.

PII discipline (ADR 0008 § 4.3): every aggregate function relays only
schema fields; never message content. Suite 23g actively asserts the
absence of content/message/messages/prompt/response/body keys in every
aggregate output.

Cross-file walk semantics (ADR 0008 § 4.2): half-open window
[startMs, endMs); date-range computed once from window bounds; each
rotated date file checked; live audit.ndjson always checked (it
covers today regardless of whether the window endpoint is past
midnight).

spendTrendDaily calendar-date semantics:
  days: N returns "last N calendar UTC dates ending today" — NOT
  "events within a rolling N*86400-ms window" (which would span N+1
  distinct UTC dates and produce off-by-one buckets at non-midnight
  call times). Computed via:
    for (let i = days-1; i >= 0; i--)
      dates.push(_utcDateFromMs(now - i*86400*1000));

cacheHitRateWindow denominator: hit_rate = hit / (hit + miss).
Bypass is intentional non-cacheable (Anthropic cache_control marker),
NOT a cache miss; excluding it from the denominator gives a clean
cache-effectiveness signal.

TESTS — Suite 23, +27 (544 → 571):

  23a-1..4: discoverAuditFiles (empty dir / live only / live+rotated /
    non-audit files ignored)
  23b-1..6: readAuditWindow (all-coverage / single-day / half-open
    exclusivity / empty window / missing files / malformed-skip with
    warn)
  23c-1..4: aggregateRequests (counts + status buckets + by_provider;
    by_owner_tier; median+p95 latency over realistic distribution;
    invalid windowMs rejection)
  23d-1..4: topFallbackChains (sort desc by count; limit truncation;
    fallback_hops=0 excluded; first_seen/last_seen carried)
  23e-1..3: spendTrendDaily (N-day range correctness — caught off-by-
    one during local run; populated day breakdown; empty day sparse-
    fill)
  23f-1..3: cacheHitRateWindow (overall + per-provider hit_rate;
    bypass not in denominator; cache_status=null events excluded)
  23g-1..3: PII guard for aggregateRequests / spendTrendDaily /
    topFallbackChains + cacheHitRateWindow — every output JSON-
    stringified + scanned for forbidden PII keys

DOCUMENTATION:

  - AGENTS.md: lib/audit-query.mjs new entry; lib/audit.mjs note added
    that D52 extends with daily rotation.

NOT IN D49 scope:

  - server.mjs endpoints consuming these queries (D50)
  - dashboard.html (D51)
  - lib/audit.mjs rotation extension + bin/olp-audit-rotate.mjs (D52)
  - tried_providers schema fix (D53; D45 P2 deferral)
  - Phase 3 close → v0.3.0 (D55; maintainer-triggered)

Test count: 544 → 571 (+27). Verified locally via npm test.

AUTHORITY:

  - ADR 0008 § 4 (query API surface) + § 5 (rotation file naming
    pattern) + § 3 (storage layout).
  - ADR 0007 § 8 (audit ndjson event schema — input data).
  - CLAUDE.md release_kit overlay phase_rolling_mode — under
    Unreleased.
  - Standing autopilot grant.

ALIGNMENT.md scope check: this PR adds a new lib/ module. No provider
plugin / entry surface / IR change. Rule 5 commit-citation requirements
for those scopes do not apply.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:51:57 +10:00
c0b696984f docs: D48 — ADR 0008 Phase 3 design draft (Dashboard + audit query layer) (#25)
* docs: D48 — ADR 0008 Phase 3 design draft (Dashboard + audit query layer, design-only)

First Phase 3 D-day. Design-only. Ratifies the storage / query model /
rotation / dashboard / refresh / scope decisions ahead of D49+
implementation D-days. Opens ADR 0007 § 12 deferral for Dashboard +
audit query layer + rotation.

NEW docs/adr/0008-dashboard-and-audit-query.md (~368 lines): 13 sections
+ Consequences + Authority citations. Per maintainer-pinned lanes
A/A/B/A/B from Phase 3 kickoff brief 2026-05-25:

  Lane 1 (tech stack): A — static HTML + vanilla JS + fetch; no build
    step; matches OLP "no bundler" ethos.
  Lane 2 (query model): A — in-memory scan of audit ndjson per request;
    O(N) per call; family-scale acceptable; defers SQLite hybrid to
    Option 3 trigger per ADR 0007 § 13 (requires engines bump as
    separate prior PR).
  Lane 3 (rotation): B — daily rotation, audit-YYYY-MM-DD.ndjson on
    first append after UTC midnight + optional bin/olp-audit-rotate.mjs
    external cron.
  Lane 4 (refresh): A — 30s page poll (no SSE infra at v0.3.0;
    pause when document.visibilityState is hidden).
  Lane 5 (dashboard scope): B — full per spec § 4.6: 4 panels (per-
    provider quota / 24h request+cache+fallback / 30d spend trend /
    top-N fallback chains).

ADR sections:

  §1 Context (what Phase 3 closes; what stays out)
  §2 Decision (5 lanes pinned)
  §3 Storage layout (~/.olp/logs/ with rotated date files)
  §4 lib/audit-query.mjs API (readAuditWindow, aggregateRequests,
     topFallbackChains, spendTrendDaily, cacheHitRateWindow)
  §5 Audit rotation (first-append-after-UTC-midnight trigger + cron
     alternative + concurrent-safety per-process lock)
  §6 Dashboard panels (4 panels per spec § 4.6, refresh + localhost-
     bound notes)
  §7 Server endpoints (/dashboard, /v0/management/dashboard-data, /v0/
     management/quota, /cache/stats — owner-only gated)
  §8 Auth gating (reuses ADR 0007 § 7 owner_only_endpoints config)
  §9 Failure modes + degradation (per-panel error states)
  §10 Acceptance criteria (15 testable items for D49-D54)
  §11 Forward path (Phase 4+ — SQLite migration, SSE push, key-mgmt UI)
  §12 Out of scope (explicitly NOT in Phase 3)
  §13 Phase 3 sprint shape (D48-D55)

CHANGED:

  - docs/adr/README.md: added ADR 0008 row with one-paragraph summary.
  - CHANGELOG.md Unreleased: D48 entry per release_kit overlay
    phase_rolling_mode discipline. Includes Phase 3 sprint shape table.

NOT IN D48 scope:

  - lib/audit-query.mjs (D49)
  - server.mjs endpoints (D50)
  - dashboard.html (D51)
  - lib/audit.mjs rotation extension + bin/olp-audit-rotate.mjs (D52)
  - tried_providers schema fix (D53; D45 P2 deferral)
  - Phase 3 close (D55; v0.3.0; maintainer-triggered)

Test count: 544 / 544 pass (design-only, no test change). Verified
locally via npm test before commit.

AUTHORITY:

  - ADR 0007 § 12 (opens Phase 3 Dashboard + audit query deferral).
  - ADR 0007 § 13 (rejects SQLite at Phase 3 per Node baseline +
    documents forward-path trigger).
  - OLP v0.1 spec § 4.6 + § 4.7 (Dashboard + observability endpoints
    planning authority).
  - OCP dashboard.html (prior-art reference for multi-panel HTML
    structure).
  - ADR 0002 (Provider.quotaStatus contract — Panel 1 data source).
  - ADR 0005 § Cache stats (/cache/stats endpoint pre-existing design).
  - CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for
    design ADR.
  - Phase 3 kickoff via maintainer "go" 2026-05-25 + standing-autopilot
    grant (~/.cc-rules/memory/auto/standing_autopilot_phase_2.md in
    cc-rules bf0ed9a — Phase 3+ requires new authorization; the "go"
    supplied that).

ALIGNMENT.md scope check: this PR introduces a new ADR with full
authority citations per Rule 1 (Cite First). No provider plugin /
entry surface / IR change in this commit.

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

* docs: D48 fold-in — opus reviewer findings (1 P2 + 2 P3, all ADR-text polish)

Fresh-context opus reviewer (PR #25) returned APPROVE_WITH_MINOR with 3
findings, all ADR-text polish. No design semantic change beyond the
clarifications.

P2 — § 8 + § 10 #9 gating-mode wording

  Original § 8 implied a new "block non-owner identities" behaviour
  without naming it; § 10 #9 tested only the universal
  allow_anonymous: false 401 case. Fix:
  - § 8 now formalizes two gating modes — owner_only_trim (Phase 2
    /health pattern) vs owner_only_block (new Phase 3 management-
    endpoints pattern) — and explains the management endpoints are
    owner_only_block because the entire payload is sensitive.
  - § 10 #9 now covers both 401 paths: (a) allow_anonymous: true
    + no header → anonymous identity → STILL 401 because management
    endpoints are owner_only_block; (b) allow_anonymous: false + no
    header → 401 at the authenticate middleware itself.

P3 #1 — /cache/stats citation accuracy

  Original § 7.4 + Authority block cited "ADR 0005 § Cache stats"
  which is not a real section. Corrected: planning authority is OLP
  v0.1 spec § 4.6; ADR 0005 references the endpoint in
  Consequences/Mitigations (~line 279) for the per-(provider, model)
  cache-hit-rate breakdown surface.

P3 #2 — cacheStore.stats() shape gap

  § 7.4 now explicitly acknowledges the current shape
  ({ hits, misses, size, inflightCount } global aggregate) lacks the
  per-(provider, model) breakdown spec § 4.6 implies; Phase 3 Panel 2
  sources per-provider counts from aggregateRequests (audit-side)
  instead. If a future panel needs the breakdown, D50 amends the
  store shape + an ADR 0005 amendment fires at that time. Phase 3
  acceptance criteria do not require the breakdown.

CHANGELOG.md Unreleased D48 entry: fold-in bullet added enumerating
the 3 fixes.

Test count: 544 / 544 pass (design-only, no test change). Verified
locally via npm test.

Authority: PR #25 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>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:41:26 +10:00
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
63 changed files with 27170 additions and 407 deletions
-2
View File
@@ -5,7 +5,6 @@ on:
paths:
- 'server.mjs'
- 'lib/**'
- 'scripts/**'
- 'models-registry.json'
- '.github/workflows/alignment.yml'
push:
@@ -13,7 +12,6 @@ on:
paths:
- 'server.mjs'
- 'lib/**'
- 'scripts/**'
- 'models-registry.json'
- '.github/workflows/alignment.yml'
+38
View File
@@ -36,6 +36,44 @@ jobs:
fi
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
id: notes
shell: bash
+7 -3
View File
@@ -37,15 +37,19 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `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/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.**
- `dashboard.html` — owner-only multi-provider dashboard (quota panels, fallback rate, cache hit rate). **📋 Planned (Phase 6) — 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 + daily rotation per ADR 0008 § 5. **✅ D45 (append) + D52 (rotation) shipped. `appendAuditEvent` fires per /v1/chat/completions + /v1/models + /v0/management/* request (warn + 1 retry; no memory buffer). `_maybeRotateAudit` (sync) is called BEFORE the append when the UTC date changes; renames live → `audit-YYYY-MM-DD.ndjson`. Optional external cron tool `bin/olp-audit-rotate.mjs` for exact-at-midnight rotation.**
- `bin/olp-audit-rotate.mjs` — external audit rotation cron tool per ADR 0008 § 5.2. **✅ D52 — `runCli(argv, { out, err })` invocable + main-guard for direct execution. Installed via `package.json bin` so `npx olp-audit-rotate` works (also `npm run olp-audit-rotate`). Idempotent + safe alongside in-server first-append trigger.**
- `lib/audit-query.mjs` — audit ndjson aggregate query layer per ADR 0008 § 4. **🟡 D49 — discoverAuditFiles + readAuditWindow + aggregateRequests + topFallbackChains + spendTrendDaily + cacheHitRateWindow shipped. Cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). PII guard: aggregate shapes never include message content. In-memory scan per request (ADR 0008 Lane 2 = A; SQLite hybrid deferred to ADR 0007 § 13 trigger).**
- `dashboard.html` — owner-only multi-provider dashboard (4 panels: per-provider quota / 24h request+cache+fallback / 30d spend trend SVG sparkline / top-10 fallback chains). **✅ D51 — full UI shipped at repo root per ADR 0008 § 6. Vanilla HTML + JS + fetch (no build step, no framework, no CDN). 30s page poll with `document.visibilityState` pause/resume. Served by `/dashboard` route in server.mjs owner-only_block. Cached in memory at first request via `_loadDashboardHtml`.**
- `models-registry.json` — single source of truth for `(provider, model) → metadata`. SPOT.
- `ALIGNMENT.md` — the constitution. Binding for any plugin / entry-surface / IR change.
- `docs/adr/` — Architecture Decision Records. Read the index in `docs/adr/README.md` before proposing governance, SPOT, or contract changes.
- `.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).
**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-27):** Phase 5 (Quota Probes + Dashboard Enrichment) is closed at v0.5.0 + v0.5.1 hotfix. The shipped set includes all Phases 15 deliverables. v0.5.1 hotfix (2026-05-27) fixes three codex review findings: F1 (doctor check bypassed backoff by calling `_probeOnce` directly — now routes through `quotaStatus()`), F2 (200 with empty `anthropic-ratelimit-*` headers was cached as live — minimum-viable-schema gate added), F3 (null collapsed all failure modes — `probe_status:'unreachable'` shape + `failure`/`failure_kind`/`backoff_until` fields added). See ADR 0008 Amendment 2 + ADR 0013 Rule 3/5 clarifications. Phase 6 is next (per CLAUDE.md `release_kit.current_phase`).
---
+26 -4
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 |
|---|---|---|
| `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.
@@ -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) |
|---|---|---|---|
| `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 |
| `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 |
@@ -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.
**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
| Provider key | Tier | Default state | Authority pin | Inclusion source |
@@ -194,9 +196,29 @@ In addition to the recurring 14 May audit below, the following one-shot audits a
## Class-specific Exceptions
(none at project founding)
Any Rule 2 or 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.
### 1. Anthropic plan-usage probe via direct `/v1/messages` call (Phase 5, D79 — 2026-05-26)
**Class:** Rule 2(a) — provider-plugin scope. The Anthropic plugin's `quotaStatus()` calls `POST https://api.anthropic.com/v1/messages` directly rather than spawning `claude -p`. Under the strict reading of Rule 2(a), plugins must mirror provider-CLI behaviour; under the strict reading, this is a deviation because the spawn path goes through the CLI binary and the probe path does not.
**Authority:** ADR 0002 Amendment 8 (governance) + ADR 0013 (implementation discipline) + ADR 0012 (Phase 5 charter). Schema pin: `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` (compiled-binary `strings` + live API probe evidence). PR #50.
**Rationale:** Claude Code's compiled binary makes the same `POST /v1/messages` call internally (verified by `strings` over the v2.1.142 / v2.1.150 Mach-O / ELF binary). The probe mirrors that observed CLI behaviour without introducing a new wire format or output assumption. The exemption is bounded by ADR 0002 Amendment 8's three constraints (READ-ONLY, subscription-scope, idempotent-failure) + ADR 0013's seven implementation rules (notably Rule 2's per-endpoint enumeration — only `POST /v1/messages` is permitted).
**Reviewer:** fresh-context opus subagent on PR #50 (Iron Rule 10 + CLAUDE.md hard requirement #3). Verdict: APPROVE_WITH_MINOR. Six in-PR nits folded in; three outside-PR nits documented and addressed (this entry is one of them — N9).
**Re-evaluation trigger:** if Anthropic publishes a public documented quota endpoint (e.g. `GET /v1/usage`), this exception is RETIRED and the plugin migrates to the documented endpoint, deleting this exception by amendment PR. Until that hypothetical retirement, this exception is the canonical entry.
### 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).
---
+828 -1
View File
@@ -4,7 +4,834 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
## Unreleased
(empty — Phase 2 entries land here once Phase 2 opens)
### Phase 7 PR-B — anthropic.mjs spawn wrapped in sandbox-runtime
- feat(sandbox): Phase 7 PR-B — `lib/providers/anthropic.mjs` spawn wrapped via `@anthropic-ai/sandbox-runtime` with config-at-boot model (per-spawn ephemeral cwd `/tmp/olp-spawn/<uuid>`, network allowlist `api.anthropic.com` + `statsig.anthropic.com`, filesystem denylist for `~/.olp` / `~/.claude` / `~/.ssh` / `~/.config` / `~/.codex`). Load-bearing negative test (Suite 44, PI231-gated) confirms in-sandbox `cat` of OAuth credentials MUST fail. `/health.sandbox.active=true` on PI231 after `apt-get install bubblewrap socat ripgrep`. Adds `lib/sandbox/manager.mjs` (bootstrap + spawn-wrap layer), server startup wiring (`bootstrapSandbox()` before listen), `/health.sandbox.active` boolean field. 805 → 813 tests (+8 Suite 43; Suite 44 skips by default, runs on PI231 with `OLP_E2E_SANDBOX=1`). ADR 0014 PR-B acceptance criteria: met.
### Phase 7 PR-A — sandbox-runtime dep + doctor + ADR 0014
- feat(sandbox): Phase 7 PR-A — @anthropic-ai/sandbox-runtime dep + lib/sandbox/doctor.mjs preflight + ADR 0014. No runtime wiring yet (PR-B will wrap anthropic.mjs spawn). /health now reports sandbox availability (`available: false` until PI231 has `bubblewrap` + `socat` + `ripgrep` installed via `sudo apt-get install -y bubblewrap socat ripgrep`). On macOS (dev machine with ripgrep via Homebrew), sandbox-runtime reports `available: true` because macOS uses the built-in `sandbox-exec` seatbelt — no apt install needed. 797 → 805 tests (+8 Suite 42).
### Phase 6 D-day — stream-json transport for Anthropic provider (ADR 0009 Amendment 1)
- feat(anthropic): stream-json output + --system-prompt suppression of env-block / tool descriptions (ADR 0009 Amendment 1). Cuts ~64% per-request cost on Sonnet 4.6 via 30% input-token reduction ($0.0216 → $0.0078), fixes bot self-check hallucination (model no longer claims server cwd / OS / tool names), exposes rate_limit + usage events from NDJSON for future audit/dashboard work. Per-key API + cache + audit semantics unchanged. claude CLI v2.1.104 verified; warn if claude-version outside v2.1.100v2.1.149.
### F4 — `bin/olp.mjs` + `olp-plugin/index.js` migration to `quota_v2` shape
**Codex post-v0.5.0 review Q4.** Both CLI surfaces (`olp usage` and `/olp usage`) previously fell through to "no quota api" for every provider because they read the legacy `body.quota` shape, which never carries `percent_used` or meaningful `available` data. Now that the server (v0.5.0+) emits `body.quota_v2` per ADR 0008 Amendment 2, both surfaces prefer `quota_v2` and fall back to legacy `quota` on older servers.
- **`bin/olp.mjs cmdUsage`**: when `body.quota_v2` is present (non-empty array), renders per-provider rows with status (`live` / `stale` / `unreachable` / `unavailable`), 5h and 7d utilization percentages with color-coding (green < 50% / yellow 5080% / red ≥ 80%), reset countdowns, binding claim, and ⚠ stale / ❌ unreachable annotations. Legacy `body.quota` path preserved as fallback for pre-v0.5.0 servers. `formatResetCountdown(epochSeconds)` added — 5-range formatter (past / <1h / <24h / <7d / ≥7d), ported from `dashboard.html` D82, kept in-file (no shared lib).
- **`olp-plugin/index.js fmtUsage()`**: same migration — `quota_v2` rows render as one-line plain text per provider (no ANSI; Telegram/Discord safe). `pluginFormatResetCountdown(epochSeconds)` added; intentionally duplicated (plugin ships as a separate package). Legacy `body.quota` fallback preserved.
### v1.x roadmap #7 — AUTH_MISSING tuple path test coverage — ✅ CLOSED
The dedicated AUTH_MISSING engine test (asserting `fallbackDetail[0].trigger_type === 'auth_missing'`) was already shipped at D56 (`test-features.mjs` line 6255). This item closes the roadmap entry with a date stamp and PR reference per the tracker convention. No code changes — documentation only.
### Tests
- Suite 40 (9 new tests): `40a``40i` covering `cmdUsage` quota_v2 live/stale/unreachable/unavailable parse, legacy fallback, `pluginFormatResetCountdown` and `formatResetCountdown` 5-range coverage, olp-plugin `fmtUsage` quota_v2 + legacy paths. 759 → 768 tests, 0 fail.
### Authority
- F4: codex post-v0.5.0 review Q4 (PR #58 review); ADR 0008 Amendment 2 (quota_v2 shape).
- #7: `docs/v1x-roadmap.md` § "#7 — AUTH_MISSING tuple path test coverage (D40 follow-up)".
## v0.5.1 — 2026-05-27
**Hotfix — Quota probe cache/backoff/schema-drift correctness (codex review findings F1F3).** Three production-quality bugs in the v0.5.0 quota probe, reproduced by codex with local mocks, are corrected. 756 → 759 tests (3 new regression tests); 4 existing test assertions updated to reflect the v0.5.1 return-shape contract.
### Fixes
- **F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3).** `anthropic.quota_probe_reachable` doctor check called `_probeOnce(auth)` directly, bypassing the module-level `quotaProbeState.backoffUntil` check. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (60s-3600s exponential backoff is mandatory for all consumers). **Fix:** doctor check now routes through `quotaStatus()`, which enforces cache + backoff. ADR 0013 Rule 3 clarification added: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly."
- **F2 [P2] — 200 with empty `anthropic-ratelimit-*` headers cached as live data (ADR 0013 Rule 5).** `_probeOnce` treated any 200 OK (regardless of header content) as a successful probe, caching it with `stale: false` even when zero `anthropic-ratelimit-*` headers were present. A proxy stripping headers, a schema change, or a mock returning `{}` would silently appear as "LIVE" on the dashboard with all bars empty. **Fix:** minimum-viable-schema gate requires these 4 fields non-null: `5h-utilization`, `5h-reset`, `7d-utilization`, `7d-reset`. Any absence → `failureKind: 'schema_drift'`, backoff scheduled, result not cached. ADR 0013 Rule 5 updated with the gate specification.
- **F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6).** `aggregateProviderQuota()` collapsed all non-null failure modes (no credentials, auth failure, rate limit, schema drift, network error) into `status: 'unavailable', reason: 'no public quota api or probe disabled'` — the same string as providers with no quota API at all. Operator could not tell what to fix. **Fix:** `quotaStatus()` v0.5.1 return contract: `null` reserved for opt-in-off only; probe failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. `aggregateProviderQuota()` emits new fields `failure_kind`, `failure`, `backoff_until` per row. `status: 'unreachable'` distinguishes "probe failed" from `status: 'unavailable'` ("no API or disabled"). Dashboard renders `unreachable` with a red border + failure.message + backoff countdown.
### Backwards-compat notes
- `quotaStatus()`: `stale: false` → now also includes `probe_status: 'live'` (additive). `stale: true` → now also includes `probe_status: 'stale'` + `failure: {...}` (additive). `null` → NOW RESERVED FOR OPT-IN-OFF ONLY (breaking for callers that relied on `null` to detect "no credentials" or "probe failed" — use `probe_status: 'unreachable'` instead).
- `ProviderQuotaEntry.status`: gains `'unreachable'` as a new value (additive). Existing `'live'`, `'stale'`, `'unavailable'` semantics unchanged.
- `ProviderQuotaEntry` gains new fields `failure`, `failure_kind`, `backoff_until` (additive, null when not applicable).
- `dashboard.html`: handles `unreachable` row (no existing row had this status; additive render path).
### Test changes
- 38f, 38j, 38l: updated assertions from `null` to `probe_status: 'unreachable'` (F3 shape change).
- 38r: refactored to seed cache + manually expire it + set backoff (F1 — doctor now routes through `quotaStatus()`). Added F1-regression assertion: HTTP call counter stays at 1 after two doctor calls within backoff.
- 38g, 38k: added `probe_status` + `failure` assertions (verify new fields present on live/stale shapes).
- **38u** (new): F1 regression — successive doctor calls within backoff window → HTTP counter stays at 1.
- **38v** (new): F2 regression — 200 + empty ratelimit headers → `probe_status: 'unreachable'` + `failure_kind: 'schema_drift'` + cache stays null.
- **38w** (new): F3 regression — `lastError` + `failureKind` propagate through `quotaStatus()` shape for all failure modes (rate_limited / auth_failed / schema_drift / no_credentials).
### ADR changes
- **ADR 0013 Rule 3** clarification: doctor checks route through `quotaStatus()`, not `_probeOnce()` directly.
- **ADR 0013 Rule 5** update: minimum-viable-schema gate specification (4 required fields; absence = schema_drift signal).
- **ADR 0008 Amendment 2**: richer `ProviderQuotaEntry` shape with `failure`/`failure_kind`/`backoff_until`; `probe_status` on `quotaStatus()` return; `unreachable` status semantics; `dashboard.html` unreachable rendering.
### Authority
ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency); ADR 0008 Amendment 2; ADR 0002 Amendment 8 (unchanged); codex review findings F1F3 (codex PR review on v0.5.0 close PR #57).
---
## v0.5.0 — 2026-05-26
**Phase 5 — Provider Quota Probes + Dashboard Enrichment.** OLP gains live subscription-quota observability for Anthropic Pro/Max subscribers, surfaced through a Claude.ai-style Plan Usage panel on the owner-only dashboard. The probe is opt-in, READ-ONLY, idempotent on failure, and 5-min-cached with 60s→3600s exponential backoff. Six D-days, seven PRs, zero blocking reviewer findings, no flaky tests; 720 → 756 total tests.
### What's new for users
- **Live plan usage on the dashboard.** Per-provider rows show 5-hour + 7-day utilization bars with reset countdowns ("Resets in 1hr 6min" / "Resets Sun 9:00 PM"), status badges (allowed / rejected), representative-claim chips ("five_hour" / "seven_day"), overage-status indicators, and a `↻ Refresh` button. 60-second auto-refresh pauses when the tab is hidden.
- **Anthropic quota probe.** Opt-in via `~/.olp/config.json providers.anthropic.quota_probe_enabled: true`. Parses the canonical `anthropic-ratelimit-unified-*` response-header schema (13 fields) from a minimal `POST /v1/messages` probe. Reuses the spawn-path OAuth credentials — env var → `~/.claude/.credentials.json` → macOS Keychain. Refresh-on-401, stale-cache-on-failure.
- **`olp doctor anthropic.quota_probe_reachable`.** New check surfaces probe health. Returns `status: ok` with parsed utilization when fresh, `warn` on stale cache, `fail` with `human_steps[]` auth-aware recipe (re-login via `claude setup-token` or wait-and-retry).
- **Provider matrix.** Anthropic ✅ live (13 fields). OpenAI ❌ no public quota API. Mistral ❌ no member-key-accessible quota endpoint (Admin API exists but org-admin-scoped, out of scope for trusted-LAN deployment per ADR 0011). All three pinned in `models-registry.json quota_probe.<provider>` block.
### What's new for contributors
- **ADR 0012 (Phase 5 charter)** — D-day plan + exit gate + scope boundaries (`docs/adr/0012-phase-5-charter-quota-probes-dashboard.md`).
- **ADR 0002 Amendment 8** — first Class-specific Exception to the plugin contract: `quotaStatus()` may call provider HTTP APIs directly, subject to three constraints (READ-ONLY, subscription-scope, idempotent-failure) and the per-endpoint enumeration in ADR 0013 Rule 2.
- **ADR 0013** — seven rules covering OAuth READ-ONLY consumption + dual-path schema-drift mitigation (compiled-binary `strings` + live API probe diff, since Claude Code v2.1.x is now a Mach-O / ELF binary with no `cli.js` to grep).
- **`models-registry.json quota_probe.schema_version`** — pinned at `2026-05-26` (13 fields). Bump on schema-drift events per ADR 0013 Rule 5.
- **Test seams** — 5 underscore-prefixed exports in `lib/providers/anthropic.mjs` (`_setQuotaUrlsForTest`, `_resetQuotaProbeStateForTest`, `_resetQuotaStateOnlyForTest`, `_getQuotaProbeStateForTest`, `_setQuotaAuthReadFnForTest`) for hermetic probe testing. Production code must not call them.
- **ALIGNMENT.md § Class-specific Exceptions** — gains its first numbered exception (Anthropic plan-usage probe via direct `/v1/messages`).
- **Audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — schema canon + verification protocol + OCP institutional history.
### D-day-level changes (Phase 5)
- **D79** (PR #50 + cleanup PR #51): governance layer — ADR 0012 charter, ADR 0002 Amendment 8, ADR 0013, ALIGNMENT.md Class-specific Exceptions entry, D84 Mistral NO-GO disposition.
- **D80** (PR #52): ported OCP `server.mjs:842-1109` to `lib/providers/anthropic.mjs:quotaStatus()`. Adds macOS-keychain reader to `readAuthArtifact()`. Parses all 13 fields including 3 new since OCP's 2026-04 capture (`5h-status`, `7d-status`, `overage-reset`). Implements 5min cache + 60s-3600s exponential refresh backoff + stale-cache-on-failure + opt-in config flag + `anthropic.quota_probe_reachable` doctor check. ~250 LOC.
- **D81** (PR #53): added `lib/audit-query.mjs aggregateProviderQuota()` + `/v0/management/dashboard-data quota_v2` field + `/v0/management/quota quota_v2` field. Pinned `quota_probe.schema_version` in `models-registry.json`. Legacy `quota` field stays alongside for backwards compat until v1.0.0. ADR 0008 Amendment 1 documents the shape.
- **D82** (PR #54): `dashboard.html` restructure — Claude.ai-style Plan Usage panel above the existing 4 panels. Per-provider rows with utilization bars, reset countdowns, status chips, representative-claim badges, overage chips, "Updated N min ago" labels. 60s `setInterval` with `visibilitychange` pause/resume. Manual refresh button with 2s spam guard. Graceful fallback to legacy `quota` when `quota_v2` absent. Closes v1.x roadmap #8.
- **D83** (PR #55): Suite 38 (20 quota-probe unit tests covering all 13-header parse + cache + backoff + 401-refresh + 429-stale + schema_version + 5 doctor status paths) + Suite 39 (8 dashboard rendering smoke tests covering /dashboard 200/401 + key D82 HTML strings). Added 5 test seams to anthropic.mjs. 727 → 755 tests, 0 fail. Fold-in commit added 38j positive-path coverage (38j2: 401 → refresh succeeds → retry 200) per reviewer finding; total 756.
- **Close-prep** (PR #56): README § Plan Usage section + § Supported Providers Quota-probe column + dashboard screenshot + `docs/exit-gates/phase-5-e2e.json` live verification artifact. Fold-in commit addressed 3 maintainer accuracy findings (doctor-kind framing / Mistral admin-API acknowledgment / SPOT drift closure via `quota_probe.openai` + `quota_probe.mistral` registry entries).
### Out of Phase 5 scope (deferred to later)
- **D84 Mistral probe.** NO-GO per 2026-05-26 spike: no member-key-accessible quota endpoint at `docs.mistral.ai/api`. Re-entry point pinned at `lib/providers/mistral.mjs DL-7`; re-evaluate if Mistral publishes a member-key surface or if OLP deployment posture expands to org-admin scope (Mistral Admin API exists).
- **OpenAI / codex probe.** Permanently skipped — `openai/codex` CLI has no public quota API.
- **`X-OLP-Cost-USD` per-request header.** Deferred to Phase 6 (depends on per-(provider, model) cost weights table).
- **`context_window_exceeded` fallback trigger.** Deferred (trigger condition not yet observed).
- **Automated schema-drift detector.** ADR 0013 Rule 5 codifies a procedural runbook (Annual Alignment Audit + `olp doctor` probe-failure + manual maintainer attention at major `claude --version` bumps), not an automated alarm.
### Authority cited
ALIGNMENT.md Rules 1 + 2 + 5; CLAUDE.md release_kit (Phase 5 close trigger); ADR 0012 § Exit gate; ADR 0013 Rule 5 schema-drift protocol; OCP `server.mjs:842-1109` as port reference; live `/v1/messages` probe transcripts captured 2026-05-26 from PI231 (D79 audit) + MacBook (D80 + Phase 5 close-prep E2E); audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
## v0.4.4 — 2026-05-26
### D78 — `bin/olp-connect` stale-strings cleanup + README CDN-safe URL + repo-visibility flip
Patch release on top of v0.4.3. Three small issues caught when running `olp-connect` for real on MacBook (D77 client-install verification):
- **G11 fix (repo visibility).** Repo `dtzp555-max/olp` flipped from PRIVATE → PUBLIC during this session, closing the original G11 finding (`bash <(curl -fsSL .../main/bin/olp-connect)` returned 404 because anonymous curl can't fetch from private repos). README's `/main/` URL works going forward; GitHub's raw CDN may serve a stale 404 for `/main/` for ~5-15min after the visibility flip due to negative caching. D78 defends against this by adding a **tag-pinned URL (`/v0.4.4/bin/olp-connect`) as the primary recommendation in README**, with `/main/` listed as an alternative for trusted-head users. Tag-pinned URLs bypass the negative-cache because the tag ref was never queried while the repo was private.
- **G12 fix (`detect_openclaw` claimed plugin not shipped).** `bin/olp-connect`'s OpenClaw detection block said `"The OpenClaw OLP plugin (D71-D73) is NOT YET SHIPPED"` — but D71-D73 shipped `olp-plugin/` at v0.4.0. D78 replaces the stale text with real install instructions: `git clone` + `openclaw plugins install ./olp-plugin/` (or symlink), edit `~/.openclaw/openclaw.json` with a dedicated bot apiKey, restart gateway. Points at `docs/integrations/openclaw.md` for the full setup.
- **G13 fix (`olp-connect` self-version hardcoded literal).** Pre-D78 the script declared `OLP_CONNECT_VERSION="0.4.0-phase4"` as a hardcoded literal that nobody updated through v0.4.1 / v0.4.2 / v0.4.3 (the maintain-the-literal-per-release pattern is reliably forgotten). D78 derives the version at runtime from the sibling `package.json` via python3 — when the script is invoked from a checked-out repo, version resolves to the actual `package.json` value; when invoked via `curl … | bash` with no on-disk package.json next to it, falls back to `unknown`. Now `bash bin/olp-connect --version` prints `olp-connect 0.4.4` automatically with no manual touch needed at the next release.
**Pre-publish audit.** Per `~/.cc-rules/docs/guides/pre-publish-audit.md` checklist (2026-05-26 session, before the visibility flip):
- Identity scrub: 0 hits (no personal names / hostnames / home paths / personal emails leaked into the working tree)
- Credential scrub: 0 real tokens — all `olp_` matches are placeholder (`olp_XXXX...`) or test fixtures (`olp_not-a-real-key-...`); gitleaks: "no leaks found"
- Git-history author emails: 78 commits, two emails (`dtzp555@gmail.com` local + `taodeng1977@gmail.com` GitHub-account squash-merges). Maintainer chose Option A (accept) — the GitHub-account email was already verified-public on the maintainer's GitHub profile, so the visibility flip exposes nothing new.
**Test count:** 717 (v0.4.3) → 720 (v0.4.4). +3 D78 regression tests in Suite 36:
- 36v — pins absence of `NOT YET SHIPPED` text + presence of real install path
- 36w — pins runtime version derivation from package.json (hardcoded literal gone)
- 36x — pins README's tag-pinned-URL recommendation
**Authority:** D77 MacBook client-install verification session (2026-05-26); `~/.cc-rules/docs/guides/pre-publish-audit.md`. Process learning: every README that includes a `curl <raw-URL> | bash` install pattern should pin to a release tag (not `/main/`) for CDN-cache resilience. The /main/ form is correct for the long-tail (when no negative cache exists) but the tag-pinned form survives the visibility-flip transient + survives any future force-push to main.
**Out of D78 scope:**
- F6 (doctor client-side vs server-side check separation) — Phase 5 ADR amendment.
- D75 reviewer P2-1 (ADR 0004 per-hop schema amendment) + P2-2 (defensive `typeof hopModel === 'string'` invariant) — both genuine follow-ups, neither blocking.
- `scripts/migrate-from-ocp.mjs` — Phase 7.
## v0.4.3 — 2026-05-26
### D76 — README install-path overhaul + `OLP_BIND` env + AI-driven install prompt + ADR 0011 amendment
Patch release closing the install-experience gap. v0.4.0v0.4.2 README's Quick Start was placeholder text with fictional commands (`npm install -g @dtzp555-max/olp` — package isn't published; `olp setup` / `olp start` — don't exist). 10 real gaps catalogued + fixed in one D-day; `OLP_BIND` env wired so the documented LAN onboarding flow actually works; AI-driven install prompt added per the Phase 4 charter brainstorm's #2 OCP inheritance candidate (was deferred at D64-D67 to the doctor framework only; D76 closes the README half).
- **G1-G7 (README "Quick Start" was fictional)** — rewrote § "Manual install" with the real sequence: prerequisites (Node ≥ 18 + provider CLI install matrix) → `git clone``npm test` verify → `olp-keys keygen --owner` first → provider OAuth (claude/codex/mistral per-CLI flows) → write `~/.olp/config.json` with the minimum that actually serves traffic → `npm start` → smoke-test → IDE pointing. Each step empirically verified against the PI231 + Mac mini E2E session (2026-05-26).
- **G8 (LAN unreachable — F5)** — added `OLP_BIND` env (default `127.0.0.1`). Operators set `OLP_BIND=0.0.0.0` (or a specific LAN IP) to accept LAN connections so `olp-connect <ip>` can actually reach the server. Pre-D76 the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`, making the documented LAN-onboarding flow only usable through an SSH tunnel. ADR 0011's original wording referenced a `BIND_ADDRESS` concept that didn't exist; D76 makes it operational.
- **G10 (no AI-install pattern)** — README § "Install with your AI (the fast path)" added. Verbatim prompt that the operator pastes into Claude Code / Cursor / Copilot / Aider; the AI follows the README + uses `olp doctor --json` machine-readable `next_action.ai_executable[]` (D64-D67) for self-repair, stopping only when `human_required[]` is non-empty (the provider OAuth dances). This closes the Phase 4 brainstorm Top-5 inheritance candidate #2 — the OCP "paste this prompt" pattern that D64-D67 only half-built.
- **Opening compressed** — § "Why OLP" (3 paragraphs of OCP billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + § "Install with your AI" / § "Manual install" without needing to digest 2026-05-14 / 2026-06-15 Anthropic billing history first. OCP users get a one-line pointer at the top.
- **§ "Configuration" full schema documentation** — replaced the placeholder with the actual `~/.olp/config.json` schema including every field that v0.4.x reads. Cross-references ADR 0004/0007/0010/0011.
- **§ "Environment Variables" extended** — added `OLP_BIND`, `OLP_API_KEY`, `OLP_OWNER_TOKEN`, `OLP_PROXY_URL` rows that were used throughout the manual-install flow but undocumented.
**ADR 0011 § "Deployment configurations" amendment.** Codifies the three deployment trust contexts (`127.0.0.1` loopback / RFC1918 + tailnet LAN / `0.0.0.0` public — with `advertise_anonymous_key: true` only safe in the first two). Documents the new `anonymous_key_advertised_with_lan_bind` startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent `BIND_ADDRESS`.
**Test count:** 714 (v0.4.2) → 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning `OLP_BIND` wiring + safety warn + ADR amendment.
**Out of D76 scope (deferred):**
- F6 (doctor client-side vs server-side check separation) — needs design ADR for a `--remote` mode. Phase 5.
- D75 reviewer P2-1 (ADR 0004 amendment for per-hop schema) + P2-2 (defensive `typeof hopModel === 'string'`) — both genuine follow-ups, neither blocking.
- `scripts/migrate-from-ocp.mjs` — Phase 7.
**Authority:** PI231 + Mac mini E2E session (2026-05-26, post-v0.4.2 verification revealed the 10 README gaps); ADR 0011 amendment self-cites; Phase 4 charter (ADR 0010) Top-5 inheritance candidate #2 (AI-driven self-repair). Process learning: every D-day reviewer rubric should add "open README in §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0.
## v0.4.2 — 2026-05-26
### Post-v0.4.1 hotfix batch (D75) — real-machine E2E findings
Patch release fixing 5 bugs caught by **real-machine E2E testing on PI231 + Mac mini (2026-05-26 session)** — bugs that prior D-day reviewers AND the post-v0.4.0 maintainer review both missed because they reviewed against spec text and against the local OLP install's `~/.codex/auth.json` shape (cached from an older codex CLI version), not against real provider CLIs running on a remote operator host that did `npm install -g @openai/codex` for the first time on 2026-05-26 and got codex CLI v0.133.0.
**Root cause of the missed-bug class.** D6 (codex plugin authoring) explicitly documented three unpinned assumptions (A3 = access-token field name, A4 = NDJSON event schema, A2-adjacent = trusted-directory sandbox). D6 noted "D7 E2E will pin." D7 then shipped without performing real-codex-CLI E2E (the E2E gating mark was carried but the actual run was deferred). Every subsequent D-day reviewer trusted the D6/D7 codex plugin code unchanged because the static review couldn't see that the v0.133.0 CLI had moved the auth-token field, the event schema, AND added a new trusted-directory sandbox flag. The D74 maintainer review focused on `/health` / `/cache/stats` / `/v0/management/dashboard-data` payload shapes — none of which exercise the codex plugin's spawn path. F7 (per-hop model override) is a different class of miss — every reviewer read `executeHopFn(provider, model, ir)` and saw `model` consumed for cache key + audit ctx, but none traced through to confirm `model` is ALSO substituted into the IR passed to `provider.spawn()`. The function signature implied per-hop semantics that the body never fully delivered.
- **[F1] codex auth.json schema pin — codex CLI v0.133.0 nests the access token under `tokens.access_token`** (verified empirically on PI231 / Mac mini, 2026-05-26). Pre-D75 `readAuthArtifact()` read only top-level `creds.access_token` / `creds.token` / `creds.accessToken` — all undefined under v0.133.0 → returned `null` → OLP reported "auth artifact missing" via `/health` and `olp doctor` AND refused to spawn codex even when the user had fully completed `codex login`. Fix: prepend `creds?.tokens?.access_token` to the precedence chain at BOTH call sites (`OPENAI_CODEX_AUTH_PATH` override branch + default `$CODEX_HOME/auth.json` branch). Legacy top-level fields preserved as fallback for backward compat with older codex CLI versions.
- **[F2] codex spawn args — codex CLI v0.133.0 trusted-directory sandbox requires `--skip-git-repo-check`.** v0.133.0 refuses with `"Not inside a trusted directory and --skip-git-repo-check was not specified."` when spawned outside a git repo, exits non-zero with zero NDJSON output → OLP surfaces `SPAWN_FAILED` with no usable chunks → the fallback engine advances to next hop unnecessarily even when codex is configured and authenticated. OLP's typical deploy CWD (`~/olp/`) is NOT a git repo on operator hosts. Fix: add `'--skip-git-repo-check'` to the args array before `--model`. OLP is the trusted caller (operator's own server invoking the operator's own subscription via documented `codex exec` automation); the sandbox safeguards interactive shells, not pre-authorized automation.
- **[F3] codex NDJSON event shape pin — codex CLI v0.133.0 emits `item.completed` + `turn.completed` + `turn.failed`**, not the D6-assumed `content`/`delta`/`text` + `type:'stop'`/`done:true` shapes. Real v0.133.0 stream (verified empirically): `{"type":"thread.started",...}``{"type":"turn.started"}``{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}``{"type":"turn.completed","usage":{...}}`. Pre-D75, every chunk was silently dropped by `codexChunkToIR()` → response body had `content: null`. Fix: prepend three new recognizers (`item.completed` with `item.type === 'agent_message'` → IR delta; `turn.completed` → IR stop; `turn.failed` → IR error). Legacy D6 defensive recognizers preserved below as forward/backward compat fallbacks.
- **[F4] `olp status` reads `body.stats.cache.size` (not OCP-era `body.cache.entries`).** Same class as D74 P2-3 (which fixed `cmdUsage` + `cmdCache`); D74 missed the parallel bug in `cmdStatus`. Server payload nests cache stats as `body.stats.cache.{hits, misses, size, inflightCount}` per `server.mjs handleManagementStatus`, and `CacheStore.stats()` has no `entries` field per `lib/cache/store.mjs`. Pre-D75 output showed `entries=?`. Fix: read `c.size` for entries display; also surface `inflightCount` when present.
- **[F7] per-hop chain `model` field now overrides IR model in `provider.spawn()`.** Pre-D75 `executeHopFn(hopProvider, hopModel, irReq)` used `hopModel` for cache key + audit ctx but passed the ORIGINAL `irReq` (with `irReq.model` = user's original request) to `hopProviderPlugin.spawn(irReq, authContext)`. A chain config `[{provider:anthropic, model:claude-X}, {provider:openai, model:gpt-5.5}]` would always spawn BOTH plugins with `--model claude-X` — openai rejected the unknown model and the chain died. This broke the core OLP value prop (cross-provider fallback with provider-appropriate model substitution per hop). Fix: build a per-hop IR variant with `{...irReq, model: hopModel}` and pass that to spawn. Conditional skips clone when `hopModel === irReq.model` (common case: single-provider chains, or single-hop chains where the chain config repeats the request model). Applied to BOTH the buffered path (`executeHopFn`) AND the streaming path (`sourceFactory` for `getOrComputeStreaming`). **Authority:** ADR 0004 § Chain advancement step 1 (per-hop config supplies provider AND model — the contract was always specified, but the code didn't complete it).
**Phase 5 process learning recorded.** Every provider plugin's D-day must include a real-CLI E2E ON A REMOTE OPERATOR HOST before merging — not on the maintainer workstation (which may have an older CLI cached from a prior install, hiding new field renames / new sandbox flags / new event shapes). The D6/D7 codex E2E was deferred and that deferral compounded across 3 layers (D6 = unpinned, D7 = pinning deferred, D8+ = trusted D6/D7 unchanged). F7 reinforces a separate lesson: when a function signature takes `(provider, model, ir)`, reviewers must check that `model` is consumed everywhere downstream, not just at the call site they happened to look at.
**Out of D75 scope (deferred to Phase 5 explicit ADR amendments):**
- F5 (server bind 127.0.0.1 / `OLP_BIND` env) — needs `lib/keys.mjs` anonymous-key trust boundary review before binding to non-loopback by default
- F6 (`olp doctor` client-vs-server-side limit detection) — needs design ADR amendment for trigger taxonomy
- **Test count delta:** 704 (v0.4.1) → 714 (v0.4.2). +10 D75 regression tests in Suite 36 (36i through 36r).
- **Files touched:** `lib/providers/codex.mjs` (F1+F2+F3), `bin/olp.mjs` (F4 cmdStatus), `server.mjs` (F7 buffered + streaming spawn sites), `test-features.mjs` (Suite 36 extension), `package.json` (version), `CHANGELOG.md` (this entry).
- **Authority:** ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback engine — per-hop model contract), `lib/providers/codex.mjs` D6 assumption A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex CLI v0.133.0 on-disk schema + `codex exec --help` output verified empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline.
## v0.4.1 — 2026-05-26
### Post-Phase-4 hotfix batch (D74) — maintainer-review findings
Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent review. Every finding was a real runtime bug that the per-D-day fresh-context opus reviewers all missed because they reviewed against spec text, not against the runtime contract (default `auth.allow_anonymous: false`, real `/health` payload shape, real `/cache/stats` payload shape, real `/v0/management/dashboard-data` payload shape). **Phase 4 lesson: future implementation D-days MUST include at least one test that boots the server with the default production config and exercises the new feature end-to-end** — not just stub-mocked codepaths.
- **[P1-1] `olp doctor` no longer false-negatives on auth-required `/health`.** `lib/doctor.mjs` now accepts an `authHeaders` option (threaded from `bin/olp.mjs` `cmdDoctor` via the existing `authHeaders()` chain) and passes it to the `server.running` + `server.version` probes. The `server.running` check now distinguishes 401/403 ("server up but bearer token missing/invalid — set `OLP_API_KEY`") from "server unreachable" — so the `kind` discriminator routes to a clean fix-auth path instead of `fix_server` when the operator just forgot to export the env var.
- **[P1-2] `bin/olp-connect` validates token shape + shell-quotes rc writes.** New `validate_olp_token <key> <source>` helper enforces the `^olp_[A-Za-z0-9_-]{43}$` regex (per ADR 0007 § 3 token format) at all 3 input sites: `--key` arg, `/health.anonymousKey` server-advertised consumption, and the interactive prompt fallback. New `shell_quote <value>` helper wraps rc-file writes (`export OPENAI_BASE_URL=$(shell_quote ...)`) so even a hypothetical bypass of the validator can't inject shell metacharacters into a sourced rc. systemd `environment.d/olp.conf` write additionally rejects embedded newlines. Hostile or malformed keys can no longer persist as shell startup injection.
- **[P2-3] `olp usage` + `olp cache` human formatter rewritten against the real payload shape.** `cmdUsage` previously read `body.usage_24h.requests` / `body.providers` / `body.top_fallback_chains` — all undefined under the actual server payload shape — so users saw "requests: ?" + missing per-provider quota + missing top-chains. Now reads `body.window_24h.request_count` / `body.cache_hit_24h.hit_rate` / `body.quota` / `body.top_fallback_chains_24h` per `server.mjs:2027` + `lib/audit-query.mjs`. `cmdCache` previously read `body.entries` / `body.bytes` / `body.maxBytes` (OCP-era field names). Now reads `body.size` / `body.inflightCount` per `CacheStore.stats()` and computes hit rate from `hits + misses`.
- **[P2-4] `olp-plugin/` `fmtHealth` iterates `providers.status` correctly.** Previously walked `Object.entries(body.providers)` which surfaced `enabled` / `available` / `status` as pseudo-providers (chat output showed `🟢 status` instead of `🟢 anthropic`). Now extracts the real provider map from `body.providers.status` and renders enabled/available counts in a header line + per-provider names with `activeSpawns` when present. Falls back to flat `body.providers.*` for the older OCP shape (backwards compat).
- **[P3-5] Stale v0.3.0-era doc strings updated.** README header status line + Implementation Status § now reflect v0.4.0 shipped + Phase 5 open. `server.mjs` startup banner no longer hardcodes "Phase 1 in progress" (now just lists version + provider count — derives accurate state from `VERSION` without future maintenance touch-ups).
**Phase 4 process learning recorded.** Per Iron Rule 第二律 (evidence over "should work"), every D-day review pass must include at least one runtime smoke against the default production config. The D-day reviewer rubric is updated implicitly — D74 Suite 36 tests pin the wire-contract shape so a future D-day refactoring server payloads can't silently re-break the CLI / plugin / docs.
- **Test count delta:** 696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36.
- **Files touched:** `lib/doctor.mjs` (P1-1), `bin/olp.mjs` (P1-1 + P2-3), `bin/olp-connect` (P1-2), `olp-plugin/index.js` (P2-4), `server.mjs` (P3-5 banner), `README.md` (P3-5), `test-features.mjs` (Suite 36 regression), `package.json` (version), `CHANGELOG.md` (this entry).
- **Authority:** maintainer independent review of `main` / `v0.4.0` / commit `ee4d945` (2026-05-26 session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump patch, tag, release before next push").
## v0.4.0 — 2026-05-26
### Phase 4 — Operator + Client UX (D60 → D73)
**Overview.** v0.4.0 closes Phase 4 — the "operator + client UX" track that grew OLP from "I built a multi-provider proxy" to "my family can use it without me holding their hand." 5 D-day groups (D60 → D73), ~13 D-days, all under standing-autopilot grant + per-D-day fresh-context opus reviewer per Iron Rule 10. The maintainer-triggered close PR lands all of it under one version tag.
**Test count: 623 (v0.3.2) → 696 (v0.4.0).** +73 tests across the Phase 4 arc.
**Strategic decision recorded:** Phase 4 explicitly DEFERS `/v1/messages` (Anthropic-shape entry surface) per ADR 0010 — re-open strictly gated on ADR 0009 P0 success AND maintainer-named family CC user. README posture: Claude Code listed as Not supported as an OLP client; recommended alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility because OpenAI tool schema is the multi-provider lingua franca).
**Phase 4 release_kit checklist**
- [x] All 5 D-day groups landed on main (D60 + D61-D63 + D64-D67 + D68-D70 + D71-D73)
- [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 group + per-D-day P0/P1/P2 fold-ins where applicable
- [x] CHANGELOG "Unreleased" promoted to "## v0.4.0 — 2026-05-26"
- [x] `package.json` bumped 0.3.2 → 0.4.0
- [x] `CLAUDE.md release_kit.phase_rolling_mode.current_phase` Phase 4 → Phase 5; `current_pre_release_identifier` `0.4.0-phase4``0.5.0-phase5`
- [x] README § IDE Setup + § Telegram/Discord Usage + § Operator CLI surfaces (env var table extension)
- [x] ADR 0010 (Phase 4 charter), ADR 0011 (anonymous-key deployment-context limits), ADR 0002 Amendment 7 (provider doctorChecks contract) all on disk
- [ ] Tag pushed (next step in this PR's lifecycle)
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push)
---
### D60 (PR #40) — Phase 4 charter (ADR 0010) + default port 3456 → 4567
Opens Phase 4. No functional code change beyond the default port value; substantive D-day work lands D61 onward.
- **Default `OLP_PORT` changed `3456 → 4567`.** OCP defaults to 3456; OLP and OCP can now co-host on the same machine without `OLP_PORT` env override. Tests use `port: 0` ephemeral — no test-surface impact.
- **ADR 0010 (Phase 4 charter) ratified.** Records 5 D-day group scope + explicit DEFER of `/v1/messages` with re-open trigger.
- **ADR 0001 + ADR 0008 amendments.** Port-conflict assumption struck-and-amended; § 6.6 default-port reference updated.
- **README quick start + Environment Variables table + Migration from OCP § note** updated.
### D61-D63 (PR #41) — SSE heartbeat + recentErrors[20] + /v0/management/status
First substantive Phase 4 implementation. 3 D-days bundled per Iron Rule 11 IDR (shared observability surface).
- **SSE heartbeat** via `streaming.heartbeat_interval_ms` config (default `0` = disabled, matches OCP safe default). When enabled, streaming branch emits `: keepalive\n\n` SSE comment every interval during silent windows; resets on real chunk; cleans up on stream end/error/abort/disconnect. Eager-headers-post-spawn from day one (the OCP `db11105` lesson). `X-Accel-Buffering: no` centralized via new `SSE_DEFAULT_HEADERS` constant. Per-attached-client lifecycle (each tee output gets its own timer).
- **`recentErrors[20]` ring buffer.** Module-scope bounded ring, populated from 5 server-side error paths. Filter: only `ProviderError` OR `statusCode >= 500` (401/403 brute-force noise excluded; D61-D63 reviewer P2-1 explicit-401/403-reject fold-in). Path sanitization via OCP `server.mjs:1395` port. In-memory only (per OCP precedent).
- **`GET /v0/management/status` combined endpoint** (owner-only_block). Returns `{ ok, version, uptime_ms, uptime_human, started_at, providers, stats, recent_errors, generated_at }`. `_totalRequests` + `_activeRequests` module-scope counters with idempotent-decrement guard.
- **Authority:** ADR 0010 § D61-D63; OCP `server.mjs:660-685` (startHeartbeat), `301, 354-358` (ring), `1151-1188` (/status), `1395` (path sanitization), commit `db11105` (eager-headers); ADR 0007 § 7 + ADR 0008 (owner-only_block pattern).
- **Test count delta:** 623 → 636 (+13).
### D64-D67 (PR #42) — `olp` Node CLI + `olp doctor` framework + per-provider doctor checks + ADR 0002 Amendment 7
Second substantive Phase 4 implementation. 4 D-days bundled — CLI dispatches to doctor; doctor calls plugins via new contract method; ADR amendment authorizes the contract change.
- **`bin/olp.mjs` Node CLI** with 11 subcommands: `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor / help`. Node not bash (per ADR 0010 § Notes — bash's python3 JSON-parsing fragility avoided). Token resolution: `OLP_API_KEY` env → `OLP_OWNER_TOKEN` env → helpful 401 message (filesystem manifest tokens are one-way SHA-256 per ADR 0007 § 5, not recoverable). Output: human-readable ANSI text by default, `--json` for scripting. Exit codes `0=ok / 1=usage / 2=network|HTTP / 3=auth`. Installed via `package.json bin.olp` so `npx olp <subcommand>` works.
- **`lib/doctor.mjs` framework** with machine-readable `next_action.ai_executable[]` for AI-driven self-repair. Per check: `{ id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }`. Built-in checks: `server.running / server.version / config.exists / config.providers_enabled / config.chains_configured / auth.owner_key_exists / system.node_version`. Per-provider checks dynamically collected via the new `provider.doctorChecks()` contract method. `--json` output emits `{ checks, kind: noop|update|fix_oauth|fix_config|fresh_install|fix_server|fix_provider, next_action: { ai_executable, human_required, verify }, summary }`. `--check <id|category>` for tight repair-loop fast paths. Reviewer P2 fold-in: `_shellQuote()` helper hardens `ai_executable[]` against malicious `OLP_HOME` shell-metacharacter injection.
- **Per-provider `doctorChecks()`** in anthropic / codex / mistral plugins: CLI-availability probe + auth-presence probe. Each fail returns `evidence.fix_commands` (for `ai_executable[]`) or `evidence.human_required`.
- **ADR 0002 Amendment 7** adds OPTIONAL `provider.doctorChecks(): DoctorCheck[]` to the Provider contract — backwards compatible (plugins without it contribute no provider checks).
- **`olp restart`** documented caveat (reviewer P2-2): `launchctl kickstart -k` does NOT re-read plist `EnvironmentVariables`; bootout/bootstrap dance noted for env reloads.
- **Authority:** ADR 0010 § D64-D67; ADR 0002 Amendment 7 (new); OCP `ocp` bash wrapper + `scripts/doctor.mjs` (port references); 2026-05-26 brainstorm Top 5 inheritance candidate #2.
- **Test count delta:** 636 → 658 (+22).
### D68-D70 (PR #43) — `bin/olp-connect` + `/health.anonymousKey` + ADR 0011
Third substantive Phase 4 implementation. 3 D-days bundled — olp-connect consumes /health.anonymousKey; both governed by ADR 0011 trusted-LAN invariant.
- **`bin/olp-connect <host-ip>` (bash, 564 lines)** zero-config LAN client setup. Bash over Node so client machines without recent Node still work. Auto-detects 6 IDEs and configures each: Claude Code (detect + warn — NOT supported per ADR 0010), Cline (print VSCode-settings snippet — manual), Continue.dev (write idempotent `models:` entry to `~/.continue/config.yaml`), Cursor (snippet + WARNING about known base-URL fragility), Aider (write `OPENAI_API_BASE` + `OPENAI_API_KEY` to rc files), OpenClaw (detect + point at `olp-plugin/`). macOS `launchctl setenv` / Linux `~/.config/environment.d/olp.conf` for GUI-app env inheritance. `--dry-run` exercises every state-change site without modifying anything. Idempotent rc-file writes via bracketed `# OLP LAN ... # /OLP LAN` block.
- **`/health.anonymousKey` opt-in field** + `auth.advertise_anonymous_key` config. Field appears in both trimmed AND full `/health` payloads when ALL THREE prerequisites hold: `auth.advertise_anonymous_key: true` + `auth.allow_anonymous: true` + at least one non-revoked guest-tier key has `plaintext_advertise` set. Default off — field ABSENT (not null), preserves v0.3.x `/health` shape. Three-prereq gate is graceful-degrade (server warns + boots; request-time re-checks).
- **`bin/olp-keys keygen --anonymous --advertise`** new flag. Writes plaintext into manifest `plaintext_advertise` field AND prints WARNING + ADR 0011 pointer. Owner-tier rejected at BOTH CLI and lib layers (defense-in-depth). Reviewer P2-1 fold-in: `listKeys()` strips `plaintext_advertise` alongside `token_hash` — callers wanting the advertised plaintext for the `/health` publication path MUST go through `findAdvertisedKey()` (the only sanctioned read site).
- **ADR 0011 (anonymous-key deployment-context)** new ADR codifying the trusted-LAN-only invariant. Threat model explicit; deployment-context table concrete; soft enforcement via startup warn if `BIND_ADDRESS` resolves to public IP AND `advertise_anonymous_key: true`. No hard allowlist (TLS-fronted private networks indistinguishable from public from server's perspective). Re-evaluation triggers named (Cloudflare Tunnel guidance / Phase 5 multi-tenant).
- **Authority:** ADR 0010 § D68-D70; ADR 0011 (new); ADR 0007 § 4 (manifest forward-compat unknown fields) + § 7 (identity classes) + § 9 (keygen flow); OCP `ocp-connect` (port reference); 2026-05-26 brainstorm Top 5 inheritance candidate #3.
- **Test count delta:** 658 → 672 (+14).
### D71-D73 (PR #44) — `olp-plugin/` (OpenClaw /olp Telegram+Discord) + `docs/integrations/*.md` + README cross-refs
Final Phase 4 substantive D-day group. 3 D-days bundled — plugin consumes existing endpoints; integration docs reference plugin + olp CLI + olp-connect together.
- **`olp-plugin/` OpenClaw gateway plugin** (482 lines). Port of OCP `ocp-plugin/index.js` minus mutations. Subcommand parity with `olp` CLI: `/olp status / usage / cache` (owner-only) + `/olp health / models / providers / chain show / doctor / help` (informational). **Explicitly NOT ported** for security: `/olp keys keygen` (chat = brute-force-prone), `/olp keys revoke` (mutation), `/olp restart` (misclick risk), `/olp logs` (PII risk). Port resolution: `OLP_PROXY_URL` env → `OLP_PORT` env → plugin config `proxyUrl``http://127.0.0.1:4567` (D60 default). Output: Telegram/Discord monospace code block with status icons (🟢🟡🔴). Long responses truncated for 4096-char Telegram limit. No npm deps (OpenClaw provides Telegram/Discord transport).
- **`docs/integrations/*.md` bundle** (6 pages + index). Per-IDE setup docs with status icons: Continue.dev ✅, Cline ✅ (cites Cline issue #7128 base-URL UI bug), Cursor ⚠️ (documented base-URL fragility), Aider ✅, **Claude Code ❌** (Anthropic wire format only; recommended alternative "Cline + OLP" per ADR 0010 § /v1/messages defer), OpenClaw ✅. Each ~60-120 lines: status / quick setup / known issues / OLP-specific notes / test-it command. `docs/integrations/README.md` is the index.
- **README updates.** New § "IDE Setup" linking `docs/integrations/README.md`. New § "Telegram / Discord Usage" with install + configure + restart + use. Quick Start mentions `olp-connect <ip>` as family-onboarding command. `package.json files` field extended to include `olp-plugin/` so the published tarball ships it.
- **Authority:** ADR 0010 § D71-D73; OCP `ocp-plugin/index.js` (port reference); 2026-05-26 brainstorm prior-art survey IDE-specific quirks.
- **Test count delta:** 672 → 696 (+24).
---
**Phase 4 close authority chain:** ADR 0010 (charter); CLAUDE.md `release_kit.phase_rolling_mode` (close trigger = explicit maintainer action — fired by maintainer 2026-05-26); standing autopilot grant covering D-day-by-D-day execution; 5 fresh-context opus reviewer passes (one per D-day group); 696/696 tests pass on this release commit head.
## v0.3.2 — 2026-05-25
### Post-Phase-3 cleanup batch #2 — streaming-path singleflight + TOCTOU close (D57 + D58 + D59)
Patch release closing v1.x roadmap #1 end-to-end. The cache layer's D4 singleflight (one spawn per identical concurrent request) was fully wired on the buffered path since v0.1 but NOT on the streaming path — N concurrent identical streaming requests each spawned their own CLI process. v0.3.2 ships the streaming sibling: tee fan-out, late-joiner replay, per-client backpressure, AbortController propagation, and TOCTOU close. 3 D-day commits (D57 + D58 + D59); ADR 0005 Amendment 8 §§114 implemented.
- **D57** (PR #36) — **cache layer.** New `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts) → { stream, isFirst, role }` mirroring `getOrCompute` on the streaming side. Internals: `_streamingInflight: Map<compositeKey, StreamingInflightEntry>` (composite key `keyId + '\0' + cacheKey`) with synchronous check+insert atomicity (closes TOCTOU per ADR 0005 Amendment 8 §1, §6); single-reader tee fan-out across all attached clients; late-joiner replay buffer (synchronous drain on attach; `STREAM_BACKPRESSURE` terminator if drain or replay-truncation would corrupt); per-client backpressure (`PER_CLIENT_QUEUE_CAP = 1 MB`, overridable via opts); accumulated replay cap (`ACCUMULATED_REPLAY_CAP = 10 MB`, mirrors D23 cache-entry cap); AbortController fires source-iterator return when all clients disconnect. New `'STREAM_BACKPRESSURE'` entry in `PROVIDER_ERROR_CODES` — NOT a hard trigger (whitelist-only `HARD_TRIGGER_CODES`). Suite 27 = 12 unit tests.
- **D58** (PR #37) — **server wiring.** Streaming branch in `server.mjs` swapped from the peek+spawn pattern to `cacheStore.getOrComputeStreaming(...)`. `tryAcquireSpawn`/`releaseSpawn` moved INSIDE the `sourceFactory` closure per ADR 0005 Amendment 8 §7 (only the first caller acquires; attached joiners share the slot; release fires exactly once on source completion/error/abort). `CONCURRENCY_LIMIT` thrown by the factory triggers fallthrough to the buffered path (preserves today's behaviour). New `X-OLP-Streaming-Inflight: source | attached` HTTP header annotates per-response role (§11). New `cache_status: 'streaming_attached'` audit value tracks the singleflight win. `lib/audit-query.mjs` aggregate APIs (`aggregateRequests`, `cacheHitRateWindow`) extended with `cache_streaming_attached` / `streaming_attached` fields so the cache_status breakdown reconciles. `res.on('close')` propagates client disconnect into the tee's `attachedClients` accounting (§9). D16 truncated-not-cached invariant preserved via server-layer `cacheStore.delete` on stop-less exhaustion (the cache layer is IR-agnostic and writes accumulatedChunks on any source exhaustion; the IR-aware server deletes the entry when the source returned without a `{type:'stop'}` chunk). Suite 28 = 8 HTTP integration tests.
- **D59** (PR #38) — **docs polish.** README § Known limitations bullet inverted to ✅ shipped marker. `docs/v1x-roadmap.md` #1 rewritten to closed state with 3-D-day breakdown. #6 (streaming SPAWN_FAILED salvage) unbundled from #1 because the tee architecture as implemented does not carry salvage semantics. Issue #16 closed with refs to PRs #36 / #37 / #38.
- **Test count:** 603 (v0.3.1) → 623 (v0.3.2). +20 streaming-SF tests (Suite 27 = 12 unit, Suite 28 = 8 HTTP integration).
- **Deferred sub-items (not blocking #1 closure):** (a) `X-OLP-Streaming-Inflight: solo` wire value not emitted — observable only post-stream via `streaming_inflight_source_done` log event's `attached_count: 0`. Future ADR amendment may expose via HTTP trailer. (b) `streaming_inflight_join` log event not emitted from the cache-layer `_attachClient` path because provider/model context lives in the sourceFactory closure (server-layer concern). (c) `isFirst` field returned by `getOrComputeStreaming` is unused by server.mjs (`role` supersedes); could be removed in a future cache-layer API cleanup.
- **Authority:** ADR 0005 Amendment 8 (design ratified at D42 2026-05-25; implementation gated on maintainer "go" — fired 2026-05-25 post-v0.3.1). `docs/v1x-roadmap.md` #1 (closed). GitHub issue #16 (closed). ADR 0002 Amendment 6 (D38 `tryAcquireSpawn`/`releaseSpawn` semantics, now invoked from sourceFactory closure).
**Patch-release classification.** Per `release_kit.phase_rolling_mode` cross-Phase discipline + maintainer release-cut decision (this session, 2026-05-25): the new wire surface (`X-OLP-Streaming-Inflight` header + `streaming_attached` cache_status) is semver-wise a minor bump, but this is roadmap-cleanup work — NOT Phase 4 product scope. The reserved `0.4.0` identifier stays for the formal Phase 4 close. v0.3.2 ships as a patch under the Phase 4 pre-release banner. Tag push triggers `release.yml`.
## v0.3.1 — 2026-05-25
### Post-Phase-3 cleanup batch #1 (D56)
Patch release closing two XS v1.x-roadmap deferrals (`docs/v1x-roadmap.md` #4 + #7) that became actionable now that Phase 3 management endpoints exist. No new feature surface; pins existing behaviour into tests + finally wires the ADR-documented `activeSpawns` field on `/health`.
- **AUTH_MISSING tuple test** (v1.x roadmap #7 / D45 reviewer P3 deferral). New engine-level test in Suite D40 asserts that an `AUTH_MISSING` hop produces a `fallbackDetail` tuple with `trigger_type: 'auth_missing'` AND that the engine does NOT advance past the AUTH_MISSING hop (per ADR 0004 § Decision — `HARD_TRIGGER_CODES[AUTH_MISSING] = false`). Pre-D56 the behaviour was implicit through other engine-path tests; this commit makes it explicit so a future refactor that moves the tuple-push past the auth_missing branch fails this test directly.
- **`/health` `activeSpawns` integration** (v1.x roadmap #4 / ADR 0002 Amendment 6 forward note). `handleHealth` now surfaces `providers.status.<name>.activeSpawns` (sourced from D38 `getActiveSpawnCount(name)`). The field is computed BEFORE `healthCheck()` is awaited so it remains present even when `healthCheck()` throws (cheap in-memory counter read). New Suite 21c-extra test pins the field presence + non-negative value for every enabled provider. With no requests in flight: 0; under saturation: equals `hints.maxConcurrent`.
- **Test count:** 601 (v0.3.0) → 603 (v0.3.1). +2 D56 tests.
- **Authority:** `docs/v1x-roadmap.md` #4 + #7; ADR 0002 Amendment 6 (concurrency observability forward note); ADR 0004 § Decision + Amendment 5 (X-OLP-Fallback-Detail tuple shape).
**Patch-release classification.** Per `release_kit.phase_rolling_mode` cross-Phase discipline: D56 landed on main after v0.3.0 was tagged, so this is a hotfix-class patch — bump patch, tag, release before next push. Tag push triggers `release.yml`.
## v0.3.0 — 2026-05-25
### Phase 3 — Dashboard + audit query layer + daily audit rotation (D48 → D54)
**Overview.** v0.3.0 closes Phase 3 — the dashboard / audit aggregate query / daily rotation track that grew OLP from "audit ndjson exists but is grep-only" (v0.2.0) to a live multi-panel owner-only dashboard with aggregate queries + automatic daily file rotation. 7 D-day commits (D48 through D54) shipped between 2026-05-25 under the standing-autopilot grant. All 15 ADR 0008 § 10 acceptance criteria are implemented + tested.
**Test count: 544 (v0.2.0) → 601 (v0.3.0).** +57 tests across the Phase 3 arc.
**Phase 3 release_kit checklist**
- [x] All 7 D-day deliverables landed on main (D48 ADR + D49-D54 implementation)
- [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 (D49/D50/D51/D52/D53) + D48 ADR draft + D54 docs polish
- [x] All 15 ADR 0008 § 10 acceptance criteria (#1#15) covered by Suite 23/24/25/26/20h-extra-audit tests
- [x] CHANGELOG "Unreleased" promoted to "## v0.3.0 — 2026-05-25" with D48 through D54 entries
- [x] `package.json` bumped 0.2.0 → 0.3.0
- [x] `CLAUDE.md release_kit.phase_rolling_mode`: `current_phase` Phase 3 → Phase 4; `current_pre_release_identifier` `0.3.0-phase3``0.4.0-phase4`
- [x] README status header + Implementation Status + Phase plan reflect Phase 3 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 0008 § 10 acceptance criteria — final ship status**
| # | Criterion | Covering tests |
|---|---|---|
| 1 | `readAuditWindow` iterates events from today + N prior rotated files | Suite 23b-1, 23b-2, 23b-3 |
| 2 | `readAuditWindow` skips malformed lines without throwing + logs warn | Suite 23b-6 |
| 3 | `aggregateRequests` counts by provider / cache_status / owner_tier / path + median/p95 latency | Suite 23c-1, 23c-2, 23c-3 |
| 4 | `topFallbackChains` sort desc by count + tied-count tiebreak | Suite 23d-1, 23d-4 |
| 5 | `spendTrendDaily` sparse-fills zero-request days + UTC day boundaries | Suite 23e-1, 23e-2, 23e-3 |
| 6 | Daily rotation past UTC midnight | Suite 26a-3, 26b-1 |
| 7 | Cross-file query with mixed rotated files | Suite 26e-1 + Suite 23b-1 |
| 8 | Concurrent rotation safety (N appends → 1 rename) | Suite 26c-1 |
| 9 | `GET /dashboard` 200 to owner; 401 to non-owner | Suite 24a, 24b, 24c, 24d |
| 10 | `GET /v0/management/dashboard-data` 200 to owner with all required fields | Suite 24e |
| 11 | `GET /cache/stats` 200 to owner with live stats | Suite 24h |
| 12 | Dashboard HTML smoke (4 panel containers + 30s poll + no external resources) | Suite 25a-25f |
| 13 | Audit row on management endpoints (success + 401) | Suite 24i, 24j |
| 14 | Graceful degradation on `quotaStatus()` throw (panel surfaces null + error) | server.mjs `handleManagementDashboardData` try/catch verified by code |
| 15 | PII guard — no message-content fields in any aggregate output | Suite 23g-1, 23g-2, 23g-3 |
**Phase 3 D-day index**
- **D48** (`c0b6969`) — ADR 0008 Phase 3 design draft (Dashboard + audit query layer) + lane decisions A/A/B/A/B
- **D49** (`686794e`) — `lib/audit-query.mjs` aggregate query layer (5 functions, PII-guarded)
- **D50** (`f9f2eaa`) — `server.mjs` 4 management endpoints (owner_only_block per ADR 0008 § 8) + dashboard.html placeholder
- **D51** (`251b578`) — `dashboard.html` full multi-panel UI (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause)
- **D52** (`408d5a8`) — Daily audit rotation in `lib/audit.mjs` (synchronous trigger on first append after UTC midnight) + `bin/olp-audit-rotate.mjs` external cron tool
- **D53** (`68e50da`) — `tried_providers` schema semantic fix (D45 P2 deferral closed; ADR 0007 § 8 amendment)
- **D54** (`6d9ab1f`) — README Phase 3 polish (docs-only)
**Bonus: also resolved at D53** — D45 fresh-context opus reviewer P2 deferral (`tried_providers` semantics on `key_no_provider_access` 403). ADR 0007 § 8 amended; server.mjs sets `tried_providers = []` on the 403 path so downstream audit queries stay accurate.
**Known limitations carried beyond v0.3.0**
Phase 3 functional scope is complete. The following remain as Phase 4+ deferrals (tracked in `docs/v1x-roadmap.md` + the new Phase 4 entry below):
- **Per-key per-provider auth artifact mapping** — ADR 0007 § 12. Each OLP key independently authenticated to a different provider account.
- **Audit query rotation / retention policies** — ADR 0008 § 11. Currently unbounded; operator manages disk. A Phase 4+ amendment adds `audit_max_days` config when an operational need emerges.
- **SQLite hybrid migration** — ADR 0007 § 13. Trigger: query latency > 2s on typical owner session OR > 5 owners polling. Requires engines bump + CI matrix change as a separate prior PR.
- **Provider-cost weights for spend trend** — ADR 0008 § 11. At v0.3.0 "spend" is proxied by request count; cost integration when commercial cost-tracking lands.
- **Per-key dashboard views** — owner sees aggregate; per-key drill-down is a future amendment.
- **Key-mgmt UI on dashboard** — owner can create / revoke / edit keys from web. Out of Phase 3 scope; needs separate security review per ADR 0008 § 11.
- **Manual smoke for dashboard** — per ADR 0008 § 10 #12 the "no JS console errors in real browser" sub-claim is manual / playwright; Phase 3 acceptance shipped with server-observable checks (Suite 25); a Phase 4+ amendment may add playwright smoke if dashboard complexity grows.
### D54 — README Phase 3 polish (docs-only, no code change)
Seventh Phase 3 D-day. Documentation polish ahead of Phase 3 close (D55, maintainer-triggered). Brings README status header / Implementation Status / API Endpoints / Known limitations / Phase plan up to date with Phase 3 work shipped to main through D48-D53.
- **Status header**: `v0.2.0 shipped``v0.2.0 shipped; v0.3.0 in progress` + lists D48-D54 highlights.
- **Implementation status note**: Phase 3 description updated from "next milestone" to "shipped to main through D54; v0.3.0 release pending maintainer-triggered close (D55)".
- **Implementation Status table** — 4 row updates:
- `lib/audit.mjs`: 🟡 D45-only → ✅ D45 append + D52 rotation; describes both responsibilities.
- `lib/audit-query.mjs`: NEW row (D49 shipped, 5-function aggregate query API).
- `dashboard.html`: 📋 Planned (Phase 6) → ✅ Phase 3 shipped (D50 stub + D51 full UI); describes the 4 panels.
- `bin/olp-audit-rotate.mjs`: NEW row (D52 shipped, external cron tool).
- **API Endpoints table** — `/cache/stats`, `/v0/management/quota`, `/dashboard` (Phase 6 📋 Planned → Phase 3 ✅ Shipped); new `/v0/management/dashboard-data` row; `/health` row clarified to spell out owner-only-trim semantic. Removed the "placeholder — full table lands" stub since the table is now substantively complete.
- **Known limitations** — Phase 2 paragraph kept (now reads as historical Phase 2 completion note); new Phase 3 paragraph summarizing D48-D54 shipped + D55 close pending.
- **Phase plan** — Phase 3 description (was "next") → "🟡 In progress — D48 (ADR) + D49D54 shipped to main 2026-05-25; v0.3.0 close awaits maintainer trigger." Added Phase 4+ entry covering the deferred items (per-key per-provider auth, SQLite hybrid, audit rotation/retention policies, provider-cost weights).
- **Test count:** 601 → 601 (docs-only).
- **Authority:** CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; ADR 0008 § 13 sprint shape (D54 = "E2E + AGENTS / README polish"); standing autopilot grant.
### D53 — `tried_providers` schema semantic fix (D45 P2 deferral closed)
Sixth Phase 3 D-day. Small focused fix for the D45 fresh-context opus reviewer P2 finding that was deferred: `auditCtx.tried_providers` on the `key_no_provider_access` 403 path was being stamped with the ORIGINAL chain (which was filtered out, never dispatched), distorting downstream audit queries like "which providers did key X actually call".
- **`server.mjs` 403 path fix** (around L815): `auditCtx.tried_providers = []` (was `_originalChainProviders`). The configured-but-blocked chain still appears in the human-readable error message body — the audit just doesn't claim those providers were "tried" when the server's filter dispatched zero.
- **ADR 0007 § 8 amendment**: new paragraph spelling out the `tried_providers` semantic — "the list of providers the server actually dispatched a spawn against. A provider that was configured in the chain but filtered out by `providers_enabled` gating is NOT included — the key didn't try the provider, the gate did. On the 403 path `tried_providers` is the empty array." Plus a forward note that audit log rotation moved to Phase 3 / ADR 0008 § 5.
- **Suite 20h-extra-audit (+1 test — 600 → 601):** creates a guest key with `providers_enabled: ['mistral']`; fires a request for an Anthropic-routed model; asserts 403 `key_no_provider_access`; reads the audit row from `audit.ndjson`; asserts `tried_providers === []`. This pins the D53 semantic against regression — if a future change reverts to stamping the original chain, the test fails.
- **Documentation:** CHANGELOG D53 entry; ADR 0007 § 8 amendment.
- **Test count:** 600 → 601 (+1 D53 regression test).
- **Authority:** ADR 0007 § 8 amendment (D53, 2026-05-25); D45 fresh-context opus reviewer P2 deferral note; CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
### D52 — Daily audit rotation (`lib/audit.mjs` extension + `bin/olp-audit-rotate.mjs`)
Fifth Phase 3 D-day. Adds daily UTC-aware rotation to `lib/audit.mjs` per ADR 0008 § 5 + ships an external cron tool. Rotation is **synchronous** at v0.3.0 (Lane 3 = B daily rotation; synchronous design eliminates the race that an async wrapper would create between date-change-detection and the append).
- **`lib/audit.mjs` extended**:
- New `_maybeRotateAudit({ olpHome, logEvent })` (synchronous): probes the live `audit.ndjson`; if it holds events from a past UTC date, renames it to `audit-YYYY-MM-DD.ndjson`. Idempotent. If the target file already exists (cron beat the in-server check), logs warn + skips per ADR 0008 § 5.3 race safety.
- `appendAuditEvent` extended: cheap fast-path date check via module-cached `_lastSeenUtcDate`. On date change, calls `_maybeRotateAudit` synchronously BEFORE `appendFileSync` — so old-date events land in the rotated file and new-date events land in the fresh live file. No event straddles the boundary.
- Why synchronous instead of async: an async wrapper would let the sync `appendFileSync` race the not-yet-completed `renameSync`, landing today's event in the about-to-be-renamed file. Sync rotation is the only correct ordering at the append-fired-from-many-routes scale OLP runs.
- New exports: `_maybeRotateAudit` (sync), `getAuditRotateCount`, `getAuditRotateFailCount`, `__resetAuditRotateState`, `__setLastSeenUtcDateForTesting`.
- First-event-date discovery: when probing the live file's date, reads only the first ndjson line + parses its `ts`. Falls back to file mtime if events absent (corrupt/empty edge).
- **`bin/olp-audit-rotate.mjs`** (~95 lines): external cron tool per ADR 0008 § 5.2. Calls `_maybeRotateAudit` once + reports outcome. Exit codes 0 (success or no-op), 1 (bad usage), 2 (rotation failed). Installed via `package.json bin` so `npx olp-audit-rotate [--olp-home=<path>]` works. Example cron line documented in the file header.
- **Concurrent-safety semantics** (ADR 0008 § 5.3): in-process sequential appends after the first date-change detection short-circuit via the updated `_lastSeenUtcDate` cache → exactly 1 rename even under N sequential appends. Cross-process (cron + server) coexistence handled by the "target already exists → skip + warn" branch.
- **Test surface (Suite 26, +12 tests — 588 → 600):**
- 26a-1..5: `_maybeRotateAudit` (no live file / today already / yesterday→rotate / idempotent re-call / cron-race target-exists warn)
- 26b-1: `appendAuditEvent` past UTC date change triggers sync rotation + append lands in fresh live file
- 26c-1: 10 sequential `appendAuditEvent` across date change → exactly 1 rotation + all 10 events in new live file
- 26d-1..4: `bin/olp-audit-rotate.mjs` CLI (--help / no-live-file / yesterday-file-rotates / unknown-flag exit 1)
- 26e-1: rotated files queryable via `lib/audit-query.mjs` `discoverAuditFiles` + `readAuditWindow` cross-file read
- **`package.json`**: `bin.olp-audit-rotate` + `scripts.olp-audit-rotate` entries added.
- **Documentation:** AGENTS.md `lib/audit.mjs` marker promoted to ✅ (D45 append + D52 rotation both shipped); new `bin/olp-audit-rotate.mjs` entry.
- **Test count:** 588 → 600 (+12 D52 tests in Suite 26).
- **Authority:** ADR 0008 § 5.1 (first-append-after-UTC-midnight trigger), § 5.2 (external cron alternative), § 5.3 (concurrent-rotation safety + cron-coexistence semantics), § 5.4 (renamed-file query path consumed by D49 lib/audit-query.mjs); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
### D51 — `dashboard.html` full multi-panel UI (Phase 3)
Fourth Phase 3 D-day. Replaces the D50 `dashboard.html` placeholder with the full 4-panel UI per ADR 0008 § 6. Vanilla HTML + JS + fetch — no build step, no framework, no CDN (Lane 1 = A). 30s page poll with `document.visibilityState` pause/resume (Lane 4 = A).
- **4 panels rendered from `/v0/management/dashboard-data`** (the single backing endpoint, per Lane 2 in-memory query model):
- **Panel 1 — Per-provider quota**: table of `{ Provider | Available | Status }`; surfaces `null` available as "n/a" + capturing per-provider `provider.quotaStatus()` errors as a red status pill (graceful degradation per ADR § 9).
- **Panel 2 — Last 24h: request count + cache hit + fallback rate**: per-provider row of `{ Requests | Cache hit % | Fallback rate % }`. Cache hit sourced from `cache_hit_24h.by_provider[p].hit_rate`; fallback rate computed from `window_24h.by_provider[p].fallback_count / count`.
- **Panel 3 — Request count last 30 days (SVG sparkline)**: vanilla SVG bar chart with `<title>` tooltips showing per-day per-provider breakdown. Y-axis: requests per day (scaled to max); X-axis: 30 daily buckets (UTC). Each bar `<title>` includes the date + total count + provider breakdown.
- **Panel 4 — Top fallback chains (last 24h)**: numbered table of `{ # | Chain | Count | First seen | Last seen }` with chain arrows rendered in monospace (`anthropic → openai`).
- **30s poll + visibilitychange pause** (ADR 0008 § 6.5):
- `setInterval(refresh, 30000)` after the initial fetch.
- `document.addEventListener('visibilitychange', ...)``stopPolling()` on hidden / `refresh() + startPolling()` on visible.
- Per ADR § 6.5 this prevents 2880 background polls/day per owner when the dashboard tab is in the background.
- **Error handling**:
- 401 from `/v0/management/dashboard-data` → in-page error banner explains owner-tier requirement + suggests SSH-tunnel + header-injection workaround (browsers can't natively send `Authorization: Bearer` without a proxy/extension).
- Other HTTP errors → generic "HTTP <code>" banner; console.warn for operator debugging.
- Per-panel "Loading…" / "No requests in window." / "No fallback chains triggered" empty states.
- **DOM helpers**: small `el(tag, attrs, ...children)` + `svgEl(tag, attrs)` factories — no framework, ~10 lines each. Sparkline uses native `<title>` for tooltips (no JS hover handlers).
- **Critical correctness invariants** (per ADR 0008 § 6 + Lane 1 = A):
- No `<script src>` — entire JS inline in `<script>` tag (Suite 25d asserts).
- No `<link rel="stylesheet" href=>` — all CSS in `<style>` tag (Suite 25d asserts).
- Only one backing endpoint hit: `/v0/management/dashboard-data` (Suite 25e asserts). All 4 panels consume slices of its response.
- 401 path keeps panels in last-good state rather than clearing them; operator sees the error banner + can debug.
- **Test surface (Suite 25, +6 tests — 582 → 588):**
- 25a: owner /dashboard response contains all 4 panel container IDs (`panel-quota`, `panel-24h`, `panel-trend`, `panel-chains`).
- 25b: dashboard JS declares `POLL_INTERVAL_MS = 30000` + uses `setInterval` + `clearInterval`.
- 25c: visibilitychange listener wired + checks `document.visibilityState === 'hidden'`.
- 25d: NO external `<script src>` and NO external stylesheet `<link href>` — pinning Lane 1 = A.
- 25e: dashboard JS fetches `/v0/management/dashboard-data` (the single consolidated D50 endpoint).
- 25f: 401 in-page error banner mentions owner-tier so a maintainer who lands on a 401 knows the route forward.
- **Manual smoke (ADR 0008 § 10 #12 manual acceptance)**: the dashboard renders without console errors in a real browser when served by a running OLP instance + owner-tier Bearer token injected via SSH-tunnel + header-injection extension. Not automated at Phase 3 (Lane 4 = A poll model doesn't need playwright; Phase 4+ may add a playwright smoke if dashboard complexity grows).
- **Documentation:** AGENTS.md `dashboard.html` marker promoted from 🟡 D50 placeholder to ✅ D51 full UI.
- **Test count:** 582 → 588 (+6 D51 tests in Suite 25).
- **Authority:** ADR 0008 § 6 (panels + refresh + localhost) + § 6.5 (poll + visibilityState pause) + Lane 1 = A (no build step) + Lane 4 = A (30s poll) + Lane 5 = B (full 4-panel scope); ADR § 9 (graceful degradation surfaced in Panel 1); ADR § 10 criterion #12 (HTML smoke); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
### D50 — `server.mjs` management endpoints (Phase 3 dashboard wire-up)
Third Phase 3 D-day. Wires the D49 `lib/audit-query.mjs` aggregate query layer into 4 owner_only_block HTTP endpoints per ADR 0008 §§ 7-8. Ships a placeholder `dashboard.html` at repo root (D51 lands the full multi-panel UI). All endpoints follow the Phase 2 / D45 auth + audit + touchLastUsed pattern.
- **4 new endpoints** (all owner_only_block per ADR 0008 § 8 — anonymous + guest + missing-key all → 401):
- `GET /dashboard` — serves `dashboard.html` (Content-Type text/html; charset=utf-8). D50 stub explains the state + lists backing endpoints; D51 replaces with full UI.
- `GET /v0/management/dashboard-data` — full aggregate per ADR 0008 § 7.2: `{ generated_at, window_24h (auditAggregateRequests), cache_hit_24h (auditCacheHitRateWindow), quota (per-provider provider.quotaStatus + error capture), spend_trend_30d (auditSpendTrendDaily — exactly 30 entries), top_fallback_chains_24h (auditTopFallbackChains limit 10), cache_stats (live cacheStore.stats()) }`.
- `GET /v0/management/quota` — quota subset only (subset of dashboard-data; useful for scripted monitoring).
- `GET /cache/stats` — live in-memory `cacheStore.stats()` shape (`{ hits, misses, size, inflightCount }` + `generated_at` wrapper).
- **`_runOwnerOnlyManagementEndpoint(req, res, method, path, inner)` helper** factors the common auth + audit ctx + owner-block + res.on('finish') wire. inner is async (req, res, olpIdentity, auditCtx) → returns void. Eliminates 4× boilerplate.
- **`owner_only_block` mode** (ADR 0008 § 8): authenticate → if not owner → 401 `owner_required`. Distinct from `owner_only_trim` (Phase 2 /health pattern). Anonymous identity (when `allow_anonymous: true`) reaches the handler and is 401'd by the owner check — verified by Suite 24c.
- **Provider quotaStatus error capture**: dashboard-data + quota endpoints catch per-provider throws and surface `{ provider, error, available: null }` so one bad provider doesn't fail the whole panel (ADR 0008 § 9 graceful degradation).
- **`dashboard.html` placeholder** (~50 lines at repo root): explains the D50 state, lists backing endpoints with curl example. Cached in memory at first /dashboard request (`_loadDashboardHtml` with module-scope `_dashboardHtmlCache`); falls back to an in-memory stub if the file is missing (e.g., test imports from non-repo cwd).
- **Audit on management endpoints** (ADR 0008 § 7.5): every management request appends an audit row including 401 paths (verified by Suite 24j). Touch wire skips anonymous + env-owner identities (matches Phase 2 pattern).
- **Router**: 4 new GET branches added between /v1/chat/completions and the 404 fallback.
- **Test surface (Suite 24, +11 tests — 571 → 582):**
- 24a-d: /dashboard owner_only_block (owner 200 / guest 401 / anonymous-with-allow_anonymous=true 401 / no-auth-with-allow_anonymous=false 401)
- 24e: dashboard-data owner → 200 JSON with all required ADR § 7.2 fields (asserts `spend_trend_30d.length === 30`)
- 24f: dashboard-data guest → 401 owner_required
- 24g: quota owner → 200 JSON with quota array
- 24h: cache/stats owner → 200 JSON with `{ hits, misses, size, inflightCount, generated_at }`
- 24h-401: cache/stats guest → 401
- 24i: successful dashboard-data appends audit row with `status_code: 200` + `key_id` + `path: '/v0/management/dashboard-data'`
- 24j: 401 (guest blocked) dashboard-data appends audit row with `error_code: 'owner_required'` + `owner_tier: 'guest'`
- **Documentation:** AGENTS.md `lib/audit-query.mjs` D49 marker note added + new `dashboard.html` entry (D50 placeholder).
- **Test count:** 571 → 582 (+11 D50 tests in Suite 24).
- **Authority:** ADR 0008 § 7 (endpoints) + § 8 (owner_only_block mode) + § 9 (graceful degradation) + § 7.5 (audit on management endpoints); ADR 0007 § 7 (auth model reused); ADR 0002 § Provider contract (quotaStatus); ADR 0005 (cacheStore.stats); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
### D49 — `lib/audit-query.mjs` audit aggregate query layer (Phase 3)
Second Phase 3 D-day. Implements ADR 0008 § 4 query API. Pure in-memory ndjson scan; cross-file walk over `audit.ndjson` (live) + `audit-YYYY-MM-DD.ndjson` (rotated). No server.mjs integration in this D-day (D50 wires the consuming endpoints).
- **New file `lib/audit-query.mjs`** (~370 lines): 5 public API functions per ADR 0008 § 4.1:
- `discoverAuditFiles({ olpHome })` — filesystem scan; returns `Map<date|'live', path>`.
- `readAuditWindow({ startMs, endMs, olpHome, logEvent })` — generator over events in half-open window [startMs, endMs). Walks rotated date files + live file. Skips malformed lines + logs warn.
- `aggregateRequests({ windowMs, olpHome })` — counts + status buckets + by_provider + by_owner_tier + by_path + median/p95 latency over rolling window.
- `topFallbackChains({ windowMs, limit, olpHome })` — top-N chains by trigger count from events with `fallback_hops > 0`. Tied-count tiebreak: ascending first_seen.
- `spendTrendDaily({ days, olpHome })` — daily series ending today with sparse-fill for zero-request days. Per-day request_count + median latency + by_provider breakdown.
- `cacheHitRateWindow({ windowMs, olpHome })` — audit-derived cache hit rate (bypass excluded from denominator); per-provider + overall.
- **PII discipline** (ADR 0008 § 4.3): every aggregate function relays only schema fields; never message content. Suite 23g actively asserts the absence of `content`/`message`/`messages`/`prompt`/`response`/`body` keys in every aggregate output.
- **Cross-file walk semantics** (ADR 0008 § 4.2): half-open window [startMs, endMs); date-range computed once from window bounds; each rotated date file checked; live `audit.ndjson` always checked (it covers today regardless of whether the window endpoint is past midnight).
- **`spendTrendDaily` calendar-date semantics**: `days: N` returns "last N calendar UTC dates ending today" — NOT "events within a rolling N×86400-ms window" (which would span N+1 distinct UTC dates and produce off-by-one buckets at non-midnight call times). Computed via `for (let i = days-1; i >= 0; i--) dates.push(_utcDateFromMs(now - i*86400*1000));`.
- **`cacheHitRateWindow` denominator**: hit_rate = hit / (hit + miss). Bypass is intentional non-cacheable (Anthropic cache_control marker), NOT a cache miss; excluding it from the denominator gives a clean cache-effectiveness signal.
- **Test surface (Suite 23, +27 tests — 544 → 571):**
- 23a-1..4: `discoverAuditFiles` (empty dir / live only / live+rotated / non-audit files ignored)
- 23b-1..6: `readAuditWindow` (all-coverage / single-day / half-open exclusivity / empty window / missing files / malformed-skip with warn)
- 23c-1..4: `aggregateRequests` (counts + status buckets + by_provider; by_owner_tier; median+p95 latency over realistic distribution; invalid windowMs rejection)
- 23d-1..4: `topFallbackChains` (sort desc by count; limit truncation; fallback_hops=0 excluded; first_seen/last_seen carried)
- 23e-1..3: `spendTrendDaily` (N-day range correctness; populated day breakdown; empty day sparse-fill)
- 23f-1..3: `cacheHitRateWindow` (overall + per-provider hit_rate; bypass not in denominator; cache_status=null events excluded)
- 23g-1..3: PII guard for `aggregateRequests` / `spendTrendDaily` / `topFallbackChains` + `cacheHitRateWindow` — every output JSON-stringified + scanned for forbidden PII keys
- **Documentation:** AGENTS.md `lib/audit-query.mjs` new entry; `lib/audit.mjs` note added that D52 extends with daily rotation.
- **Test count:** 544 → 571 (+27 D49 tests).
- **Authority:** ADR 0008 § 4 (query API surface) + § 5 (rotation file naming pattern) + § 3 (storage layout); ADR 0007 § 8 (audit ndjson event schema — input data); CLAUDE.md `release_kit overlay phase_rolling_mode` — under Unreleased; standing autopilot grant.
### D48 — ADR 0008 Phase 3 design draft (Dashboard + audit query layer)
First Phase 3 D-day. Design-only. Ratifies the storage / query model / rotation / dashboard / refresh / scope decisions ahead of D49+ implementation D-days. Opens ADR 0007 § 12 deferral for Dashboard + audit query layer + rotation.
- **New file `docs/adr/0008-dashboard-and-audit-query.md`** (~390 lines): 13 sections + Consequences + Authority citations. Decisions per maintainer-pinned lanes:
- Lane 1 (tech stack): static HTML + vanilla JS + fetch (no build step; matches OLP "no bundler" ethos)
- Lane 2 (query model): in-memory scan of audit ndjson per request (defers SQLite hybrid per ADR 0007 § 13)
- Lane 3 (rotation): daily rotation, `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight + optional `bin/olp-audit-rotate.mjs` external cron
- Lane 4 (refresh): 30s page poll (no SSE infra at v0.3.0)
- Lane 5 (dashboard scope): full per spec § 4.6 — 4 panels (quota / per-provider 24h counts / 30d spend trend / top fallback chains)
- **`docs/adr/README.md` index**: added ADR 0008 row with one-paragraph summary.
- **CHANGELOG.md** Unreleased: this entry.
- **Phase 3 sprint shape:** D49 `lib/audit-query.mjs` + Suite 23 → D50 `/v0/management/*` endpoints + Suite 24 → D51 `dashboard.html` → D52 daily audit rotation + Suite 25 → D53 `tried_providers` schema fix (D45 P2 deferral) → D54 E2E + docs → D55 Phase 3 close → v0.3.0 (maintainer-triggered).
- **Fold-in (fresh-context opus reviewer findings — 1 P2 + 2 P3, all ADR-text polish):**
- **P2 § 8 + § 10 #9 gating-mode wording** — original § 8 implied a new "block non-owner identities" behaviour without naming it; § 10 #9 tested only the universal `allow_anonymous: false` 401 case. Fix: § 8 now formalizes two gating modes — `owner_only_trim` (Phase 2 /health pattern) vs `owner_only_block` (new Phase 3 management-endpoints pattern) — and explains the management endpoints are `owner_only_block` because the entire payload is sensitive. § 10 #9 now covers both 401 paths (with `allow_anonymous: true` + no header → anonymous identity → still 401 because management endpoints are `owner_only_block`; AND with `allow_anonymous: false` + no header → 401 at the authenticate middleware itself).
- **P3 `/cache/stats` citation accuracy** — original § 7.4 + Authority block cited "ADR 0005 § Cache stats" which is not a real section. Corrected: planning authority is OLP v0.1 spec § 4.6; ADR 0005 references the endpoint in `Consequences/Mitigations` (~line 279) for the per-`(provider, model)` cache-hit-rate breakdown surface.
- **P3 `cacheStore.stats()` shape gap** — § 7.4 now explicitly acknowledges the current shape (`{ hits, misses, size, inflightCount }` global aggregate) lacks the per-`(provider, model)` breakdown spec § 4.6 implies; Phase 3 Panel 2 sources per-provider counts from `aggregateRequests` (audit-side) instead. If a future panel needs the breakdown, D50 amends the store shape + an ADR 0005 amendment fires at that time. Phase 3 acceptance criteria do not require the breakdown.
- **Test count:** 544 → 544 (design-only, no test change).
- **Authority:** ADR 0007 § 12 (opens deferral) + § 13 (rejects SQLite at Phase 3 per Node baseline); v0.1 spec § 4.6 / § 4.7 (Dashboard + observability endpoints planning authority); OCP `dashboard.html` (prior art); CC 开发铁律 v1.6 § 10 — fresh-context opus reviewer required for design ADR; Phase 3 kickoff via maintainer "go" 2026-05-25 + standing-autopilot grant; PR #25 fresh-context opus reviewer findings (3 polish items).
## 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
+2 -2
View File
@@ -135,7 +135,7 @@ release_kit:
# 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
# before filing a compliance finding.
current_phase: Phase 1
current_pre_release_identifier: "0.1.0-bootstrap"
current_phase: Phase 6
current_pre_release_identifier: "0.6.0-phase6"
phase_close_trigger: explicit maintainer action (not automated)
```
+437 -65
View File
@@ -1,62 +1,230 @@
# OLP — Open LLM Proxy
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 + fallback + content-addressed caching. 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.5.1 shipped, 759+ tests. Phase 5 (Quota Probes + Dashboard Enrichment) closed; Phase 6 next. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
---
## Why OLP
## What you get
On 2026-05-14, Anthropic announced (effective 2026-06-15) that `claude -p`, the Agent SDK, and third-party agent traffic move out of the Pro/Max subscription pool into a separate fixed monthly Agent SDK Credit pool. [OCP](https://github.com/dtzp555-max/ocp), OLP's predecessor, was a proxy around a single CLI — its core assumption was *"subscription = unlimited within rate limits"*. That assumption breaks for Anthropic on the effective date.
The structural response is to stop relying on one provider's subscription terms remaining favourable. OLP spreads risk across multiple providers whose subscriptions still include CLI/programmatic use, routes intelligently between them, and caches aggressively so every request that does spawn a CLI counts.
OLP is **not**: a commercial multi-tenant SaaS; an enterprise gateway competing with LiteLLM / OpenCode / CLIProxyAPI on breadth; a model-capability router ("route to the smartest model" — you pick the model); a conversation-state store (your client handles that).
See [`ALIGNMENT.md`](./ALIGNMENT.md) for OLP's constitution and [`docs/adr/`](./docs/adr/) for the founding ADRs.
- **OpenAI-compatible** `/v1/chat/completions` endpoint — any IDE that speaks OpenAI (Cline / Continue.dev / Cursor / Aider) plugs in
- **Multi-provider chain** — primary fails / quota dies → automatically falls back to the next provider (anthropic ↔ codex ↔ mistral by default; risk-tier framework guards which ones get enabled)
- **Content-addressed cache** — repeat requests don't re-spawn the CLI; streaming requests dedup via singleflight tee
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
- **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
- **AI-driven self-repair** — `olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
- **Observability** — owner-only `/dashboard` (live Claude.ai-style plan-usage rows / 24h stats / 30d spend trend / top fallback chains)
- **Plan-usage probe** (Phase 5, v0.5.0) — opt-in per-provider quota probe for Anthropic Pro/Max subscriptions; parses the canonical `anthropic-ratelimit-unified-*` response headers, surfaces 5-hour + 7-day utilization with reset countdowns. See [§ Plan Usage](#plan-usage-live-quota-probe).
---
## Quick Start
## Install with your AI (the fast path)
_placeholder — lands with Phase 1._
If the manual steps feel like a lot, paste this verbatim into your AI coding assistant (Claude Code / Cursor / Copilot / Aider). It walks you through everything:
Anticipated shape:
```
I want to install OLP (Open LLM Proxy) on this machine. The repo is at
https://github.com/dtzp555-max/olp and ships these tools that you can use:
1. `git clone` the repo to ~/olp
2. README.md § "Manual install" has the sequenced steps. Follow them in order.
3. After EACH step, run `node ~/olp/bin/olp.mjs doctor --json` (NOT `npx olp`
— the package isn't on npm). Parse the JSON. The `kind` field tells you
the state: noop / fresh_install / fix_server / fix_oauth / fix_provider /
fix_config.
4. If `kind != noop`, run the commands listed in `next_action.ai_executable[]`
verbatim. Then re-run doctor to verify.
5. STOP and ask me only when `next_action.human_required[]` is non-empty.
That's where I need to do a browser OAuth flow you can't do for me.
The provider CLIs OLP spawns (claude / codex / vibe) need their own one-time
OAuth — those are the only steps I personally have to do (Claude.ai login,
ChatGPT login, Mistral API key). Everything else (clone, npm install of the
provider CLIs, owner-key generation, config.json bootstrap, server start) is
in your `ai_executable[]` and you should run it without asking.
Begin.
```
Then sit back and respond when it asks for OAuth confirmation. This pattern works because `olp doctor` is purpose-built for AI consumption — every failure mode has a shell-executable repair command AND a human-required step listed separately.
---
## Manual install (5-10 min)
### 0. Prerequisites
- **Node.js ≥ 18.** Verify: `node --version`
- **The provider CLIs you want OLP to spawn.** Install whichever you'll actually use:
| Provider | Install | Subscription |
|---|---|---|
| `anthropic` (`claude -p`) | `npm install -g @anthropic-ai/claude-code` | Claude Pro/Max (OAuth) |
| `openai` (`codex exec`) | `npm install -g @openai/codex` | ChatGPT Plus/Pro (OAuth) or OpenAI API key |
| `mistral` (`vibe --prompt`) | follow the `vibe` install docs | Le Chat Pro API key |
You only need to install the ones you'll route to. Single-provider OLP works fine.
### 1. Clone and verify the test suite
```bash
# install
npm install -g @dtzp555-max/olp
# run setup (writes ~/.olp/config.json, asks which providers to enable)
olp setup
# start the proxy (default port 3456 — same as OCP if you migrate)
olp start
# point your IDE at http://localhost:3456/v1/chat/completions with the OLP API key from `olp keys list`.
git clone https://github.com/dtzp555-max/olp.git ~/olp
cd ~/olp
npm test # 714+ tests, ~5s, no external deps
```
(If `npm test` fails here, stop — that means your Node version or the repo state is broken. Don't proceed to step 2.)
### 2. Bootstrap the owner key
The owner key is what you (and `olp-connect`) use to authenticate to OLP. Default config has `auth.allow_anonymous: false`, so you need a key BEFORE the server starts accepting requests.
```bash
node ~/olp/bin/olp-keys.mjs keygen --owner --name=$(whoami)-laptop
# Prints the plaintext token ONCE. Copy it now — you can't recover it later.
# Example: olp_l23-PN46tDljmPATV94-KfOgOBO0Ed8theVjTdAgQoY
```
Export it so the CLI subcommands can use it:
```bash
export OLP_API_KEY=olp_l23-PN46... # paste your real token
```
(Add to `~/.bashrc` / `~/.zshrc` to persist.)
### 3. Authenticate the providers (one-time OAuth)
Run each provider's own login flow. OLP's anthropic / openai / mistral plugins spawn these CLIs and reuse their cached credentials — OLP itself never touches the OAuth dance.
```bash
# Anthropic (Claude Pro/Max subscription)
claude setup-token
# Opens a TUI / prints a URL. Authorize in browser. Paste the returned code.
# Result: ~/.claude/.credentials.json
# OpenAI (ChatGPT subscription)
codex login --device-auth
# Prints a https://auth.openai.com/codex/device URL + 10-char code.
# Open URL in browser, enter code, authorize.
# Result: ~/.codex/auth.json
# Mistral (Le Chat API key)
export MISTRAL_API_KEY=sk-... # add to ~/.bashrc to persist
```
### 4. Write a minimum config
`~/.olp/config.json`:
```json
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": [
"/health",
"/v0/management/dashboard-data",
"/v0/management/quota",
"/v0/management/status",
"/cache/stats",
"/dashboard"
],
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "anthropic": true, "openai": true }
},
"routing": {
"chains": {
"claude-sonnet-4-6": [
{ "provider": "anthropic", "model": "claude-sonnet-4-6" },
{ "provider": "openai", "model": "gpt-5.5" }
],
"gpt-5.5": [{ "provider": "openai", "model": "gpt-5.5" }]
}
},
"streaming": { "heartbeat_interval_ms": 15000 }
}
```
(Enable only the providers you actually authenticated in step 3. Chains map `<your-IDE's-requested-model>` → ordered list of `{provider, model}` hops; the chain's per-hop `model` is what gets passed to that provider's CLI.)
### 5. Start the server
```bash
cd ~/olp
npm start
# OLP v0.4.3 listening on :4567 (2 providers enabled)
```
### 6. Smoke-test
```bash
curl -H "Authorization: Bearer $OLP_API_KEY" http://localhost:4567/health | jq
# Expect: {ok: true, providers: {enabled: 2, status: {anthropic: {ok: true...}, openai: {ok: true...}}}}
node ~/olp/bin/olp.mjs doctor
# Expect: "9 of 9 checks passed", kind=noop
```
### 7. Point your IDE at OLP
```
OPENAI_BASE_URL=http://localhost:4567/v1
OPENAI_API_KEY=$OLP_API_KEY
```
Per-IDE configuration details: [`docs/integrations/`](./docs/integrations/README.md).
---
## Family / LAN setup
To let other devices on your home network use the same OLP server, you need TWO things:
1. **Bind to the LAN interface** (not just loopback). On the SERVER:
```bash
OLP_BIND=0.0.0.0 npm start # or your specific LAN IP, e.g. 192.168.1.10
```
Default is `127.0.0.1` (loopback only). See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — **never set `OLP_BIND=0.0.0.0` on a public-internet-facing host** (use a tunnel like Tailscale instead).
2. **Onboard each family member's device** from THEIR machine:
```bash
# Pinned to a known-good release (recommended — survives GitHub raw CDN cache hiccups):
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/v0.4.4/bin/olp-connect) <olp-host-ip>
# OR latest from main (use after v0.4.4 + once you trust head):
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect) <olp-host-ip>
```
Detects Cline / Continue.dev / Cursor / Aider / OpenClaw locally and writes per-tool config pointing at your OLP host. Requires `python3` on the client. Prompts for the OLP API key — OR, if the server has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, picks the token up from `/health.anonymousKey` (zero out-of-band paste). See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Telegram / Discord `/olp` slash command setup: [§ Telegram / Discord Usage](#telegram--discord-usage).
---
## Supported Providers
Source of truth: [`models-registry.json`](./models-registry.json). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync.
Source of truth: [`models-registry.json`](./models-registry.json). Per-provider columns are sourced from the registry's `providers.<key>` block (model metadata + tier) and `quota_probe.<key>` block (D81+; probe status / reason / source). This table is regenerated from the registry per the [`release_kit`](./CLAUDE.md) overlay; do not edit it out of sync.
OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned) from **Enabled Providers** (authority pin filled + plugin landed + Phase audit passed). The v0.1 founding commit ships **zero Enabled Providers** — enablement is a Phase audit deliverable, not a bootstrap claim. See [`ALIGNMENT.md` § Provider Inventory](./ALIGNMENT.md) for the transition gate.
### Candidate Providers
| Provider key | CLI | Subscription / auth | Anticipated Tier | Anticipated Phase |
|---|---|---|---|---|
| `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | D (re-eval post-2026-06-15) | Phase 1 |
| `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | D | Phase 2 |
| `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | D | Phase 3 |
| `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | C | Phase 8+ |
| `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | C | Phase 8+ |
| `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | B | Phase 8+ |
| `glm` | TBD | Zhipu Coding Plan ($10+/mo) | B | Phase 8+ |
| `qwen` | TBD | Alibaba Coding Plan ($50/mo) | B | Phase 8+ |
| Provider key | CLI | Subscription / auth | Quota probe (v0.5.0+) | Anticipated Tier | Anticipated Phase |
|---|---|---|---|---|---|
| `anthropic` | `claude -p` | Pro / Max OAuth (pre-2026-06-15); Agent SDK Credit pool after | ✅ Live (13 `anthropic-ratelimit-unified-*` headers; opt-in via `quota_probe_enabled`) | D (re-eval post-2026-06-15) | Phase 1 |
| `openai` | `codex exec --json` | ChatGPT Pro OAuth or API key | ❌ Not available (no public quota API) — audit-derived spend tracking only | D | Phase 2 |
| `mistral` | `vibe --prompt --output json` | Le Chat Pro API key | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26. Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) does expose billing / usage queries but requires an org-admin scope (out of scope for OLP family-tier deployment). Audit-derived spend tracking only at v0.5.0. | D | Phase 3 |
| `grok` | `grok -p --output-format streaming-json` | xAI Build `xai-...` API key | TBD (Phase 8+) | C | Phase 8+ |
| `kimi` | `kimi -p --output-format stream-json` | Moonshot Kimi API key | TBD (Phase 8+) | C | Phase 8+ |
| `minimax` | TBD | MiniMax Token Plan (¥29+/mo) | TBD (Phase 8+) | B | Phase 8+ |
| `glm` | TBD | Zhipu Coding Plan ($10+/mo) | TBD (Phase 8+) | B | Phase 8+ |
| `qwen` | TBD | Alibaba Coding Plan ($50/mo) | TBD (Phase 8+) | B | Phase 8+ |
**Risk tier guide.** D = permissive / safe (eligible for default-enabled); C = tightening signal, no enforcement history (opt-in); B = service-level key revocation risk (opt-in + consent); A = excluded by default (cannot be opt-in enabled). Tier B providers prompt for explicit consent on first enable and record consent in `~/.olp/config.json`. See [`ALIGNMENT.md` § Risk Tier Framework](./ALIGNMENT.md#risk-tier-framework).
@@ -66,12 +234,19 @@ OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned)
## Configuration
_placeholder — full configuration reference lands with Phase 4 (fallback engine)._
OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
OLP reads `~/.olp/config.json` at startup. § "[Manual install § Step 4](#4-write-a-minimum-config)" above has a working minimum example. The full schema:
```json
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": ["/health", "/dashboard", "/v0/management/..."],
"advertise_anonymous_key": false,
"fallback_detail_header_policy": "owner_only"
},
"providers": {
"enabled": { "<provider-key>": true }
},
"routing": {
"chains": {
"<requested-model>": [
@@ -82,28 +257,92 @@ OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
"soft_triggers": {
"<provider-key>": { "<trigger>": <threshold> }
}
},
"streaming": {
"heartbeat_interval_ms": 0
}
}
```
> **Note:** `routing.soft_triggers` thresholds are parsed and stored but have **no runtime effect at v0.1** — the quota polling path (`quotaStatus()` per hop) is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). The evaluation logic exists and is tested; only the production data ingestion path is deferred.
Field guide:
Trigger types, fallback safety, idempotency rules, and the full example config land here when Phase 4 ships. See [ADR 0004 (Fallback Engine Semantics & Safety)](./docs/adr/0004-fallback-engine.md) for the design.
- **`auth.allow_anonymous`** — default `false`. When false, every request needs a Bearer token; when true, anonymous-tier requests succeed (ADR 0007 § 7). Production posture is `false`.
- **`auth.owner_only_endpoints`** — list of endpoints that REQUIRE owner-tier auth (non-owner returns 401). The defaults above are minimum sane for production.
- **`auth.advertise_anonymous_key`** — default `false`. When true (+ `allow_anonymous: true` + a key created with `olp-keys keygen --anonymous --advertise`), `/health.anonymousKey` exposes the plaintext token so `olp-connect <ip>` is zero-config. **Trusted-LAN only** — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md).
- **`auth.fallback_detail_header_policy`** — controls `X-OLP-Fallback-Detail` response header emission. `owner_only` (default) only shows tuples to owner identity; debug surface to LAN family without leaking to anonymous.
- **`providers.enabled`** — flip a provider plugin on. Only enable providers whose CLI you've authenticated; OLP doesn't do its own OAuth.
- **`routing.chains`** — keyed by the model name your IDE / client requests. Each entry is an ordered list of fallback hops; each hop's `model` is what gets passed to that provider's CLI. F7 fix (D75) — the hop-level `model` field finally overrides the IR's request model during cross-provider fallback.
- **`routing.soft_triggers`** — parsed and stored but **inert at v0.4.x** — the `quotaStatus()` polling data path is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). Startup emits a warn if non-empty so the inert state is visible.
- **`streaming.heartbeat_interval_ms`** — default `0` (disabled). Set > 0 (e.g. `15000`) to emit SSE keepalive frames during silent windows. Required behind reverse proxies (nginx / Cloudflare Tunnel / Tailscale Funnel) with 60s idle aborts.
See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007 (Multi-key auth)](./docs/adr/0007-multi-key-auth.md), [ADR 0010 (Phase 4 charter)](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md), [ADR 0011 (Anonymous-key deployment)](./docs/adr/0011-anonymous-key-deployment-context.md).
---
## Plan Usage (live quota probe)
OLP v0.5.0+ surfaces live subscription quota for Anthropic Pro/Max subscribers on the owner-only `/dashboard`. Per-provider rows show 5-hour and 7-day utilization bars with reset countdowns, status badges, representative-claim hints, and a manual refresh button. The panel auto-refreshes every 60 seconds and pauses when the tab is hidden.
![OLP v0.5.1 dashboard — Plan Usage panel with live anthropic quota (utilization 5h: 6%, 7d: 38%, status: live)](./docs/img/dashboard-v0.5.1.png)
### How it works
The probe issues a minimal `POST /v1/messages` to `api.anthropic.com` (max_tokens: 1) using the same OAuth token Claude Code uses for `claude -p`. The body is discarded; only the 13 `anthropic-ratelimit-unified-*` response headers are parsed (5h/7d utilization + reset, status, representative-claim, fallback-percentage, overage status + disabled reason). Results cache for 5 minutes; refresh failures fall back to the previous cache marked `stale: true` while exponential backoff (60s → 3600s) protects against hammering the API.
See [ADR 0002 § Amendment 8](./docs/adr/0002-plugin-architecture.md), [ADR 0012 (Phase 5 charter)](./docs/adr/0012-phase-5-charter-quota-probes-dashboard.md), [ADR 0013 (OAuth READ-ONLY consumption + schema-drift mitigation)](./docs/adr/0013-oauth-read-only-consumption-and-schema-drift.md), and the schema pin at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`.
### Enabling the probe
The probe is **opt-in** (default off) per ADR 0013 Rule 4 — a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes. To enable, add to `~/.olp/config.json`:
```json
{
"providers": {
"anthropic": {
"enabled": true,
"quota_probe_enabled": true
}
}
}
```
The probe reads the OAuth token from (in order): `CLAUDE_CODE_OAUTH_TOKEN` env var, `~/.claude/.credentials.json`, macOS Keychain entry `"Claude Code-credentials"`. Make sure Claude Code is logged in (`claude setup-token` or equivalent) before opting in.
`olp doctor` adds a `anthropic.quota_probe_reachable` check when the probe is enabled. The check has `category: 'provider'`, so any failure (401/403 token-expiry, 429 rate-limit, network error) discriminates to `kind: fix_provider`. The `human_steps` recovery recipe inside the check distinguishes the underlying cause (re-login via `claude setup-token` for auth failures vs wait-and-retry for rate-limit) — the discriminator is uniformly `fix_provider` but the actionable text is auth-aware. Successful probes return `status: ok` with the parsed 5h / 7d utilization in the message body; stale-cache returns `status: warn`. Routing an auth-class failure to `kind: fix_oauth` (the other discriminator the framework supports) would require splitting this check across the `provider` / `auth` boundary — deferred to v1.x if `olp doctor` consumers report the ambiguity.
### Provider coverage
| Provider | Live quota probe | Path |
|---|---|---|
| `anthropic` | ✅ Live — 13 fields via `anthropic-ratelimit-unified-*` headers | This section |
| `openai` (codex) | ❌ Not available — `openai/codex` CLI has no public quota API | Falls back to audit-derived request counts |
| `mistral` | ❌ Not implemented at v0.5.0 — no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys. Mistral's Admin API does expose billing / usage queries but is gated to org-admin scope and out of scope for OLP family-tier deployment. | Falls back to audit-derived request counts |
If Mistral ever publishes a usage endpoint, `lib/providers/mistral.mjs` DL-7 marks the re-entry point.
### Schema-drift protection
Claude Code v2.1.x is distributed as a compiled native binary (Mach-O on macOS, ELF on Linux) — the OCP-era "grep `cli.js`" verification no longer applies. OLP's replacement protocol (ADR 0013 § Rule 5):
1. `strings` over the platform-specific claude-code binary captures all hardcoded header names the binary expects.
2. A live `POST /v1/messages` against `api.anthropic.com` with valid OAuth captures what the server actually emits today.
3. Diff path 1 vs path 2 → the actionable schema delta.
This is re-run at every major `claude --version` bump (next trigger: v2.x → v3.x), at the Annual Alignment Audit (14 May), and whenever `olp doctor anthropic.quota_probe_reachable` returns an unexpected status code. The current pinned schema (13 fields, `2026-05-26`) lives in `models-registry.json` under `quota_probe.schema_version`.
---
## API Endpoints
_placeholder — full table lands as each endpoint lands._
| Endpoint | Method | Phase | Status | Description |
|---|---|---|---|---|
| `/v1/chat/completions` | POST | 1 | ✅ Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. |
| `/v1/models` | GET | 1 | ✅ Shipped | Lists models from `models-registry.json`. |
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot (owner-only). |
| `/cache/stats` | GET | 5 | 📋 Planned | Cache hit rate, by-provider breakdown. |
| `/v0/management/quota` | GET | 6 | 📋 Planned | Per-provider quota / credit pool status (best-effort). |
| `/dashboard` | GET | 6 | 📋 Planned | Owner-only dashboard (localhost-bound by default). |
| `/health` | GET | 1 | ✅ Shipped | Per-provider health snapshot. Phase 2 owner-only-trim: full per-provider details to owner identity; trimmed `{ ok, version }` to guest / anonymous. Gate via `auth.owner_only_endpoints` config. **Optional `anonymousKey` field (D69 / Phase 4, v0.4.0)** appears in both trimmed and full payloads when `auth.advertise_anonymous_key: true` AND `auth.allow_anonymous: true` AND at least one non-revoked guest-tier key has `plaintext_advertise: true` (see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant). Default off — field absent when prereqs unmet. |
| `/dashboard` | GET | 3 + 5 | ✅ Shipped (D50 + D51 + D82) | Owner-only multi-provider dashboard HTML. Phase 5 D82 adds a Claude.ai-style Plan Usage section at the top (per-provider utilization bars + reset countdowns + 60s auto-refresh + manual refresh button) on top of the existing four panels (24h request stats / 30d spend trend / top fallback chains / legacy quota fallback). Owner-only_block; non-owner identities receive 401. Localhost-bound by default. |
| `/v0/management/dashboard-data` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | JSON aggregate consumed by the dashboard polls. Shape `{ generated_at, window_24h, cache_hit_24h, quota, quota_v2, spend_trend_30d, top_fallback_chains_24h, cache_stats }`. The new `quota_v2` field (D81) is the normalized per-provider shape consumed by the Plan Usage UI; the legacy `quota` field stays alongside for backwards compatibility until v1.0.0. Owner-only_block. |
| `/v0/management/quota` | GET | 3 + 5 | ✅ Shipped (D50 + D81) | Per-provider quota snapshot via `provider.quotaStatus()`. Includes both legacy `quota` and new `quota_v2` shape (mirrors `dashboard-data` for scripted monitoring). Owner-only_block. |
| `/cache/stats` | GET | 3 | ✅ Shipped (D50) | Live in-memory `cacheStore.stats()` (`{ hits, misses, size, inflightCount }` + `generated_at`). Owner-only_block. |
---
@@ -113,11 +352,30 @@ _placeholder — full table lands per-phase as variables are introduced._
| Variable | Default | Description |
|---|---|---|
| `OLP_PORT` | `3456` | HTTP listener port. |
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
| `OLP_BIND` | `127.0.0.1` | HTTP listener bind address. **Set to `0.0.0.0` or your LAN IP to accept LAN connections** (required for `olp-connect <ip>` to actually reach the server). Default loopback-only is the secure default. See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — never bind to a public-internet IP. |
| `OLP_API_KEY` | (none) | Owner-tier OLP API key (the `olp_...` plaintext from `olp-keys keygen --owner`) used by `olp` CLI subcommands as the bearer for management endpoints. |
| `OLP_OWNER_TOKEN` | (none) | Fallback used by `olp` CLI if `OLP_API_KEY` is absent. |
| `OLP_PROXY_URL` | `http://127.0.0.1:$OLP_PORT` | Override target URL for `olp` CLI subcommands (so the same binary works against a remote OLP via SSH tunnel or direct LAN). |
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
### `config.json` keys introduced at Phase 4
These live in `~/.olp/config.json` (not env vars) — they're documented here alongside the env-var table for discoverability.
| Config key | Default | Description |
|---|---|---|
| `streaming.heartbeat_interval_ms` | `0` (disabled) | D61 / Phase 4. SSE keepalive comment frames during stream-silent windows. Set `>0` (e.g. `15000` for 15s) when OLP runs behind nginx / Cloudflare / Tailscale Funnel with idle-abort timeouts. |
| `auth.advertise_anonymous_key` | `false` | D69 / Phase 4. When `true`, surfaces an existing guest-tier key's plaintext via `/health.anonymousKey` so `olp-connect <ip>` can self-bootstrap clients on the LAN with zero out-of-band coordination. **Requires `auth.allow_anonymous: true` AND at least one key created via `olp-keys keygen --anonymous --advertise`.** Trusted-LAN-only — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md). |
### Operator CLI surfaces (Phase 4)
- `olp` (Node CLI at `bin/olp.mjs`): `status / health / usage / models / cache / providers / chain show / logs / restart / keys / doctor`. Run `npx olp --help` for full subcommand reference. `olp doctor --json` emits a machine-readable `next_action.ai_executable[]` payload designed for AI agents to self-repair OLP. See [ADR 0010](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md) § Phase 4 D-day plan and [ADR 0002 Amendment 7](./docs/adr/0002-plugin-architecture.md) (per-plugin `doctorChecks()` contract).
- `olp-connect` (bash at `bin/olp-connect`): zero-config LAN client setup — detects Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw and configures each. Run `bash bin/olp-connect --help`. Requires `python3` for JSON parsing.
- `olp-keys keygen --anonymous --advertise`: creates a guest-tier key with the plaintext stored alongside its hash so `/health.anonymousKey` can publish it. Prints an explicit ADR-0011 warning at keygen time.
### Per-provider auth env vars
These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests.
@@ -155,7 +413,7 @@ See also the [Implementation status](#implementation-status-as-of-2026-05-24) ta
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-Fallback-Hops: <n>` — number of fallback hops (`0` if served by the primary chain entry).
- `X-OLP-Cache: hit | miss | bypass` — cache layer outcome.
@@ -165,9 +423,68 @@ If a fallback chain is exhausted, `X-OLP-Fallback-Exhausted` lists the tried pro
---
## Implementation status (as of 2026-05-24)
## IDE Setup
Phase 1 is in progress. This table reflects what is currently shipped vs. what is designed for later phases.
Per-tool setup pages live under [`docs/integrations/`](./docs/integrations/README.md). Index:
| Tool | Status | Notes |
|---|---|---|
| [Continue.dev](./docs/integrations/continue.md) | ✅ Supported | `config.yaml` `apiBase` (not `baseURL`); supports OLP custom headers |
| [Cline](./docs/integrations/cline.md) | ✅ Supported | "OpenAI Compatible" provider; watch Cline issue [#7128](https://github.com/cline/cline/issues/7128) |
| [Cursor](./docs/integrations/cursor.md) | ⚠️ Best-effort | "Override OpenAI Base URL" — known fragile across Cursor updates |
| [Aider](./docs/integrations/aider.md) | ✅ Supported | `OPENAI_API_BASE` env + `openai/` model prefix; no custom-header support |
| [Claude Code](./docs/integrations/claude-code.md) | ❌ Not supported | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline + OLP instead |
| [OpenClaw](./docs/integrations/openclaw.md) | ✅ Supported | Telegram + Discord gateway via [`olp-plugin/`](./olp-plugin/) |
The fastest path is `olp-connect <olp-host-ip>` on the client device — it auto-detects what's installed and writes the per-tool config. See [Quick Start](#quick-start).
---
## Telegram / Discord Usage
OLP ships [`olp-plugin/`](./olp-plugin/) as a native OpenClaw gateway plugin. After install, family members get a read-only `/olp` slash command on whichever chat surfaces OpenClaw exposes (Telegram + Discord today).
**Install:**
```bash
# Option A — OpenClaw CLI
openclaw plugins install /path/to/olp/olp-plugin/
# Option B — symlink (equivalent)
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
**Configure:** edit `~/.openclaw/openclaw.json` and set the plugin's `apiKey` to an owner-tier OLP token created with:
```bash
npx olp-keys keygen --owner --name=openclaw-bot
```
Use a dedicated bot key — not the maintainer's personal owner key — so revocation is scoped.
```json
{
"plugins": {
"olp": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
```
**Restart:** `openclaw gateway restart`.
**Use:** `/olp status`, `/olp usage`, `/olp models`, `/olp health`, `/olp cache`, `/olp providers`, `/olp doctor`, `/olp help`.
**Read-only by design.** Mutating subcommands (`keygen`, `revoke`, `restart`, `logs`) are deliberately NOT exposed via chat — those are SSH-only via the local `olp` CLI. See [`olp-plugin/README.md`](./olp-plugin/README.md#what-you-can-not-do-from-chat-by-design) for the rationale.
---
## Implementation status (as of 2026-05-26, post-v0.4.0)
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 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 closed at v0.5.0 (Quota Probes + Dashboard Enrichment — live Anthropic plan-usage probe + Claude.ai-style dashboard + audit-query aggregateProviderQuota); v0.5.1 hotfix (quota probe cache/backoff/schema-drift correctness — codex review findings F1F3). Phase 6 is next. This table reflects what is currently shipped vs. what is designed for later phases.
| File / artifact | Status | Notes |
|---|---|---|
@@ -182,14 +499,62 @@ 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 |
| `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`) |
| `lib/keys.mjs` | 📋 Planned (Phase 2) | Multi-key auth, per-key namespacing, audit log |
| `dashboard.html` | 📋 Planned (Phase 6) | Owner-only multi-provider dashboard |
| `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 + 3 (D45 append + D52 rotation) | Append-only ndjson audit at `~/.olp/logs/audit.ndjson` per ADR 0007 § 6.2 + § 8. `appendAuditEvent` fires for every `/v1/*` + `/v0/management/*` request (success, 401, 403, 5xx). Warn + 1 retry on append failure; no memory buffer at Phase 2 (forward path). PII excluded. D52 adds synchronous daily rotation per ADR 0008 § 5 — first append after UTC midnight renames live → `audit-YYYY-MM-DD.ndjson`. |
| `lib/audit-query.mjs` | ✅ Phase 3 shipped (D49) | Audit ndjson aggregate query layer per ADR 0008 § 4. 5 functions: `discoverAuditFiles`, `readAuditWindow`, `aggregateRequests`, `topFallbackChains`, `spendTrendDaily`, `cacheHitRateWindow`. In-memory cross-file scan; PII guard at output. Consumed by `/v0/management/dashboard-data`. |
| `dashboard.html` | ✅ Phase 3 shipped (D50 stub + D51 full UI) | Owner-only multi-provider dashboard per ADR 0008 § 6. 4 panels (quota / 24h request stats / 30d SVG sparkline / top fallback chains). Vanilla HTML+JS+fetch (no build step). 30s page poll with `document.visibilityState` pause. Served by `/dashboard` route owner-only_block. |
| `bin/olp-audit-rotate.mjs` | ✅ Phase 3 shipped (D52) | External audit rotation cron tool per ADR 0008 § 5.2. `npx olp-audit-rotate [--olp-home=<path>]`. Idempotent + safe alongside the in-server first-append trigger. |
| `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/alignment-audits/` | 📋 Planned | Output directory for annual alignment audits (first audit: 2027-05-14) |
| `scripts/migrate-from-ocp.mjs` | 📋 Planned (Phase 7) | OCP → OLP migration tool |
| `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 ✅ shipped (D57 + D58, 2026-05-25).** `cacheStore.getOrComputeStreaming(...)` mirrors the buffered-path `getOrCompute` and resolves the TOCTOU window between peek and spawn ([issue #16](https://github.com/dtzp555-max/olp/issues/16)). Two concurrent identical streaming requests share one CLI spawn via tee fan-out; late joiners receive accumulated replay + the live tail; per-client backpressure (`PER_CLIENT_QUEUE_CAP=1MB`) protects against slow consumers; full-disconnect aborts the source CLI via AbortController. New `X-OLP-Streaming-Inflight: source | attached` header annotates the role. New `cache_status: 'streaming_attached'` audit value tracks the singleflight win. Authority: [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md), v1.x roadmap #1.
- **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 shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
- **Phase 3 (Dashboard + audit query layer + rotation) shipped at v0.3.0 (D48D54).** `docs/adr/0008-dashboard-and-audit-query.md` + `lib/audit-query.mjs` (D49) + 4 owner-only_block endpoints (D50) + `dashboard.html` (D51) + daily audit rotation (D52) + `tried_providers` schema fix (D53). All 15 ADR 0008 § 10 acceptance criteria covered.
- **Phase 4 (Operator + Client UX) shipped at v0.4.0 (D60 → D73).** ADR 0010 (charter) + ADR 0011 (anonymous-key trusted-LAN limits) + ADR 0002 Amendment 7 (provider `doctorChecks()` contract). Default `OLP_PORT` 3456 → 4567 so OLP and OCP can co-host. SSE heartbeat (D61) + `recentErrors[20]` + `/v0/management/status` (D62-D63). `bin/olp.mjs` Node CLI + `bin/olp-keys.mjs` + `lib/doctor.mjs` framework with `next_action.ai_executable[]` (D64-D67). `bin/olp-connect` bash zero-config IDE auto-config + opt-in `/health.anonymousKey` (D68-D70). `olp-plugin/` OpenClaw `/olp` Telegram-Discord plugin (read-only, no chat mutations) + 6 IDE integration docs at `docs/integrations/*.md` (D71-D73). Test count 623 → 696.
**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:4567/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).
- **Agentic clients with shell-tool routing may report OLP-server-side state as "self".** This is an architectural property of spawn-CLI proxying that OLP cannot fully fix at the proxy layer. When a client like OpenClaw runs in **client mode** (gateway on user's machine, LLM backend pointed at remote OLP) and the agent exposes shell / fs tools, those tool calls execute on whatever machine the client's tool-handler is wired to. If the client's `ocp` / `olp` plugin routes shell to the OLP server host, an in-agent "do a self-check" prompt produces results describing the OLP host (e.g. PI231) rather than the user's local machine. OLP cannot inject "you are the client, not the server" into the prompt because (a) the client owns the system message, and (b) OLP is stateless and doesn't know which client is calling. **Phase 6c's `--system-prompt` override (ADR 0009 Amendment 1) addresses one side of this — claude CLI no longer injects `<env>cwd=...</env>` blocks into the prompt** — but it cannot prevent the client from sending tool-results that the model then describes as its own state. Recommendations for integrators:
- **OpenClaw client mode** — if you want bot self-checks to describe the user's local machine, configure OpenClaw's tool plugins (`plugins.entries.{ocp,olp}` etc.) so shell / fs tools route to the local host, not to the OLP server. The bundled `olp-plugin/` ships as a read-only telemetry surface (no shell mutations); the older `ocp` plugin's shell-routing semantics are OCP-era legacy and may misroute when OLP is the LLM backend.
- **Hermes Agent client mode** — Hermes pre-processes tools on its own host before sending; the LLM emits no tool_use that reaches OLP, so this limitation does not apply to chat-only Hermes flows. Tool-using Hermes flows behave correctly: Hermes runs the tool locally and includes the result as a follow-up user message.
- **Cline / Continue.dev / Cursor / Aider** — IDE clients typically run shell / fs tools locally on the user's machine, so self-checks report the user's machine correctly. No OLP-side action needed.
- **Generic agentic clients** — if your client routes tool execution to the OLP server, expect bot self-reports to describe the OLP server's state. Either: (1) configure your client's tool handler to run tools locally, or (2) document this to your client users as a known limitation.
See [ADR 0014](./docs/adr/0014-sandbox-runtime-integration.md) for the multi-tenant security counterpart of this issue — even with shell-tool routing, OLP server-side sandboxing prevents one client from reading another client's OAuth tokens (Phase 7 PR-A shipped; PR-B HTTP-path activation pending).
---
## Architecture
@@ -211,32 +576,39 @@ 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.
- Phase 0 — Repo bootstrap, `ALIGNMENT.md`, founding ADRs, CI workflows, PR template. **(current)**
- 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.
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.
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 + audit query layer + daily audit rotation per ADR 0008: in-memory ndjson aggregate query layer (`lib/audit-query.mjs`), 4 owner-only_block management endpoints (`/dashboard` + `/v0/management/dashboard-data` + `/v0/management/quota` + `/cache/stats`), multi-panel `dashboard.html` with 30s poll, synchronous daily audit rotation + `bin/olp-audit-rotate.mjs` cron tool, `tried_providers` schema fix (D45 P2 deferral). ✅ Shipped — v0.3.0 (2026-05-25, D48 → D54). All 15 ADR 0008 § 10 acceptance criteria covered.
- **Phase 5** — Live quota probe (Anthropic Pro/Max OAuth plan-usage via `anthropic-ratelimit-unified-*` headers), Claude.ai-style dashboard enrichment (utilization bars + reset countdowns), audit-query `aggregateProviderQuota()`, per-provider quota_v2 shape in dashboard-data. ✅ Shipped — v0.5.0 (2026-05-27). v0.5.1 hotfix (2026-05-27): quota probe cache/backoff/schema-drift correctness (codex review findings F1F3).
- **Phase 6 (planned)** — Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral), audit query rotation/retention policies, SQLite hybrid migration (ADR 0007 § 13 trigger), provider-cost weights for spend trend.
- **Phase 4+ (v1.x roadmap, triggered as needed)** — 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`.
---
## Migration from OCP
OLP is OCP's successor. The trigger was Anthropic's 2026-05-14 announcement (effective 2026-06-15) splitting `claude -p` / Agent SDK / third-party agent traffic out of the Pro/Max subscription pool into a separate fixed $100/month Agent SDK credit pool — invalidating OCP's foundational assumption (*"subscription = unlimited within rate limits"*) for its only provider. OLP's structural response is to spread risk across multiple subscriptions whose CLI/programmatic use remains in their main subscription pool, with intelligent fallback when one runs out.
Beyond the billing trigger, OLP is intentionally NOT a commercial multi-tenant SaaS (LiteLLM / OpenRouter / Portkey already serve that market with funding + SOC2), NOT an enterprise gateway competing on provider breadth, NOT a model-capability router ("route to the smartest model" — you pick the model in `routing.chains`), and NOT a conversation-state store (your client manages its own context). See [ADR 0001](./docs/adr/0001-project-founding.md) for the founding decision and [`ALIGNMENT.md`](./ALIGNMENT.md) for the constitution that governs every plugin / IR / entry-surface change.
### Migrating an existing OCP install
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
Anticipated user-facing flow (target: <5 minutes):
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
2. Install OLP.
3. Run `olp migrate-from-ocp`copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable.
4. Start OLP. Clients pointing at port 3456 keep working; their existing OLP API keys remain valid.
2. Install OLP (per [§ Manual install](#manual-install-5-10-min) above).
3. Run `olp migrate-from-ocp` — will copy `~/.ocp/keys/` to `~/.olp/keys/` and point provider plugins at OCP's existing auth artifacts where applicable.
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid.
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
**Default port moved 3456 → 4567 at v0.4.0** so OCP and OLP can co-host on the same machine during the migration window — set `OLP_PORT=3456` if you want the pre-D60 default. OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
---
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* bin/olp-audit-rotate.mjs — External audit rotation cron tool (Phase 3 / D52)
*
* Authority: ADR 0008 § 5.2 (external cron alternative to in-server first-
* append-after-UTC-midnight trigger).
*
* Use case: operators who want exact-at-UTC-midnight rotation rather than
* "first request after midnight". Invoke from a host cron / launchd job.
*
* Idempotent + safe to run alongside the in-server check (both detect the
* date condition; whichever fires first does the rename; the other no-ops).
*
* Usage:
* olp-audit-rotate [--olp-home=<path>]
*
* Exit codes:
* 0 = success (rotation performed OR no rotation needed)
* 1 = bad usage (unknown flag)
* 2 = rotation attempted + failed (e.g., EACCES)
*
* Example cron line (UTC midnight):
* 1 0 * * * /usr/local/bin/node /path/to/bin/olp-audit-rotate.mjs >> /var/log/olp-audit-rotate.log 2>&1
*/
import { _maybeRotateAudit } from '../lib/audit.mjs';
function parseArgv(argv) {
const flags = {};
for (const arg of argv) {
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq > 0) flags[arg.slice(2, eq)] = arg.slice(eq + 1);
else flags[arg.slice(2)] = true;
}
}
return flags;
}
const USAGE = `OLP audit rotation cron tool
Usage:
olp-audit-rotate [--olp-home=<path>]
Triggers a rotation check: if the live audit.ndjson holds events from a
past UTC date, rename it to audit-YYYY-MM-DD.ndjson. Idempotent; safe to
run alongside the in-server first-append-after-UTC-midnight trigger.
Authority: ADR 0008 § 5.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.includes('--help') || argv.includes('-h')) {
ioOut(USAGE + '\n');
return 0;
}
const flags = parseArgv(argv);
const allowed = new Set(['olp-home', 'help', 'h']);
for (const k of Object.keys(flags)) {
if (!allowed.has(k)) {
ioErr(`Error: unknown flag --${k}\n${USAGE}\n`);
return 1;
}
}
const olpHome = typeof flags['olp-home'] === 'string' ? flags['olp-home'] : undefined;
try {
// _maybeRotateAudit is synchronous at v0.3.0 (D52) — rotation must
// complete BEFORE the next append so no event straddles the boundary.
const result = _maybeRotateAudit({ olpHome });
if (result.rotated) {
ioOut(`Rotated ${result.fromPath} -> ${result.toPath} (dateUsed=${result.dateUsed}).\n`);
} else {
ioOut('No rotation needed (live audit is current or absent).\n');
}
return 0;
} catch (err) {
ioErr(`Error: rotation failed: ${err?.message ?? err}\n`);
return 2;
}
}
// Main guard
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));
}
+657
View File
@@ -0,0 +1,657 @@
#!/usr/bin/env bash
# bin/olp-connect — Lightweight client script to connect this machine to a remote
# OLP (Open LLM Proxy). Ported from OCP's `ocp-connect` per ADR 0010 § Phase 4
# D68-D70 charter; uses /health.anonymousKey when the remote operator opted in
# via `auth.advertise_anonymous_key: true` (ADR 0011).
#
# Authority:
# - ADR 0010 (Phase 4 charter — D68 line: client-side IDE auto-config)
# - ADR 0011 (anonymous-key deployment-context limits — trusted-LAN invariant)
# - OCP `ocp-connect` v1.3.0 (prior-art reference)
#
# Why bash (not Node like `olp` CLI):
# olp-connect MUST run on CLIENT machines that may not have a recent Node
# installed (parents' laptops, work machines, Raspberry Pi). bash + curl +
# python3 give maximum portability; this script does not import any OLP
# Node modules.
#
# Dependencies: bash >=4, curl, python3 (for /health JSON parsing).
#
# Install:
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect -o olp-connect
# chmod +x olp-connect
#
# Or via npm/npx (once `npm install -g olp` is run on a machine that has Node):
# olp-connect <ip>
#
# Or run directly via curl-pipe:
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect | bash -s -- <host-ip>
set -euo pipefail
# D78 (G13): derive version from package.json instead of hardcoding (was
# stuck at "0.4.0-phase4" through v0.4.1/v0.4.2/v0.4.3 because no one
# updated it). Look up package.json next to the script if available;
# fall back to "unknown" when running curl-piped (no on-disk package.json).
_resolve_version() {
local script_dir pkg
# When curl-piped (`curl ... | bash`), BASH_SOURCE[0] is empty → dirname
# yields "." → script_dir resolves to cwd. D78 reviewer P2-1 hardening:
# require the suffix-strip to actually fire (script_dir ENDED with /bin),
# otherwise we'd happily pick up an unrelated package.json from whatever
# directory the user happens to be in when piping. Belt-and-braces.
# ${BASH_SOURCE[0]:-} default-empty guards against `set -u` nounset error
# when invoked via `curl ... | bash` (no source file → BASH_SOURCE unset).
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-}")" &>/dev/null && pwd)"
if [[ "$script_dir" != */bin ]]; then
echo "unknown"
return
fi
pkg="${script_dir%/bin}/package.json"
# D78 reviewer P2-2: pass $pkg via env var instead of -c interpolation
# so paths with apostrophes / shell metacharacters can't break the
# python invocation. Canonical layout is safe; this is defense-in-depth.
if [[ -f "$pkg" ]] && command -v python3 >/dev/null 2>&1; then
OLP_PKG_PATH="$pkg" python3 -c 'import json,os;print(json.load(open(os.environ["OLP_PKG_PATH"])).get("version","unknown"))' 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
OLP_CONNECT_VERSION="$(_resolve_version)"
show_version() {
echo "olp-connect $OLP_CONNECT_VERSION"
}
show_help() {
cat <<'EOF'
olp-connect — Connect this machine to a remote OLP (Open LLM Proxy)
Configures OPENAI_BASE_URL + OPENAI_API_KEY in your shell rc file (and macOS
launchctl env / Linux systemd user env), then detects installed IDEs (Cline,
Continue.dev, Cursor, Aider, OpenClaw, Claude Code) and prints / writes the
provider-specific configuration each needs.
Usage:
olp-connect <host-ip> [options]
olp-connect --help
olp-connect --version
Options:
--port PORT Port OLP listens on (default: 4567 — OLP v0.4.0+ default;
set 3456 if connecting to a pre-D60 OLP install)
--key API_KEY OLP API key (from `olp-keys keygen` on the server). When
omitted, the script reads /health.anonymousKey (if the
server opted in via auth.advertise_anonymous_key=true) or
prompts interactively.
--no-system-env Skip macOS launchctl setenv / Linux systemd env writes;
only update shell rc files.
--dry-run Print everything the script would do without modifying
any file or setting any env var.
--version Print version and exit
--help, -h Show this help
Examples:
olp-connect 192.168.1.10
olp-connect 192.168.1.10 --port 8080
olp-connect 192.168.1.10 --key olp_AbcDef1234...
olp-connect 100.64.0.5 --dry-run
Requires:
bash, curl, python3 (for /health JSON parsing)
Exit codes:
0 success
1 bad arguments / unknown flag / missing required value
2 connectivity or auth failure / smoke test failure
Authority: ADR 0010 § Phase 4 D68-D70; ADR 0011 (anonymous-key trusted-LAN
invariant — when --key is auto-resolved from /health.anonymousKey, this
deployment MUST be on a trusted LAN).
EOF
}
# ── Globals populated by main() ─────────────────────────────────────────────
DRY_RUN=false
NO_SYSTEM_ENV=false
# ── Logging helpers ─────────────────────────────────────────────────────────
log_info() { echo " $*"; }
log_step() { echo " → $*"; }
log_ok() { echo " ✓ $*"; }
log_warn() { echo " ⚠ $*"; }
log_err() { echo " ✗ $*" >&2; }
# Echo a state change (a write / env-set) before executing — operator can
# Ctrl-C if something looks wrong. Returns 0 always.
log_change() { echo " • $*"; }
# ── IDE detection + configuration ───────────────────────────────────────────
# Truncate long keys for display (avoid leaking via screenshot / screen share).
key_display() {
local k="$1"
if [[ -z "$k" ]]; then
echo "(none — anonymous; most IDEs require a non-empty API Key)"
elif [[ ${#k} -gt 16 ]]; then
echo "${k:0:8}...${k: -4}"
else
echo "$k"
fi
}
# D74 P1-2: validate OLP API key format. Per ADR 0007 § 3, tokens are
# `olp_` + 32 random bytes base64url-encoded (43 chars, no padding). This
# regex pins the on-the-wire shape so a malformed or hostile `--key` /
# server-advertised `anonymousKey` never gets persisted into a shell rc.
# Returns 0 on valid, 1 on invalid (with diagnostic to stderr).
validate_olp_token() {
local k="$1" source="$2"
if [[ ! "$k" =~ ^olp_[A-Za-z0-9_-]{43}$ ]]; then
log_err "Rejected $source: token format does not match ^olp_[A-Za-z0-9_-]{43}$ (ADR 0007 § 3)."
log_err " Got ${#k}-char value starting with '$(echo "$k" | cut -c1-8)...'"
log_err " Expected: olp_ followed by 43 base64url chars. Run 'npx olp-keys list' on the server"
log_err " to confirm the key format, or have the operator regenerate with 'npx olp-keys keygen'."
return 1
fi
return 0
}
# D74 P1-2: POSIX shell-quote a value before interpolating into a shell rc
# write. Wraps in single quotes + escapes embedded single quotes per:
# foo'bar → 'foo'\''bar'
# Even with the validator above, this is defense-in-depth: any non-token
# string that slips through (e.g., environment.d KEY=VALUE writes) MUST be
# safe to source. Same helper pattern as lib/doctor.mjs _shellQuote.
shell_quote() {
local s="$1"
# Escape any single quotes: ' → '\''
printf "'%s'" "${s//\'/\'\\\'\'}"
}
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
# client. The user is steered toward Cline + OLP.
detect_claude_code() {
if command -v claude &>/dev/null; then
log_info ""
log_info "Detected: Claude Code (`command -v claude`)"
log_warn "Claude Code is NOT supported as an OLP client (OLP does not ship"
log_warn " /v1/messages — see ADR 0010 § Out-of-Phase-4-scope)."
log_warn " Recommended alternative: install Cline (VSCode extension) + OLP."
log_warn " Cline uses OpenAI-shape /v1/chat/completions which OLP DOES serve."
fi
}
# Detect Cline VSCode extension and print manual-configure snippet.
# Cline cannot be auto-configured via env vars — user must paste into VSCode
# settings UI. We surface the values for them.
detect_cline() {
local base_url="$1" key="$2"
local exts=""
if command -v code &>/dev/null; then
exts=$(code --list-extensions 2>/dev/null || true)
fi
if [[ -z "$exts" && -d "$HOME/.vscode/extensions" ]]; then
exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
fi
if echo "$exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
log_info ""
log_info "Detected: Cline (VSCode extension)"
log_info " Cline must be configured via the VSCode settings UI."
log_info " Open VSCode → Cline panel → Settings → API Provider:"
log_info " API Provider: \"OpenAI Compatible\""
log_info " Base URL: $base_url/v1"
log_info " API Key: $(key_display "$key")"
log_info " Model ID: claude-sonnet-4-5 (or any model from /v1/models)"
fi
}
# Detect Continue.dev and write a `models:` entry to ~/.continue/config.yaml
# (idempotent — checks if an entry with the same name exists first).
detect_continue() {
local base_url="$1" key="$2"
local exts=""
if command -v code &>/dev/null; then
exts=$(code --list-extensions 2>/dev/null || true)
fi
local config_yaml="$HOME/.continue/config.yaml"
local config_json="$HOME/.continue/config.json"
local found=false
if echo "$exts" | grep -qi 'continue\.continue'; then found=true; fi
if [[ -f "$config_yaml" || -f "$config_json" ]]; then found=true; fi
if ! $found; then return 0; fi
log_info ""
log_info "Detected: Continue.dev"
log_info " Configuration snippet for ~/.continue/config.yaml:"
log_info " models:"
log_info " - name: OLP Sonnet"
log_info " provider: openai"
log_info " model: claude-sonnet-4-5"
log_info " apiBase: $base_url/v1"
log_info " apiKey: $(key_display "$key")"
log_info " Note: Continue.dev autoreload-on-save is fragile; restart VSCode if"
log_info " the new model doesn't appear in the model selector."
}
# Detect Cursor and print manual snippet + known-fragility warning.
detect_cursor() {
local base_url="$1" key="$2"
local found=false
if command -v cursor &>/dev/null; then found=true; fi
if [[ -d "$HOME/.cursor" ]]; then found=true; fi
if [[ -d "/Applications/Cursor.app" ]]; then found=true; fi
if ! $found; then return 0; fi
log_info ""
log_info "Detected: Cursor"
log_info " Cmd+Shift+P → 'Cursor Settings' → Models:"
log_info " OpenAI API Key: $(key_display "$key")"
log_info " Override OpenAI Base URL: $base_url/v1"
log_info " Custom OpenAI Models: claude-sonnet-4-5,claude-opus-4-1"
log_warn " Cursor's base-URL handling is known-fragile (issue #7128 et al);"
log_warn " if requests fail with 'malformed request', try removing then"
log_warn " re-adding the model in the Cursor models list."
}
# Detect Aider and write OPENAI_API_BASE / OPENAI_API_KEY to rc files.
# Aider reads these env vars at startup — already handled by the rc-file
# block in main(). We just announce detection here.
detect_aider() {
if command -v aider &>/dev/null; then
log_info ""
log_info "Detected: Aider (`command -v aider`)"
log_info " Aider reads OPENAI_API_BASE + OPENAI_API_KEY from env."
log_info " These are already being written to your shell rc — open a fresh"
log_info " shell and run: aider --model openai/claude-sonnet-4-5"
fi
}
# Detect OpenClaw. Phase 4 D71-D73 shipped olp-plugin/ as the OpenClaw
# gateway plugin for /olp Telegram + Discord slash commands. Point users
# at the install path.
detect_openclaw() {
if command -v openclaw &>/dev/null || [[ -f "$HOME/.openclaw/openclaw.json" ]]; then
log_info ""
log_info "Detected: OpenClaw"
log_info " OLP ships an OpenClaw gateway plugin for /olp Telegram + Discord"
log_info " slash commands (status / usage / cache / models / providers /"
log_info " chain show / health / doctor). Read-only by design — no chat-side"
log_info " mutations."
log_info ""
log_info " Install the plugin (one-time, on the host running OpenClaw):"
log_info " git clone https://github.com/dtzp555-max/olp.git /tmp/olp-repo"
log_info " openclaw plugins install /tmp/olp-repo/olp-plugin"
log_info " # OR symlink: ln -sf /tmp/olp-repo/olp-plugin ~/.openclaw/extensions/olp"
log_info ""
log_info " Then edit ~/.openclaw/openclaw.json to set the plugin apiKey to a"
log_info " dedicated OLP key (NOT your owner key — create one via olp-keys"
log_info " keygen --name <bot-name>). Restart OpenClaw gateway."
log_info ""
log_info " See docs/integrations/openclaw.md for full instructions."
fi
}
# ── rc-file helpers ─────────────────────────────────────────────────────────
# Identify which shell rc files to write to. Returns paths on stdout, one per line.
detect_rc_files() {
local is_mac=false
[[ "$(uname)" == "Darwin" ]] && is_mac=true
if [[ "${SHELL:-}" == */fish ]]; then
log_warn "fish shell detected; writing to ~/.bashrc — add to fish config manually." >&2
echo "$HOME/.bashrc"
return
fi
if $is_mac; then
# macOS Catalina+ default shell is zsh
[[ -f "$HOME/.bashrc" ]] && echo "$HOME/.bashrc"
[[ -f "$HOME/.zshrc" ]] || { $DRY_RUN || touch "$HOME/.zshrc"; }
echo "$HOME/.zshrc"
else
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && echo "$HOME/.bashrc"
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && echo "$HOME/.zshrc"
fi
}
# Remove any previously-written OLP block from an rc file (idempotent).
# The block is bracketed by:
# # OLP LAN (added by olp-connect) ... # /OLP LAN
strip_olp_block() {
local rc_file="$1"
[[ -f "$rc_file" ]] || return 0
if $DRY_RUN; then
if grep -qF '# OLP LAN (added by olp-connect)' "$rc_file" 2>/dev/null; then
log_change "[dry-run] would strip existing OLP block from $rc_file"
fi
return 0
fi
python3 - "$rc_file" <<'PYEOF'
import sys
path = sys.argv[1]
try:
with open(path) as f:
lines = f.readlines()
except OSError:
sys.exit(0)
out = []
skip = False
for line in lines:
s = line.rstrip('\n')
if s == '# OLP LAN (added by olp-connect)':
skip = True
continue
if skip and s == '# /OLP LAN':
skip = False
continue
if skip:
continue
out.append(line)
with open(path, 'w') as f:
f.writelines(out)
PYEOF
}
# Append a new OLP block to an rc file.
append_olp_block() {
local rc_file="$1" base_url="$2" key="$3"
if $DRY_RUN; then
log_change "[dry-run] would append OLP block to $rc_file:"
log_change " # OLP LAN (added by olp-connect)"
log_change " export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(shell_quote "$(key_display "$key")")"
log_change " # /OLP LAN"
return 0
fi
# D74 P1-2: shell-quote values before writing to rc files. Defense-in-depth
# alongside validate_olp_token — even if a future code path bypasses the
# validator, the rc file remains safe to source.
{
echo ""
echo "# OLP LAN (added by olp-connect)"
echo "export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
if [[ -n "$key" ]]; then
echo "export OPENAI_API_KEY=$(shell_quote "$key")"
fi
echo "# /OLP LAN"
} >> "$rc_file"
}
# ── System-level env (macOS launchctl / Linux systemd user) ────────────────
set_system_env() {
local base_url="$1" key="$2"
if $NO_SYSTEM_ENV; then
log_info "Skipping system-level env (--no-system-env)"
return 0
fi
if [[ "$(uname)" == "Darwin" ]]; then
if $DRY_RUN; then
log_change "[dry-run] would launchctl setenv OPENAI_BASE_URL=$base_url/v1"
[[ -n "$key" ]] && log_change "[dry-run] would launchctl setenv OPENAI_API_KEY=$(key_display "$key")"
return 0
fi
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null || log_warn "launchctl setenv OPENAI_BASE_URL failed"
if [[ -n "$key" ]]; then
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null || log_warn "launchctl setenv OPENAI_API_KEY failed"
fi
log_ok "launchctl setenv applied (visible to GUI apps + daemons)"
log_info " Note: launchctl env vars reset on reboot. Re-run olp-connect after restart"
log_info " or add the script to Login Items."
else
local env_dir="$HOME/.config/environment.d"
if $DRY_RUN; then
log_change "[dry-run] would write $env_dir/olp.conf"
return 0
fi
mkdir -p "$env_dir" 2>/dev/null
# D74 P1-2: systemd environment.d format is KEY=VALUE per line. While
# systemd does its own parsing (no shell sourcing), reject embedded
# newlines defensively — validate_olp_token already enforces the
# restricted charset for the API key, so this is belt-and-braces.
if [[ "$base_url" == *$'\n'* || "$key" == *$'\n'* ]]; then
log_err "Refusing to write environment.d entry: value contains newline."
return 2
fi
{
echo "OPENAI_BASE_URL=$base_url/v1"
if [[ -n "$key" ]]; then
echo "OPENAI_API_KEY=$key"
fi
} > "$env_dir/olp.conf"
log_ok "Wrote $env_dir/olp.conf (applies to systemd user services after re-login)"
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
local host="" port=4567 key=""
# Parse args (POSIX-style; --flag value AND --flag=value both accepted)
while [[ $# -gt 0 ]]; do
case "$1" in
--port) port="${2:?--port requires a value}"; shift 2 ;;
--port=*) port="${1#*=}"; shift ;;
--key) key="${2:?--key requires a value}"
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
# D74 P1-2: reject malformed --key before it ever reaches an rc write.
validate_olp_token "$key" "--key flag" || exit 1
shift 2 ;;
--key=*) key="${1#*=}"
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
validate_olp_token "$key" "--key flag" || exit 1
shift ;;
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--version) show_version; exit 0 ;;
--help|-h) show_help; exit 0 ;;
--*) log_err "Unknown option: $1"; show_help >&2; exit 1 ;;
*) host="$1"; shift ;;
esac
done
if [[ -z "$host" ]]; then
log_err "host IP is required."
echo "" >&2
show_help >&2
exit 1
fi
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
log_err "invalid host '$host'"
exit 1
fi
# Dependency check
for cmd in curl python3; do
if ! command -v "$cmd" &>/dev/null; then
log_err "'$cmd' is required but not found in PATH."
[[ "$cmd" == "python3" ]] && log_err " python3 is used for /health + /v1/models JSON parsing."
exit 1
fi
done
local base_url="http://$host:$port"
echo "olp-connect v$OLP_CONNECT_VERSION"
echo "─────────────────────────────────────"
log_info "Remote: $base_url"
$DRY_RUN && log_info "Mode: DRY RUN (no files will be modified, no env vars will be set)"
echo ""
# Step 1: connectivity probe. We capture status separately from body so we
# can distinguish "TCP/HTTP unreachable" from "reached but 401" (the latter
# is a known surface when the server has auth.allow_anonymous=false AND
# auth.advertise_anonymous_key=false — user MUST provide --key).
log_step "Probing /health..."
local probe_body probe_status
probe_body=$(curl -s --max-time 5 -o /tmp/olp-connect-health.$$ -w "%{http_code}" "$base_url/health" 2>/dev/null || echo "000")
probe_status="$probe_body"
if [[ -f /tmp/olp-connect-health.$$ ]]; then
probe_body=$(cat /tmp/olp-connect-health.$$ 2>/dev/null || echo "")
rm -f /tmp/olp-connect-health.$$
fi
if [[ "$probe_status" == "000" ]]; then
log_err "Cannot reach $base_url/health (connection refused / timeout / DNS)"
log_err " Ensure OLP is running on $host and bound to 0.0.0.0 (LAN mode)."
log_err " Default port changed 3456 → 4567 at OLP v0.4.0; pass --port 3456 for older installs."
exit 2
fi
if [[ "$probe_status" == "401" ]]; then
log_warn "Server reachable but /health returned 401."
log_warn " Either the operator has not enabled auth.advertise_anonymous_key, or"
log_warn " the server requires auth (auth.allow_anonymous=false)."
if [[ -z "$key" ]]; then
log_err " Pass --key olp_... to continue, or ask the operator to advertise an anonymous key (see ADR 0011)."
exit 2
fi
# If user supplied --key, we proceed without /health body (auth-required mode).
log_info " Proceeding with the --key you supplied; skipping /health.anonymousKey discovery."
local health_json=""
local remote_version="?"
else
if [[ "$probe_status" != "200" ]]; then
log_err "/health returned HTTP $probe_status (expected 200 or 401)."
exit 2
fi
local health_json="$probe_body"
local remote_version
remote_version=$(echo "$health_json" | python3 -c "import sys,json
try: print(json.loads(sys.stdin.read()).get('version','?'))
except: print('?')" 2>/dev/null || echo "?")
log_ok "Connected — OLP v$remote_version"
fi
# Step 2: auth resolution
if [[ -z "$key" ]]; then
# Try /health.anonymousKey first (D69 / ADR 0011 opt-in).
local anon_key
anon_key=$(echo "$health_json" | python3 -c "import sys,json
try:
d = json.loads(sys.stdin.read())
k = d.get('anonymousKey')
print(k if isinstance(k, str) and k else '')
except: print('')" 2>/dev/null || echo "")
if [[ -n "$anon_key" ]]; then
# D74 P1-2: validate server-advertised token shape before consuming.
# A hostile or misconfigured server could otherwise inject arbitrary
# strings into the user's rc file via the `anonymousKey` field.
if ! validate_olp_token "$anon_key" "/health.anonymousKey from ${host}:${port}"; then
log_err "Refusing to consume malformed advertised key. Use --key explicitly or contact the OLP operator."
exit 2
fi
key="$anon_key"
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
log_info " the trusted-LAN-only invariant — this assumes you and the remote are"
log_info " on the same trusted network)"
else
# No advertised key; prompt interactively (skip in dry-run for non-TTY safety)
if $DRY_RUN; then
log_info "[dry-run] would prompt for API key here (no --key + no anonymousKey)"
key="<prompted-at-runtime>"
else
echo ""
log_info "Remote does not advertise an anonymous key."
log_info "Ask the OLP operator to run on the server: olp-keys keygen --name <your-label>"
printf " Enter OLP API key: "
{ read -rs key </dev/tty; } 2>/dev/null || key=""
echo
if [[ -z "$key" ]]; then
log_err "No key provided and the remote did not advertise an anonymous key."
log_err " Re-run with: olp-connect $host --key olp_..."
exit 2
fi
# D74 P1-2: also validate the interactively-prompted key.
validate_olp_token "$key" "interactive prompt" || exit 1
fi
fi
fi
# Step 3: smoke test /v1/models
log_step "Smoke-testing /v1/models..."
if $DRY_RUN && [[ "$key" == "<prompted-at-runtime>" ]]; then
log_info "[dry-run] skipping smoke test (no real key)"
else
local models_out models_ok=0
if [[ -n "$key" ]]; then
models_out=$(curl -sf --max-time 10 \
-H "Authorization: Bearer $key" \
"$base_url/v1/models" 2>/dev/null) && models_ok=1
else
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
fi
if [[ $models_ok -eq 0 ]]; then
log_err "/v1/models request failed — key may be invalid, revoked, or not allowed for any provider."
exit 2
fi
local model_count
model_count=$(echo "$models_out" | python3 -c "import sys,json
try: print(len(json.loads(sys.stdin.read()).get('data', [])))
except: print('?')" 2>/dev/null || echo "?")
log_ok "/v1/models OK — $model_count models available"
fi
echo ""
# Step 4: write shell rc files
log_step "Writing shell rc files..."
local rc_files=()
while IFS= read -r line; do
[[ -n "$line" ]] && rc_files+=("$line")
done < <(detect_rc_files)
if [[ ${#rc_files[@]} -eq 0 ]]; then
log_warn "No shell rc files detected; falling back to ~/.bashrc"
rc_files=("$HOME/.bashrc")
fi
for rc_file in "${rc_files[@]}"; do
log_change "stripping old OLP block from $(basename "$rc_file") (idempotent)"
strip_olp_block "$rc_file"
log_change "appending new OLP block to $(basename "$rc_file")"
append_olp_block "$rc_file" "$base_url" "$key"
done
log_ok "Shell rc files updated:"
for rc_file in "${rc_files[@]}"; do
log_info " $rc_file"
done
echo ""
# Step 5: system-level env (macOS launchctl / Linux systemd)
log_step "Setting system-level env..."
set_system_env "$base_url" "$key"
echo ""
# Step 6: IDE detection + per-IDE config
log_step "Detecting installed IDEs..."
detect_claude_code
detect_cline "$base_url" "$key"
detect_continue "$base_url" "$key"
detect_cursor "$base_url" "$key"
detect_aider
detect_openclaw
echo ""
# Step 7: final summary
log_step "Done."
log_info "OLP base URL: $base_url/v1"
log_info "OLP API key: $(key_display "$key")"
log_info ""
log_info "Test it: open a fresh shell, run:"
log_info " curl -sf -H \"Authorization: Bearer \$OPENAI_API_KEY\" $base_url/v1/models | python3 -m json.tool | head -20"
log_info ""
log_info "Reload your current shell to apply env changes:"
for rc_file in "${rc_files[@]}"; do
log_info " source $rc_file"
done
}
main "$@"
+298
View File
@@ -0,0 +1,298 @@
#!/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 keygen --anonymous --advertise [--name=<label>] [--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); ADR 0011 (anonymous-key
deployment-context limits — trusted-LAN-only invariant for --advertise).`;
// ── Subcommand implementations ────────────────────────────────────────────
async function cmdKeygen(flags, ioOut, ioErr) {
const olpHome = flags['olp-home'];
const owner = flags.owner === true;
const force = flags.force === true;
// D69 (ADR 0011): --anonymous is shorthand for "create a guest-tier key
// intended to be the zero-config /health.anonymousKey advertise key".
// It implies --tier=guest and defaults the name to 'anonymous'. The
// distinct field that actually triggers /health advertisement is
// --advertise (writes plaintext_advertise into the manifest). Either
// flag works on its own (--anonymous without --advertise is just a
// conventionally-named guest key); --advertise without --anonymous is
// accepted (operator may want to advertise a named guest key).
const isAnonymous = flags.anonymous === true;
const advertise = flags.advertise === true;
let tier = flags.tier;
if (owner) tier = 'owner';
if (isAnonymous && !owner) tier = 'guest';
if (!tier) tier = 'guest';
if (tier !== 'owner' && tier !== 'guest') {
ioErr(`Error: --tier must be "owner" or "guest" (got "${tier}").\n`);
return 1;
}
// D69: reject --owner --advertise (would expose owner identity unauthenticated).
if (advertise && tier !== 'guest') {
ioErr(`Error: --advertise requires guest tier (cannot advertise owner-tier key plaintext). See ADR 0011.\n`);
return 1;
}
let name = flags.name;
if (!name) {
if (owner) name = 'owner';
else if (isAnonymous) name = 'anonymous';
}
if (!name) {
ioErr('Error: --name is required (or use --owner to default to "owner", or --anonymous to default to "anonymous").\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, plaintext_advertise: advertise });
} 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`);
if (advertise) {
// D69 (ADR 0011): explicit warning when plaintext lands on disk + opt-in surface.
ioOut(` advertise: YES — plaintext stored in manifest; surfaced via /health.anonymousKey\n`);
ioErr(`\n WARNING: this key's plaintext is now stored on disk + will be exposed via\n`);
ioErr(` /health.anonymousKey when auth.advertise_anonymous_key=true AND\n`);
ioErr(` auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.\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));
}
Executable
+859
View File
@@ -0,0 +1,859 @@
#!/usr/bin/env node
/**
* bin/olp.mjs — OLP operator CLI (Phase 4 / D64)
*
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
* python3 JSON-parsing fragility called out in the ADR.
*
* Subcommands:
* status GET /v0/management/status (owner-only)
* health GET /health
* usage GET /v0/management/dashboard-data (owner-only)
* models GET /v1/models
* cache GET /cache/stats (owner-only)
* providers local: models-registry + config providers.enabled
* chain show [<model>] local: ~/.olp/config.json routing.chains
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
* restart launchctl (macOS) / systemctl --user (Linux)
* doctor [--check X] run lib/doctor.mjs runDoctor + format
* keys ... delegate to bin/olp-keys.mjs
* help | --help usage
*
* Global flags:
* --json emit raw JSON (silences human-readable output)
* --proxy-url=<url> override resolved proxy URL
* --olp-home=<path> override ~/.olp
*
* URL resolution:
* OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567}
*
* Auth (Bearer token) resolution:
* 1. OLP_API_KEY env
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
* 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk
*
* Note: the third option only works during the same session in which `olp-keys
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
* reports "no owner token available" + remediation instructions if env vars are
* absent — it does NOT try to crack the hash.
*
* Exit codes:
* 0 = success
* 1 = bad usage / unknown subcommand
* 2 = network or HTTP error (4xx/5xx)
* 3 = auth missing / forbidden
*/
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { URL } from 'node:url';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn as spawnProc } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
import { listKeys } from '../lib/keys.mjs';
import { readAuditWindow } from '../lib/audit-query.mjs';
import modelsRegistry from '../models-registry.json' with { type: 'json' };
import { runCli as runKeysCli } from './olp-keys.mjs';
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
const ANSI = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
function colorize(s, code, useColor) {
if (!useColor) return s;
return `${code}${s}${ANSI.reset}`;
}
function statusBadge(status, useColor) {
const map = {
ok: { txt: 'PASS', col: ANSI.green },
warn: { txt: 'WARN', col: ANSI.yellow },
fail: { txt: 'FAIL', col: ANSI.red },
};
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
return colorize(`[${m.txt}]`, m.col, useColor);
}
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
export function parseArgv(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq > 0) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const name = a.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
} else {
flags[name] = true;
}
}
} else {
positional.push(a);
}
}
return { positional, flags };
}
// ── Output helpers (respect --json) ───────────────────────────────────────
function makeIO(opts) {
const out = opts.out ?? (s => process.stdout.write(s));
const err = opts.err ?? (s => process.stderr.write(s));
const wantJson = opts.json === true;
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
// (debug noise suppression per the bundle requirements). `errln` always writes
// to stderr.
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
const errln = (...parts) => err(parts.join(' ') + '\n');
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
}
// ── HTTP helper ───────────────────────────────────────────────────────────
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let urlObj;
try { urlObj = new URL(url); }
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
const isHttps = urlObj.protocol === 'https:';
const reqFn = isHttps ? httpsRequest : httpRequest;
let req;
try {
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
});
} catch (e) {
return finish({ ok: false, error: String(e?.message ?? e) });
}
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
req.on('timeout', () => {
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
});
req.end();
});
}
// ── Token resolution ──────────────────────────────────────────────────────
/**
* Resolve a Bearer token. Returns the plaintext token string or null.
* Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed).
*/
export function resolveBearerToken() {
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
return null;
}
function authHeaders() {
const tok = resolveBearerToken();
return tok ? { Authorization: `Bearer ${tok}` } : {};
}
// ── HTTP-error → exit code mapping ────────────────────────────────────────
function httpErrorToExit(res, io) {
if (!res.ok) {
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
return 2;
}
io.errln(`Error: network error: ${res.error}`);
return 2;
}
if (res.status === 401) {
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
return 3;
}
if (res.status === 403) {
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
return 3;
}
if (res.status >= 400) {
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
return 2;
}
return 0;
}
// ── Human-readable formatters ─────────────────────────────────────────────
function formatBytes(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
}
function formatMs(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
}
/** formatAgo(diffMs) — "N min ago" / "Nh ago" from a millisecond diff. */
function formatAgo(diffMs) {
if (typeof diffMs !== 'number' || diffMs < 0) return 'just now';
const sec = Math.floor(diffMs / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
return `${Math.floor(min / 60)}h ago`;
}
/**
* formatResetCountdown(epochSeconds) → human-readable reset countdown string.
*
* Mirrors dashboard.html formatResetCountdown(). Five ranges:
* past / < 1h / < 24h / < 7d / ≥ 7d
*
* Authority: ADR 0008 Amendment 2 (quota_v2 shape); ported from
* dashboard.html (D82). No external deps. Pure formatter.
*
* @param {number|null} epochSeconds — Unix epoch seconds for reset time
* @returns {string}
*/
export function formatResetCountdown(epochSeconds) {
if (epochSeconds == null) return '—';
const nowMs = Date.now();
const targetMs = epochSeconds * 1000;
const diffMs = targetMs - nowMs;
if (diffMs <= 0) return 'resetting now';
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 60) return `resets in ${diffMin}m`;
if (diffHr < 24) {
const remMin = diffMin - diffHr * 60;
if (remMin === 0) return `resets in ${diffHr}h`;
return `resets in ${diffHr}h ${remMin}m`;
}
const target = new Date(targetMs);
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
if (diffDay < 7) {
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
return `resets ${dayStr} ${timeStr}`;
}
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
return `resets ${dateStr} ${timeStr}`;
}
// ── Subcommand: status ────────────────────────────────────────────────────
async function cmdStatus(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize('OLP status', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` version: ${body.version}`);
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
io.log(` started: ${body.started_at}`);
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
if (body.providers?.status && typeof body.providers.status === 'object') {
for (const [name, s] of Object.entries(body.providers.status)) {
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
}
}
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
// D75 F4 fix: server payload nests cache stats under stats.cache (per
// server.mjs handleManagementStatus, ~line 2092). The CacheStore.stats()
// contract returns { hits, misses, size, inflightCount } per
// lib/cache/store.mjs — there is no `entries` field. Pre-D75 cmdStatus read
// `body.stats.cache.entries` (OCP-era) which was always undefined → output
// showed "entries=?". Same pattern as D74 P2-3 applied to cmdCache/cmdUsage.
if (body.stats?.cache) {
const c = body.stats.cache;
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.size ?? 0}${typeof c.inflightCount === 'number' ? ` inflight=${c.inflightCount}` : ''}`);
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
io.log(` recent errors: ${body.recent_errors.length}`);
for (const e of body.recent_errors.slice(0, 5)) {
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'}${(e.message ?? '').slice(0, 80)}`);
}
}
return 0;
}
// ── Subcommand: health ────────────────────────────────────────────────────
async function cmdHealth(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const [k, v] of Object.entries(body)) {
if (typeof v === 'object' && v !== null) {
io.log(` ${k}:`);
for (const [k2, v2] of Object.entries(v)) {
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
}
} else {
io.log(` ${k}: ${v}`);
}
}
return 0;
}
// ── Subcommand: usage ─────────────────────────────────────────────────────
async function cmdUsage(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
// D74 P2-3 fix: server payload shape is { generated_at, window_24h: { request_count, status_2xx,
// status_4xx, status_5xx, by_provider, by_owner_tier, by_path, median_latency_ms, p95_latency_ms },
// cache_hit_24h: { total, hit, miss, bypass, streaming_attached, hit_rate, by_provider }, quota: [{provider, ...}],
// spend_trend_30d: [{date, request_count, by_provider}], top_fallback_chains_24h: [{chain, count, ...}],
// cache_stats: { hits, misses, size, inflightCount } } per server.mjs:2027 + lib/audit-query.mjs.
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
const w24 = body.window_24h ?? {};
const cache24 = body.cache_hit_24h ?? {};
if (typeof w24 === 'object' && (w24.request_count ?? 0) > 0) {
io.log(` requests: ${w24.request_count}`);
io.log(` 2xx / 4xx / 5xx: ${w24.status_2xx ?? 0} / ${w24.status_4xx ?? 0} / ${w24.status_5xx ?? 0}`);
if (typeof w24.median_latency_ms === 'number') {
io.log(` latency p50/p95: ${w24.median_latency_ms}ms / ${w24.p95_latency_ms ?? 0}ms`);
}
if (typeof cache24.hit_rate === 'number') {
const pct = (cache24.hit_rate * 100).toFixed(1);
io.log(` cache hit rate: ${pct}% (hit=${cache24.hit ?? 0} miss=${cache24.miss ?? 0}${cache24.streaming_attached ? ` streaming_attached=${cache24.streaming_attached}` : ''})`);
}
} else {
io.log(' (no 24h usage data — server may not have processed any requests yet)');
}
// F4 (v0.5.1 codex post-release review Q4): prefer quota_v2 when present
// (server v0.5.0+), fall back to legacy quota array on older servers.
// Authority: ADR 0008 Amendment 2 (quota_v2 shape).
if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) {
io.log('');
io.log(colorize('Per-provider quota (live)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const p of body.quota_v2) {
const label = String(p.provider ?? '?').toUpperCase().padEnd(12);
const status = p.status ?? 'unavailable';
if (status === 'unavailable') {
io.log(` ${colorize(label, ANSI.gray, io.useColor)} unavailable ${p.reason ?? 'no public quota api'}`);
} else if (status === 'unreachable') {
const fk = p.failure?.kind ?? 'unknown';
const fm = p.failure?.message ?? 'probe failed';
io.log(` ${colorize(label, ANSI.red, io.useColor)} ❌ no cached data — failure: ${fk} (${fm})`);
} else {
// live or stale
const staleWarn = status === 'stale'
? colorize(` ⚠ stale${p.last_fresh_at ? ` (${formatAgo(Date.now() - p.last_fresh_at)})` : ''} failure: ${p.failure?.kind ?? 'unknown'}`, ANSI.yellow, io.useColor)
: '';
const util = p.utilization ?? {};
const reset = p.reset ?? {};
const parts = [];
for (const window of ['5h', '7d']) {
const frac = util[window];
const resetEpoch = reset[window];
if (frac != null) {
const pct = `${Math.round(frac * 100)}%`;
const rst = formatResetCountdown(resetEpoch);
parts.push(`${window}: ${colorize(pct, frac >= 0.8 ? ANSI.red : frac >= 0.5 ? ANSI.yellow : ANSI.green, io.useColor)} (${rst})`);
}
}
const binding = p.representative_claim ? ` binding: ${p.representative_claim.replace('_', '-')}` : '';
io.log(` ${colorize(label, ANSI.bold, io.useColor)} ${colorize(status, status === 'live' ? ANSI.green : ANSI.yellow, io.useColor).padEnd(6)} ${parts.join(' ')}${binding}${staleWarn}`);
}
}
} else if (Array.isArray(body.quota) && body.quota.length > 0) {
// Legacy fallback for pre-v0.5.0 servers
io.log('');
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const p of body.quota) {
const label = String(p.provider ?? '?').padEnd(12);
if (p.error) {
io.log(` ${label} error: ${p.error}`);
} else if (typeof p.percent_used === 'number') {
io.log(` ${label} ${p.percent_used}% used${p.resets_in_human ? ` (resets in ${p.resets_in_human})` : ''}`);
} else if (p.available === false) {
io.log(` ${label} unavailable`);
} else {
io.log(` ${label} no quota api`);
}
}
}
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
io.log('');
io.log(colorize('Top fallback chains (24h)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const f of body.top_fallback_chains_24h.slice(0, 10)) {
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
}
}
return 0;
}
// ── Subcommand: models ────────────────────────────────────────────────────
async function cmdModels(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const m of body.data ?? []) {
io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`);
}
return 0;
}
// ── Subcommand: cache ─────────────────────────────────────────────────────
async function cmdCache(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
// D74 P2-3 fix: cacheStore.stats() returns { hits, misses, size, inflightCount }
// per lib/cache/store.mjs:320. There is no entries / evictions / bytes / maxBytes
// in the OLP cache model — those were OCP-era field names. Compute a hit rate from
// the numerator/denominator instead of fabricating bytes.
const hits = body.hits ?? 0;
const misses = body.misses ?? 0;
const denom = hits + misses;
const hitRate = denom > 0 ? ((hits / denom) * 100).toFixed(1) : '0.0';
io.log(colorize('OLP cache (live in-memory)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` entries: ${body.size ?? 0}`);
io.log(` hits / misses: ${hits} / ${misses} (hit rate ${hitRate}%)`);
io.log(` inflight: ${body.inflightCount ?? 0}`);
if (body.generated_at) io.log(` generated_at: ${body.generated_at}`);
return 0;
}
// ── Subcommand: providers (local) ─────────────────────────────────────────
function cmdProviders(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let enabled = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
enabled = cfg?.providers?.enabled ?? {};
} catch { /* fine — empty enabled map */ }
const providers = modelsRegistry?.providers ?? {};
const rows = [];
for (const [name, p] of Object.entries(providers)) {
rows.push({
name,
displayName: p?.displayName ?? name,
tier: p?.tier ?? '?',
modelCount: (p?.models ?? []).length,
enabled: enabled[name] === true,
candidate: p?.candidate === true,
});
}
if (io.wantJson) {
io.emitJson({ providers: rows, config_path: configPath });
return 0;
}
io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const r of rows) {
const enabledTxt = r.enabled
? colorize('enabled ', ANSI.green, io.useColor)
: colorize('disabled', ANSI.gray, io.useColor);
const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : '';
io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`);
}
io.log('');
io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor));
return 0;
}
// ── Subcommand: chain show ────────────────────────────────────────────────
function cmdChainShow(positional, flags, io) {
const target = positional[0] ?? null; // model name, or null = print all
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let chains = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
chains = cfg?.routing?.chains ?? {};
} catch { /* empty */ }
if (io.wantJson) {
if (target) {
io.emitJson({ model: target, chain: chains[target] ?? null });
} else {
io.emitJson({ chains });
}
return 0;
}
io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
if (Object.keys(chains).length === 0) {
io.log(` (no chains configured in ${configPath})`);
return 0;
}
if (target) {
const chain = chains[target];
if (!chain) {
io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`);
return 1;
}
io.log(` ${target}:`);
for (const hop of chain) {
io.log(`${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
} else {
for (const [model, chain] of Object.entries(chains)) {
io.log(` ${model}:`);
for (const hop of chain) {
io.log(`${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
}
}
return 0;
}
// ── Subcommand: logs ──────────────────────────────────────────────────────
async function cmdLogs(positional, flags, io) {
const n = positional[0] ? parseInt(positional[0], 10) : 20;
if (!Number.isFinite(n) || n <= 0) {
io.errln(`Error: invalid log count "${positional[0]}"`);
return 1;
}
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
// readAuditWindow is a generator over [startMs, endMs). Default window = last 24h.
const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000;
const endMs = Date.now();
const startMs = endMs - windowMs;
let events = [];
try {
for (const ev of readAuditWindow({ startMs, endMs, olpHome })) {
events.push(ev);
}
} catch (e) {
io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`);
return 2;
}
let filtered = events;
if (flags.level) {
filtered = filtered.filter(e => e.level === flags.level);
}
// Tail (audit events are already chronological per generator order).
filtered = filtered.slice(-n);
if (io.wantJson) {
io.emitJson({ events: filtered });
return 0;
}
io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const e of filtered) {
// Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is
// not always present in audit ndjson (it is in stderr-side logEvent).
const level = (e.level ?? 'info').toUpperCase();
const levelColor =
level === 'ERROR' ? ANSI.red
: level === 'WARN' ? ANSI.yellow
: ANSI.gray;
const summary = e.message ?? e.error ?? '';
io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`);
}
return 0;
}
// ── Subcommand: restart ───────────────────────────────────────────────────
async function cmdRestart(flags, io) {
// macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy
// Linux: systemctl --user restart olp-proxy
// Neither installed → fall through to a helpful error.
//
// CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per
// ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k`
// does NOT re-read the plist's EnvironmentVariables block — launchd
// sticks to its cached env from the most recent bootstrap. If you edited
// ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will
// silently use stale values. Use `launchctl bootout gui/<uid>/dev.olp.proxy`
// followed by `launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.olp.proxy.plist`
// to force a clean env reload. The Phase 4 installer (planned post-D73)
// will expose `olp restart --full` for the bootout/bootstrap dance.
const platform = process.platform;
const uid = process.getuid?.() ?? null;
let cmd, args;
if (platform === 'darwin') {
if (uid == null) {
io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl');
return 2;
}
cmd = 'launchctl';
args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`];
} else if (platform === 'linux') {
cmd = 'systemctl';
args = ['--user', 'restart', 'olp-proxy'];
} else {
io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`);
io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`);
return 2;
}
// Spawn + wait for exit; bubble up any error.
const result = await new Promise(resolve => {
const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' });
child.on('error', e => resolve({ code: -1, error: e }));
child.on('exit', code => resolve({ code }));
});
if (result.code === 0) {
if (io.wantJson) {
io.emitJson({ ok: true, cmd, args });
} else {
io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor));
}
return 0;
}
if (result.error?.code === 'ENOENT' || result.code === 127) {
io.errln(`Error: '${cmd}' not found on this system.`);
if (platform === 'darwin') {
io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`);
} else {
io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`);
}
return 2;
}
io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`);
return 2;
}
// ── Subcommand: doctor ────────────────────────────────────────────────────
async function cmdDoctor(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
// D74 P1-1: pass authHeaders so server.running / server.version checks
// succeed under the default production posture (auth.allow_anonymous:
// false). resolveBearerToken returns null when no env var is set; the
// doctor still runs but distinguishes 401 from "server down" by status
// code per the updated check.
const result = await runDoctor({
olpHome,
proxyUrl,
checkFilter,
authHeaders: authHeaders(),
});
if (io.wantJson) {
io.emitJson(result);
return result.fail_count === 0 ? 0 : 2;
}
io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const c of result.checks) {
io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`);
}
io.log('');
io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`);
if (result.next_action.ai_executable.length > 0) {
io.log('');
io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor));
for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`);
}
if (result.next_action.human_required.length > 0) {
io.log('');
io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor));
for (const step of result.next_action.human_required) io.log(`${step}`);
}
io.log('');
io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor));
return result.fail_count === 0 ? 0 : 2;
}
// ── Subcommand: keys (delegate) ───────────────────────────────────────────
async function cmdKeys(rest, io) {
// Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings
// (no \n needed since the underlying CLI emits them itself).
return await runKeysCli(rest, {
out: s => process.stdout.write(s),
err: s => process.stderr.write(s),
});
}
// ── Usage ──────────────────────────────────────────────────────────────────
const USAGE = `OLP operator CLI — Phase 4 (ADR 0010)
Usage:
olp <subcommand> [args] [--json] [--proxy-url=<url>] [--olp-home=<path>]
Subcommands:
status GET /v0/management/status (owner-only)
health GET /health
usage GET /v0/management/dashboard-data (owner-only)
models GET /v1/models
cache GET /cache/stats (owner-only)
providers list providers (registry + config providers.enabled)
chain show [<model>] print routing.chains from ~/.olp/config.json
logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson
restart launchctl (macOS) / systemctl --user (Linux)
doctor [--check X] run diagnostic checks (id, category, or prefix filter)
keys [args ...] delegate to bin/olp-keys.mjs
help print this message
Global flags:
--json emit raw JSON (silences human-readable formatting)
--proxy-url=<url> override resolved proxy URL
--olp-home=<path> override ~/.olp
Env:
OLP_PROXY_URL full URL (overrides OLP_PORT)
OLP_PORT port for default URL (default: 4567)
OLP_API_KEY Bearer token for the proxy
OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4)
OLP_HOME ~/.olp override
Exit codes:
0 success
1 bad usage / unknown subcommand
2 network or HTTP error (4xx/5xx)
3 auth missing / forbidden`;
// ── runCli (testable entry) ───────────────────────────────────────────────
/**
* Run the CLI with explicit argv + IO streams. Returns the exit code (no
* process.exit). Exported for tests.
*
* @param {string[]} argv args after the script name
* @param {object} [opts]
* @param {(s: string) => void} [opts.out]
* @param {(s: string) => void} [opts.err]
* @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings
* @returns {Promise<number>}
*/
export async function runCli(argv, opts = {}) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
const io = makeIO({ ...opts, json: false });
io.log(USAGE);
return argv.length === 0 ? 1 : 0;
}
const [subcommand, ...rest] = argv;
// `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs.
if (subcommand === 'keys') {
const io = makeIO({ ...opts, json: false });
return await cmdKeys(rest, io);
}
const { positional, flags } = parseArgv(rest);
const json = flags.json === true;
const io = makeIO({ ...opts, json });
switch (subcommand) {
case 'status': return await cmdStatus(flags, io);
case 'health': return await cmdHealth(flags, io);
case 'usage': return await cmdUsage(flags, io);
case 'models': return await cmdModels(flags, io);
case 'cache': return await cmdCache(flags, io);
case 'providers': return cmdProviders(flags, io);
case 'chain': {
// `olp chain show [model]`
const sub = positional[0];
if (sub !== 'show') {
io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`);
return 1;
}
return cmdChainShow(positional.slice(1), flags, io);
}
case 'logs': return await cmdLogs(positional, flags, io);
case 'restart': return await cmdRestart(flags, io);
case 'doctor': return await cmdDoctor(flags, io);
default:
io.errln(`Error: unknown subcommand "${subcommand}".`);
io.errln(USAGE);
return 1;
}
}
// ── Main guard ────────────────────────────────────────────────────────────
function _isMain() {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (_isMain()) {
runCli(process.argv.slice(2))
.then(code => process.exit(code))
.catch(e => {
process.stderr.write(`Fatal: ${e?.stack ?? e}\n`);
process.exit(2);
});
}
+859
View File
@@ -0,0 +1,859 @@
<!DOCTYPE html>
<!--
OLP Dashboard — Phase 5 / D82
------------------------------
Multi-panel owner-only dashboard per ADR 0008 § 6.
Panels:
0. Plan Usage (new D82 — Claude.ai-style per-provider rows; quota_v2; 1-min refresh)
1. Per-provider quota / credit pool (legacy; kept for graceful fallback when quota_v2 absent)
2. Per-provider 24h request count + cache hit rate + fallback rate (30s refresh)
3. 30-day spend trend (SVG sparkline; per-provider in tooltip) (30s refresh)
4. Top 10 fallback chains by trigger count (30s refresh)
Refresh cadence:
- Plan Usage panel: 60s (separate timer; visibilityState-guarded per ADR 0012 D82)
- Other panels: 30s (original poll cadence; paused when tab hidden)
Authority:
- ADR 0008 § 6 — dashboard layout + owner-only_block
- ADR 0012 D82 — quota_v2 Claude.ai-style restructure
- v1.x roadmap #8 — closed by this D-day
No build step, no framework, no external dependencies. Vanilla JS +
fetch + DOM render. Owner-only_block: anonymous / guest / no-auth all
receive 401 — non-owner identities will see an error banner.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>OLP Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 1.5rem; background: #f9fafb; color: #1f2937; }
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
.meta { color: #6b7280; font-size: 0.875rem; margin-bottom: 1.5rem; }
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; max-width: 1200px; }
.panel { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 1rem 1.25rem; }
.panel h2 { margin: 0 0 0.75rem; font-size: 1rem; color: #374151; font-weight: 600; }
.panel-error { color: #b91c1c; font-style: italic; padding: 0.5rem 0; }
.panel-loading { color: #6b7280; font-style: italic; padding: 0.5rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #f3f4f6; }
th { font-weight: 600; color: #4b5563; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.banner { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 0.75rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.banner.error { background: #fee2e2; border-color: #ef4444; color: #991b1b; }
.sparkline { width: 100%; height: 120px; }
.sparkline rect { fill: #3b82f6; }
.sparkline rect:hover { fill: #1d4ed8; }
.chain { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.85rem; color: #374151; }
.pill { display: inline-block; background: #e5e7eb; color: #374151; padding: 0.05rem 0.4rem; border-radius: 3px; font-size: 0.75rem; }
footer { margin-top: 2rem; color: #9ca3af; font-size: 0.75rem; text-align: center; }
/* ───────────────────────────────────────────
Plan Usage panel — D82 Claude.ai-style rows
─────────────────────────────────────────── */
.plan-usage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.plan-usage-header h2 { margin: 0; }
.plan-usage-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: #6b7280;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.75rem;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.8rem;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
}
.refresh-btn:hover:not(:disabled) { background: #f9fafb; border-color: #9ca3af; }
.refresh-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.refresh-btn .spin { display: inline-block; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.provider-row {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
background: #fff;
}
.provider-row:last-child { margin-bottom: 0; }
.provider-row.unavailable { background: #f9fafb; }
.provider-row.stale { border-color: #fcd34d; }
.provider-row.unreachable { border-color: #fca5a5; background: #fff5f5; }
.provider-row-top {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.provider-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #fff;
}
.provider-badge.anthropic { background: #cc4b24; }
.provider-badge.codex { background: #10a37f; }
.provider-badge.mistral { background: #6d5acd; }
.provider-badge.openai { background: #10a37f; }
.provider-badge.default { background: #6b7280; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.live { background: #10b981; }
.status-dot.stale { background: #f59e0b; }
.status-dot.unavailable { background: #9ca3af; }
.status-dot.unreachable { background: #ef4444; }
.status-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 99px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.status-chip.live { background: #d1fae5; color: #065f46; }
.status-chip.stale { background: #fef3c7; color: #92400e; }
.status-chip.unavailable { background: #f3f4f6; color: #6b7280; }
.status-chip.unreachable { background: #fee2e2; color: #991b1b; }
.chip-sm {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
color: #374151;
background: #f3f4f6;
border: 1px solid #e5e7eb;
}
.schema-tag {
margin-left: auto;
font-size: 0.7rem;
color: #9ca3af;
}
.unavailable-reason {
font-size: 0.875rem;
color: #9ca3af;
font-style: italic;
padding: 0.25rem 0 0;
}
.unreachable-reason {
font-size: 0.875rem;
color: #b91c1c;
font-style: italic;
padding: 0.25rem 0 0;
}
.last-fresh-tag {
font-size: 0.7rem;
color: #9ca3af;
}
.utilization-bars { display: flex; flex-direction: column; gap: 0.6rem; }
.util-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.util-label {
flex: 0 0 180px;
font-size: 0.8rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 600px) {
.util-label { flex: 0 0 100%; }
.util-row { flex-direction: column; align-items: flex-start; }
.grid { grid-template-columns: 1fr; }
}
.util-bar-wrap {
flex: 1 1 120px;
min-width: 80px;
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
}
.util-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.3s ease;
}
.util-bar-fill.green { background: linear-gradient(90deg, #34d399, #10b981); }
.util-bar-fill.amber { background: linear-gradient(90deg, #fbbf24, #f59e0b); }
.util-bar-fill.red { background: linear-gradient(90deg, #f87171, #ef4444); }
.util-pct {
flex: 0 0 40px;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: #374151;
text-align: right;
}
.util-reset {
flex: 0 0 auto;
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
}
.rep-claim-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #ede9fe;
color: #5b21b6;
border: 1px solid #ddd6fe;
font-weight: 600;
}
.overage-chip {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 4px;
font-size: 0.7rem;
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.overage-chip.allowed {
background: #d1fae5;
color: #065f46;
border-color: #6ee7b7;
}
.provider-row-bottom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.65rem;
flex-wrap: wrap;
}
</style>
</head>
<body>
<h1>OLP Dashboard</h1>
<div id="meta" class="meta">Loading…</div>
<div id="banner-slot"></div>
<!-- Plan Usage panel (D82 — Claude.ai-style; full width) -->
<section class="panel" style="max-width: 1200px; margin-bottom: 1rem;">
<div class="plan-usage-header">
<h2>Plan Usage</h2>
<div class="plan-usage-meta">
<span id="quota-last-refresh"></span>
<button class="refresh-btn" id="quota-refresh-btn" title="Refresh quota data">
<span id="quota-refresh-icon"></span> Refresh
</button>
</div>
</div>
<div id="panel-plan-usage"><div class="panel-loading">Loading…</div></div>
</section>
<div class="grid">
<section class="panel" id="legacy-quota-section" style="display:none;">
<h2>Quota (per provider) — legacy</h2>
<div id="panel-quota"><div class="panel-loading">Loading…</div></div>
</section>
<section class="panel">
<h2>Last 24h — request count · cache hit · fallback rate</h2>
<div id="panel-24h"><div class="panel-loading">Loading…</div></div>
</section>
<section class="panel" style="grid-column: span 2;">
<h2>Request count — last 30 days (UTC)</h2>
<div id="panel-trend"><div class="panel-loading">Loading…</div></div>
</section>
<section class="panel" style="grid-column: span 2;">
<h2>Top fallback chains (last 24h)</h2>
<div id="panel-chains"><div class="panel-loading">Loading…</div></div>
</section>
</div>
<footer>OLP Dashboard · Plan Usage: 60s refresh · other panels: 30s · paused when tab hidden · v0.5.1</footer>
<script>
(function () {
'use strict';
/* ─────────────── constants ─────────────── */
const POLL_INTERVAL_MS = 30000; // 30s for legacy panels
const QUOTA_POLL_INTERVAL_MS = 60000; // 60s for Plan Usage (D82)
let pollHandle = null;
let quotaRefreshTimer = null;
/* ─────────────── DOM helpers ─────────────── */
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
function fmtPct(rate) { return (rate * 100).toFixed(1) + '%'; }
function el(tag, attrs, ...children) {
const node = document.createElement(tag);
if (attrs) for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'style') node.style.cssText = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c);
}
return node;
}
function svgEl(tag, attrs) {
const node = document.createElementNS('http://www.w3.org/2000/svg', tag);
if (attrs) for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
return node;
}
/* ─────────────── Reset countdown helper (D82 § B) ─────────────── */
/**
* formatResetCountdown(epochSeconds) → human-readable string
*
* - past: "Resetting now…"
* - < 1 hour: "Resets in 23 min"
* - < 24 hours: "Resets in 12hr 30min"
* - < 7 days: "Resets Sun 9:00 PM"
* - >= 7 days: "Resets May 31 9:00 PM"
*/
function formatResetCountdown(epochSeconds) {
if (epochSeconds == null) return '—';
const nowMs = Date.now();
const targetMs = epochSeconds * 1000;
const diffMs = targetMs - nowMs;
if (diffMs <= 0) return 'Resetting now…';
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 60) {
return 'Resets in ' + diffMin + ' min';
}
if (diffHr < 24) {
const remMin = diffMin - diffHr * 60;
if (remMin === 0) return 'Resets in ' + diffHr + 'hr';
return 'Resets in ' + diffHr + 'hr ' + remMin + 'min';
}
// Format as "Resets <day-of-week> <time>" or "Resets <month> <day> <time>"
const target = new Date(targetMs);
const timeStr = target.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
if (diffDay < 7) {
const dayStr = target.toLocaleString('en-US', { weekday: 'short' });
return 'Resets ' + dayStr + ' ' + timeStr;
}
const dateStr = target.toLocaleString('en-US', { month: 'short', day: 'numeric' });
return 'Resets ' + dateStr + ' ' + timeStr;
}
/* ─────────────── "Updated N min ago" helper ─────────────── */
function formatAgo(epochMs) {
if (epochMs == null) return '';
const diffMs = Date.now() - epochMs;
if (diffMs < 0) return 'just now';
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Updated just now';
const diffMin = Math.floor(diffSec / 60);
if (diffMin === 1) return 'Updated 1 min ago';
if (diffMin < 60) return 'Updated ' + diffMin + ' min ago';
const diffHr = Math.floor(diffMin / 60);
if (diffHr === 1) return 'Updated ~1hr ago';
return 'Updated ~' + diffHr + 'hr ago';
}
/* ─────────────── Utilization bar color ─────────────── */
function utilizationColor(fraction) {
if (fraction == null) return 'green';
if (fraction >= 0.80) return 'red';
if (fraction >= 0.50) return 'amber';
return 'green';
}
/* ─────────────── Provider badge color class ─────────────── */
function providerBadgeClass(name) {
const n = (name || '').toLowerCase();
if (n === 'anthropic') return 'anthropic';
if (n === 'codex') return 'codex';
if (n === 'mistral') return 'mistral';
if (n === 'openai') return 'openai';
return 'default';
}
/* ─────────────── Plan Usage renderer (quota_v2) ─────────────── */
function renderPlanUsage(quotaV2) {
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
if (!Array.isArray(quotaV2) || quotaV2.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No quota data available.'));
return;
}
const frag = document.createDocumentFragment();
for (const entry of quotaV2) {
const status = entry.status || 'unavailable';
const rowEl = el('div', { class: 'provider-row ' + status });
/* ── top bar: badge + status dot + chips + schema tag ── */
const topBar = el('div', { class: 'provider-row-top' });
topBar.appendChild(el('span', { class: 'provider-badge ' + providerBadgeClass(entry.provider) }, (entry.provider || '').toUpperCase()));
topBar.appendChild(el('span', { class: 'status-dot ' + status, title: 'Status: ' + status }));
topBar.appendChild(el('span', { class: 'status-chip ' + status }, status));
if (entry.schema_version) {
topBar.appendChild(el('span', { class: 'schema-tag' }, 'schema: ' + entry.schema_version));
}
rowEl.appendChild(topBar);
/* ── unavailable: just show reason, no bars ── */
if (status === 'unavailable') {
const reason = entry.reason || 'no public quota api or probe disabled';
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, reason));
frag.appendChild(rowEl);
continue;
}
/* ── unreachable (v0.5.1): probe failed + no cache — show failure detail ── */
if (status === 'unreachable') {
const failure = entry.failure || {};
const kind = failure.kind || 'unknown';
const msg = failure.message || 'probe failed — no cached data available';
const shortText = `${kind}: ${msg}`;
rowEl.appendChild(el('div', { class: 'unreachable-reason' }, shortText));
if (failure.backoff_until) {
const backoffMs = Math.max(0, failure.backoff_until - Date.now());
const backoffSec = Math.round(backoffMs / 1000);
if (backoffSec > 0) {
rowEl.appendChild(el('div', { class: 'unavailable-reason' }, `backoff active: ${backoffSec}s remaining`));
}
}
frag.appendChild(rowEl);
continue;
}
/* ── utilization bars (5h + 7d) ── */
const util = entry.utilization || {};
const reset = entry.reset || {};
const barsWrap = el('div', { class: 'utilization-bars' });
const windows = [
{ key: '5h', label: 'Current 5-hour session' },
{ key: '7d', label: 'Weekly all-models' },
];
for (const w of windows) {
const frac = util[w.key];
const resetEpoch = reset[w.key];
const color = utilizationColor(frac);
const pctStr = frac != null ? Math.round(frac * 100) + '%' : '—';
const fillPct = frac != null ? Math.min(100, Math.round(frac * 100)) : 0;
const resetStr = formatResetCountdown(resetEpoch);
const utilRow = el('div', { class: 'util-row' });
utilRow.appendChild(el('span', { class: 'util-label', title: w.label },
w.label + (frac != null ? ': ' + pctStr : '')
));
const barWrap = el('div', { class: 'util-bar-wrap' });
barWrap.appendChild(el('div', {
class: 'util-bar-fill ' + color,
style: 'width: ' + fillPct + '%',
'aria-valuenow': fillPct,
'aria-valuemin': '0',
'aria-valuemax': '100',
role: 'progressbar',
}));
utilRow.appendChild(barWrap);
utilRow.appendChild(el('span', { class: 'util-pct' }, pctStr));
utilRow.appendChild(el('span', { class: 'util-reset' }, resetStr));
barsWrap.appendChild(utilRow);
}
rowEl.appendChild(barsWrap);
/* ── bottom chips: representative-claim, overage, last-fresh ── */
const bottomBar = el('div', { class: 'provider-row-bottom' });
if (entry.representative_claim) {
const claimLabel = entry.representative_claim === 'five_hour' ? '5-hour claim'
: entry.representative_claim === 'seven_day' ? '7-day claim'
: entry.representative_claim;
bottomBar.appendChild(el('span', { class: 'rep-claim-badge', title: 'Binding window: ' + entry.representative_claim }, claimLabel));
}
if (entry.overage && entry.overage.status) {
const ov = entry.overage;
const ovStatus = (ov.status || 'unknown').toLowerCase();
const isAllowed = ovStatus === 'allowed' || ovStatus === 'active';
const chipClass = isAllowed ? 'overage-chip allowed' : 'overage-chip';
const label = 'Overage: ' + (ov.status || '—')
+ (ov.disabled_reason ? ' (' + ov.disabled_reason + ')' : '');
bottomBar.appendChild(el('span', { class: chipClass, title: label }, label));
}
if (entry.fallback_percentage != null) {
const fpPct = Math.round(entry.fallback_percentage * 100) + '%';
bottomBar.appendChild(el('span', { class: 'chip-sm', title: 'Fallback rate (last window)' }, 'Fallback ' + fpPct));
}
if (entry.last_fresh_at) {
bottomBar.appendChild(el('span', { class: 'last-fresh-tag' }, formatAgo(entry.last_fresh_at)));
}
if (status === 'stale') {
const staleTitle = entry.last_fresh_at
? 'Last successful probe was ' + formatAgo(entry.last_fresh_at) + '; backoff active'
: 'Probe data is stale; backoff active';
bottomBar.appendChild(el('span', { class: 'chip-sm', style: 'color: #92400e; background: #fef3c7; border-color: #fcd34d;', title: staleTitle }, '⚠ stale data'));
}
rowEl.appendChild(bottomBar);
frag.appendChild(rowEl);
}
target.appendChild(frag);
}
/* ─────────────── Legacy quota renderer (graceful fallback) ─────────────── */
function renderQuota(data) {
const target = document.getElementById('panel-quota');
target.innerHTML = '';
if (!Array.isArray(data) || data.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No providers enabled.'));
return;
}
const table = el('table', null,
el('thead', null, el('tr', null,
el('th', null, 'Provider'),
el('th', null, 'Available'),
el('th', null, 'Status'),
)),
);
const tbody = el('tbody');
for (const row of data) {
const available = row.error ? el('span', { class: 'pill' }, 'unavailable')
: row.available === null || row.available === undefined ? el('span', { class: 'pill' }, 'n/a')
: el('span', null, fmtNum(row.available));
const status = row.error ? el('span', { style: 'color: #b91c1c;' }, row.error)
: row.available === null || row.available === undefined ? 'no quota API'
: 'ok';
tbody.appendChild(el('tr', null,
el('td', null, row.provider),
el('td', { class: 'num' }, available),
el('td', null, status),
));
}
table.appendChild(tbody);
target.appendChild(table);
}
/* ─────────────── Plan Usage top-level render + quota routing ─────────────── */
function renderQuotaSection(data) {
const hasV2 = Array.isArray(data.quota_v2) && data.quota_v2.length > 0;
const legacySection = document.getElementById('legacy-quota-section');
if (hasV2) {
// D82: use enriched quota_v2 rows; hide legacy panel
legacySection.style.display = 'none';
renderPlanUsage(data.quota_v2);
} else {
// Graceful fallback: show legacy quota panel (older server build without D81)
legacySection.style.display = '';
// Also show legacy data in Plan Usage panel with a note
const target = document.getElementById('panel-plan-usage');
target.innerHTML = '';
target.appendChild(el('div', { class: 'panel-loading', style: 'color:#6b7280;' },
'quota_v2 not available (server may not have D81 yet). See legacy Quota panel below.'));
renderQuota(data.quota);
}
}
/* ─────────────── Other panel renderers (unchanged from D51) ─────────────── */
function render24h(window24h, cacheHit24h) {
const target = document.getElementById('panel-24h');
target.innerHTML = '';
const byProvider = (window24h && window24h.by_provider) || {};
const providers = Object.keys(byProvider);
if (providers.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No requests in window.'));
return;
}
const table = el('table', null,
el('thead', null, el('tr', null,
el('th', null, 'Provider'),
el('th', null, 'Requests'),
el('th', null, 'Cache hit'),
el('th', null, 'Fallback rate'),
)),
);
const tbody = el('tbody');
for (const p of providers) {
const pData = byProvider[p];
const hitData = (cacheHit24h && cacheHit24h.by_provider && cacheHit24h.by_provider[p]) || null;
const fallbackRate = pData.count > 0 ? pData.fallback_count / pData.count : 0;
tbody.appendChild(el('tr', null,
el('td', null, p),
el('td', { class: 'num' }, fmtNum(pData.count)),
el('td', { class: 'num' }, hitData ? fmtPct(hitData.hit_rate) : 'n/a'),
el('td', { class: 'num' }, fmtPct(fallbackRate)),
));
}
table.appendChild(tbody);
target.appendChild(table);
}
function renderTrend(spendTrend30d) {
const target = document.getElementById('panel-trend');
target.innerHTML = '';
if (!Array.isArray(spendTrend30d) || spendTrend30d.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No trend data.'));
return;
}
const counts = spendTrend30d.map(d => d.request_count);
const maxCount = Math.max(1, ...counts);
const width = 800, height = 120, padding = { top: 8, right: 8, bottom: 20, left: 32 };
const innerW = width - padding.left - padding.right;
const innerH = height - padding.top - padding.bottom;
const barGap = 2;
const barW = (innerW - barGap * (spendTrend30d.length - 1)) / spendTrend30d.length;
const svg = svgEl('svg', { class: 'sparkline', viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: 'xMidYMid meet' });
for (let i = 0; i < spendTrend30d.length; i++) {
const d = spendTrend30d[i];
const h = (d.request_count / maxCount) * innerH;
const x = padding.left + i * (barW + barGap);
const y = padding.top + innerH - h;
const rect = svgEl('rect', { x, y, width: barW, height: Math.max(1, h) });
const providerBreakdown = Object.entries(d.by_provider || {}).map(([p, n]) => `${p}: ${n}`).join(', ');
const title = svgEl('title');
title.textContent = `${d.date}${fmtNum(d.request_count)} requests${providerBreakdown ? ' (' + providerBreakdown + ')' : ''}`;
rect.appendChild(title);
svg.appendChild(rect);
}
// Y-axis labels (max + min)
const maxLabel = svgEl('text', { x: 4, y: padding.top + 10, 'font-size': 10, fill: '#6b7280' });
maxLabel.textContent = fmtNum(maxCount);
svg.appendChild(maxLabel);
const minLabel = svgEl('text', { x: 4, y: height - 4, 'font-size': 10, fill: '#6b7280' });
minLabel.textContent = '0';
svg.appendChild(minLabel);
// Date labels (first + last only)
if (spendTrend30d.length > 0) {
const firstDate = svgEl('text', { x: padding.left, y: height - 4, 'font-size': 10, fill: '#6b7280' });
firstDate.textContent = spendTrend30d[0].date.slice(5);
svg.appendChild(firstDate);
const lastDate = svgEl('text', { x: width - padding.right - 28, y: height - 4, 'font-size': 10, fill: '#6b7280' });
lastDate.textContent = spendTrend30d[spendTrend30d.length - 1].date.slice(5);
svg.appendChild(lastDate);
}
target.appendChild(svg);
target.appendChild(el('div', { class: 'meta', style: 'margin-top: 0.5rem; font-size: 0.8rem;' },
'Hover bars for per-day provider breakdown · y-axis: requests per day (max ' + fmtNum(maxCount) + ')'));
}
function renderChains(chains) {
const target = document.getElementById('panel-chains');
target.innerHTML = '';
if (!Array.isArray(chains) || chains.length === 0) {
target.appendChild(el('div', { class: 'panel-loading' }, 'No fallback chains triggered in window.'));
return;
}
const table = el('table', null,
el('thead', null, el('tr', null,
el('th', null, '#'),
el('th', null, 'Chain'),
el('th', null, 'Count'),
el('th', null, 'First seen'),
el('th', null, 'Last seen'),
)),
);
const tbody = el('tbody');
chains.forEach((c, i) => {
tbody.appendChild(el('tr', null,
el('td', { class: 'num' }, String(i + 1)),
el('td', null, el('span', { class: 'chain' }, c.chain.join(' → '))),
el('td', { class: 'num' }, fmtNum(c.count)),
el('td', null, c.first_seen || ''),
el('td', null, c.last_seen || ''),
));
});
table.appendChild(tbody);
target.appendChild(table);
}
/* ─────────────── Error / clear banner ─────────────── */
function showError(message) {
const slot = document.getElementById('banner-slot');
slot.innerHTML = '';
slot.appendChild(el('div', { class: 'banner error' }, message));
}
function clearError() {
document.getElementById('banner-slot').innerHTML = '';
}
/* ─────────────── Fetch ─────────────── */
async function fetchDashboardData() {
const res = await fetch('/v0/management/dashboard-data', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (res.status === 401) {
showError('401 — owner-tier OLP key required. The dashboard is owner-only_block (ADR 0008 §8). Pass `Authorization: Bearer <owner-token>` via a proxy/extension; OLP itself doesn\'t accept browser cookies. Common path: SSH-tunnel + curl + tee the dashboard-data JSON, OR use a browser extension that adds the header.');
throw new Error('owner_required');
}
if (!res.ok) {
showError('Dashboard data fetch failed: HTTP ' + res.status);
throw new Error('http_' + res.status);
}
return await res.json();
}
/* ─────────────── Quota-only refresh (D82 § C — 60s timer) ─────────────── */
let _lastQuotaFetchedAt = null;
async function refreshQuotaV2() {
try {
const data = await fetchDashboardData();
clearError();
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
} catch (err) {
console.warn('OLP quota refresh failed:', err.message);
}
}
function updateQuotaLastRefreshLabel() {
const span = document.getElementById('quota-last-refresh');
if (!span) return;
if (_lastQuotaFetchedAt) {
span.textContent = 'Updated ' + new Date(_lastQuotaFetchedAt).toLocaleTimeString();
}
}
/* ─────────────── 60s quota timer with visibilityState guard ─────────────── */
function startQuotaRefresh() {
if (quotaRefreshTimer !== null) return;
quotaRefreshTimer = setInterval(refreshQuotaV2, QUOTA_POLL_INTERVAL_MS);
}
function stopQuotaRefresh() {
if (quotaRefreshTimer === null) return;
clearInterval(quotaRefreshTimer);
quotaRefreshTimer = null;
}
/* ─────────────── Manual refresh button (D82 § D) ─────────────── */
(function wireRefreshButton() {
const btn = document.getElementById('quota-refresh-btn');
const icon = document.getElementById('quota-refresh-icon');
if (!btn) return;
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
icon.textContent = '⟳';
icon.classList.add('spin');
try {
await refreshQuotaV2();
} finally {
icon.classList.remove('spin');
icon.textContent = '↻';
// Re-enable after 2s spam guard
setTimeout(() => { btn.disabled = false; }, 2000);
}
});
})();
/* ─────────────── Full 30s refresh (legacy panels + meta) ─────────────── */
async function refresh() {
try {
const data = await fetchDashboardData();
clearError();
const generated = data.generated_at ? new Date(data.generated_at) : new Date();
document.getElementById('meta').textContent =
'Last refresh: ' + generated.toLocaleString() + ' · quota every 60s · other panels every 30s';
// Quota section: also render on each full refresh to keep in sync
_lastQuotaFetchedAt = Date.now();
renderQuotaSection(data);
updateQuotaLastRefreshLabel();
render24h(data.window_24h, data.cache_hit_24h);
renderTrend(data.spend_trend_30d);
renderChains(data.top_fallback_chains_24h);
} catch (err) {
console.warn('OLP dashboard refresh failed:', err.message);
}
}
/* ─────────────── 30s poll (legacy panels) ─────────────── */
function startPolling() {
if (pollHandle !== null) return;
pollHandle = setInterval(refresh, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollHandle === null) return;
clearInterval(pollHandle);
pollHandle = null;
}
/* ─────────────── visibilitychange (both timers) ─────────────── */
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
stopPolling();
stopQuotaRefresh();
} else {
refresh();
startPolling();
refreshQuotaV2();
startQuotaRefresh();
}
});
/* ─────────────── Boot ─────────────── */
refresh().finally(() => {
startPolling();
if (document.visibilityState === 'visible') startQuotaRefresh();
});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -40,7 +40,7 @@ What OLP **does NOT inherit** from ADR 0005 (and where ADR 0005's reasoning does
- ADR 0005's separate-project recommendation came with two qualifiers that OLP rejects: "BYOK from day one" and "no `cli.js` spawn." Both qualifiers were appropriate for the *commercial* path ADR 0005 was contemplating. OLP is not commercial — it is personal- and family-scale, shares the maintainer's own subscription quota across family clients, and explicitly spawns provider CLIs (it is precisely the spawn-binary architecture that delivers the "subscription quota maximization" value proposition spec §1 names).
- OLP is therefore not the commercial pivot ADR 0005 endorsed. It is a personal-use re-architecture of the proxy-CLI pattern, which ADR 0005 did not contemplate. The supersession is honest about this gap.
OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).
OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. ~~The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).~~ **Amended at D60 (2026-05-26, ADR 0010 Phase 4 charter):** the port-conflict assumption is lifted. OLP's default port moved `3456 → 4567` at v0.4.0 so OCP (which stays on 3456) and OLP can co-host on the same machine during a transition window. Launchd label collision **will be** avoided via `dev.olp.proxy` (OLP plist generation lands at Phase 4 close per ADR 0010 D64D70; not on disk at D60) vs `dev.ocp.proxy` (OCP, already shipped). Credentials remain per-project (`~/.ocp/` vs `~/.olp/`). Co-host is explicit-opt-in, not the recommended steady state.
OCP ADR 0005 receives a header amendment on merge of this ADR: *"Superseded in part by OLP — see https://github.com/dtzp555-max/olp ADR 0001 for the narrow scope of the supersession (single-provider-sufficiency premise only; ADR 0005's commercial / BYOK / no-spawn recommendations are not adopted)."* The body of ADR 0005 is otherwise untouched. Future readers should see the original reasoning intact and the supersession marker scoped explicitly.
+82 -2
View File
@@ -7,6 +7,84 @@
## Amendments
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6, 7 — 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 8 — 2026-05-26: Permit `quotaStatus()` direct-API access (READ-ONLY exemption) for plan-usage probes (D79D80 — Phase 5)
- **Context:** ADR 0012 (Phase 5 charter) opens 2026-05-26 to port OCP's plan-usage probe (`ocp/server.mjs:842-1109`) into `lib/providers/anthropic.mjs:quotaStatus()`. The probe calls `POST https://api.anthropic.com/v1/messages` directly with an OAuth bearer and parses `anthropic-ratelimit-unified-*` response headers. This violates the plugin contract's implicit assumption that ALL provider interaction goes through `spawn` (the binary CLI). `ALIGNMENT.md` Rule 2 (provider-CLI-as-authority) further constrains plugins to operations the provider CLI itself performs. The OCP-derived plan-usage probe satisfies neither of these — it bypasses `claude -p` and hits the public API directly. **Without an explicit exemption Amendment, D80 is unalignable.**
- **Why the exemption is sound:** The probe is strictly **READ-ONLY** (one `POST /v1/messages` with `max_tokens: 1`; the response body is discarded; only response headers are parsed) AND **subscription-scope** (the OAuth bearer is the same one Claude Code uses for `claude -p`; no extra grant is requested) AND **idempotent** (probe failure returns `null`, never throws to a caller). The "what authority backs this?" answer is: Anthropic's CLI internally makes the same `/v1/messages` call (verified 2026-05-26 by `strings` on the compiled binary — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`); the probe is mirroring an established CLI behaviour rather than introducing a new wire format. Under `ALIGNMENT.md` Rule 2, mirroring observed CLI behaviour is permitted; the Rule's intent is "don't invent wire formats Anthropic's CLI does not perform", which the probe respects.
- **Change — extend the Provider contract description:**
- `quotaStatus(authContext): { quotaInfo }` is now permitted to call provider HTTP APIs directly, subject to **all three** constraints:
1. **READ-ONLY** — the API call must not mutate provider-side state. POST is acceptable when the response is what's needed (Anthropic returns ratelimit headers on `POST /v1/messages`); the request body MUST minimise side-effects (`max_tokens: 1`, dummy `messages`).
2. **Subscription-scope reuse** — the credentials used MUST be the same auth artifact the spawn path already reads via `readAuthArtifact()`. No new OAuth grant, no new API-key registration, no separate scopes.
3. **Idempotent failure** — if the probe fails for any reason (network error, 401, 429, schema parse failure), the function returns a structured shape (`{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }` since v0.5.1; see ADR 0013 Rule 6 + ADR 0008 Amendment 2) rather than throwing. The caller (server.mjs / dashboard / `olp usage` CLI) gracefully degrades. At v0.5.0 the failure shape was the literal value `null`; v0.5.1 refined this to a structured shape so operators can distinguish auth failures from rate-limit failures from network failures from in-backoff stale-cache. The substantive idempotent-failure constraint (no throw to caller) is unchanged.
- `healthCheck()` and other contract methods are NOT extended by this Amendment. Only `quotaStatus()` may make direct API calls. A plugin that wants live data for any other contract method must continue to use `spawn` or `readAuthArtifact`.
- The probe MUST cache its result. Recommended TTL: 5 minutes (mirrors OCP `USAGE_CACHE_TTL`). Tighter TTLs (e.g. dashboard's 1-minute refresh) are served from the cached value if fresh; cache miss triggers a real probe.
- The probe MUST implement exponential backoff on refresh failures: minimum 60s, maximum 3600s (mirrors OCP `OAUTH_REFRESH_MIN_BACKOFF` / `OAUTH_REFRESH_MAX_BACKOFF`). Tight loop on failure has historically burned through Anthropic's rate limit in seconds (OCP institutional lesson 2026-04).
- The probe MUST be opt-in via `~/.olp/config.json` (`providers.<name>.quota_probe_enabled: true`; default `false`). Reasoning: a fresh OLP install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes; the operator opts in once the credentials are configured.
- **What this Amendment does NOT permit:**
- Mutating API calls (e.g. POST/PATCH/DELETE that change provider-side state). Still forbidden.
- API calls for any contract method other than `quotaStatus()`. `spawn` / `healthCheck` / `doctorChecks` / `estimateCost` / `models` / `hints` / `name` / `displayName` / `auth` remain spawn-and-filesystem-only.
- Per-provider new auth grants. The probe uses the spawn path's existing credentials.
- Bypassing the alignment.yml blacklist. The hallucinated `/api/oauth/usage` token stays blacklisted; the probe uses `/v1/messages` (real endpoint).
- **API calls to endpoints not explicitly enumerated by the companion ADR 0013 § Rule 2.** Amendment 8 permits the *kind* of call (READ-ONLY direct API for quota probing); ADR 0013 Rule 2 enumerates *which specific endpoint* is permitted. A future reader of Amendment 8 alone should NOT infer that any READ-ONLY/idempotent endpoint is fair game — the per-endpoint containment is locked to ADR 0013. Re-opening per-endpoint scope requires an ADR 0013 amendment, not a new plugin-level interpretation of Amendment 8.
- **Backwards compatibility:** Plugins whose `quotaStatus()` still returns `null` (mistral at v0.5.0 pending D84 audit, codex permanently per Phase 5 charter) are NOT affected. No existing behaviour changes for them.
- **Authority cited at the implementation:** D80 commit cites this Amendment + `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` + `Claude Code v2.1.x § OAuth bearer + ratelimit-unified headers` + live-probe transcript from 2026-05-26 in the commit body. ALIGNMENT.md Rule 1 + Rule 5 (CI) both satisfied.
- **Tests:** Suite 38 (Phase 5 D83) covers the probe: mock HTTP server returning all 13 `anthropic-ratelimit-unified-*` headers; assert parse correctness for each; assert 5min cache; assert 60s-3600s exponential backoff on simulated 429; assert stale-cache-on-failure. At v0.5.0 stale-failure returned `{ stale: true, ... }` with `null` reserved for no-cache failures; v0.5.1 refined the return contract — `null` is now reserved STRICTLY for opt-in-off, and all failure modes (auth / rate-limit / schema-drift / network / no-creds) return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. See `test-features.mjs` Suite 38 (38u/38v/38w added for the v0.5.1 hotfix regression coverage of F1 / F2 / F3 per codex review).
- **Procedural mechanism:** Iron Rule 11 (IDR) — this Amendment, ADR 0012 (Phase 5 charter), and ADR 0013 (OAuth READ-ONLY consumption rules) land together at D79 as a single coupled commit. Reviewing them separately cannot verify consumer-producer alignment. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
### Amendment 7 — 2026-05-26: Add OPTIONAL `doctorChecks()` to the Provider contract (D67 — Phase 4 operator UX)
- **Context:** ADR 0010 § Phase 4 D64-D67 ships `bin/olp.mjs` operator CLI + `olp doctor` framework. `olp doctor` runs a set of `Check` objects (id / category / async `run()` returning `{ status, message, evidence? }`) and discriminates the next remediation step via a `kind` field (`noop` / `fix_server` / `fix_oauth` / `fix_provider` / `fresh_install`). The framework needs per-provider checks so a user with a broken `claude` install gets a different fix recipe than a user with a broken `vibe` install. Hardcoding the recipes in `bin/olp.mjs` would re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too.
- **Change — add to Provider contract:**
- Introduce **OPTIONAL** `doctorChecks()` returning `DoctorCheck[]` where each `DoctorCheck` has the shape:
- `id: string` — unique per check, conventionally `<provider>.<probe-name>` (e.g. `anthropic.cli_available`, `anthropic.oauth_token_present`).
- `category: 'provider'` — fixed for plugin-contributed checks. The framework reserves `'server'`, `'auth'`, `'config'`, `'system'` for built-in checks.
- `async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }` — runs the probe. `status: 'fail'` makes `olp doctor` exit non-zero and contributes to the `kind: fix_provider` discriminator; `evidence.fix_commands[]` is concatenated into `next_action.ai_executable[]` and `evidence.human_steps[]` into `next_action.human_required[]`.
- **Backwards compatibility:** Plugins that omit `doctorChecks()` contribute zero provider checks. Their healthCheck() return value continues to flow through `/health.providers.status.<name>` exactly as today. No existing plugin behaviour changes; no existing test breaks. `validateProvider` in `lib/providers/base.mjs` is updated to type-check `doctorChecks` only when present (must be a function); absence is allowed.
- **What `doctorChecks()` is for vs. what `healthCheck()` is for:**
- `healthCheck()` answers "is this provider currently usable?" — checked at the request-execution layer; output feeds `/health` and per-request retry decisions.
- `doctorChecks()` answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feeds `olp doctor` + the `next_action.ai_executable[]` repair templates that a downstream AI agent can paste-and-run.
- **Suggested probe set (per plugin):**
- `<provider>.cli_available` — spawn `<bin> --version` with short timeout (≤3s); fail → fix_commands include install instruction.
- `<provider>.<auth-artifact>_present` — check whether the auth file / env var the plugin's `readAuthArtifact()` reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be in `ai_executable[]`).
- **Authority:** ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed — `doctorChecks()` is an internal contract field. Implementation lands in D67 (this PR): `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, `lib/providers/mistral.mjs` each gain a `doctorChecks()` method covering `cli_available` + `<auth-artifact>_present`.
- **Tests:** Suite 32 (`bin/olp.mjs` CLI smoke) and Suite 33 (`olp doctor` framework) in `test-features.mjs` cover the contract amendment. Suite 33 specifically asserts: (a) a plugin without `doctorChecks()` contributes no provider checks (default behaviour), (b) a plugin with a failing `doctorChecks()` probe triggers `kind: fix_provider` and propagates its `evidence.fix_commands[]` into `next_action.ai_executable[]`, (c) all-passing checks yield `kind: noop`.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
### 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)
- **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 +138,9 @@ lib/providers/
index.mjs # static registry (enumeration of in-tree providers)
anthropic.mjs # spawn `claude -p` — port of OCP server.mjs spawn logic
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)
kimi.mjs # spawn `kimi -p --output-format stream-json` (optional)
minimax.mjs # tier-2 optional, default-disabled
@@ -83,7 +163,7 @@ Every provider plugin exports an object conforming to:
- `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).
- `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.
- `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)
@@ -7,6 +7,23 @@
## Amendments
### Amendment 3 — 2026-05-27: Accept OpenAI `role: "developer"` at entry surface, normalize to `system` in IR
- **Finding:** Hermes Agent v0.13/v0.14 (and likely Cline, Continue.dev, and other modern openai-completions clients) default to `role: "developer"` for what was historically the `system`-role slot when the model id matches OpenAI's o1/o3+ reasoning family. The `developer` role was introduced by OpenAI's Responses-API spec for reasoning models (high-priority developer-authored instructions; semantically a peer of `system`). OLP IR's role allow-list at v0.1 was the original four roles (`system|user|assistant|tool`); the IR validator rejected `developer` with `400 IR validation failed: role must be one of system|user|assistant|tool, got "developer"`. Reproduced 2026-05-27 on PI230 Hermes v0.14.0 → OLP v0.5.1 routing path.
- **Decision:** Extend `openai-to-ir.mjs:normalizeRole()` to map `developer``system` at the entry boundary. The IR's canonical-four-roles invariant is preserved; every provider plugin's role-handling stays unchanged. The normalize-at-entry pattern matches the existing `function``tool` normalization that already lives in the same function (function-role-deprecation was the precedent for entry-boundary normalization vs. IR schema bloat).
- **Why not "add `developer` to VALID_ROLES + handle in every provider":** That alternative would require:
- Expanding `VALID_ROLES` in `lib/ir/types.mjs`.
- Adding `developer` branch in `anthropic.mjs:irToAnthropic` (which would map to `[System]` annotation anyway).
- Adding `developer` branch in `codex.mjs:irToCodex` (would map to `[System]` annotation anyway).
- Adding `developer` branch in `mistral.mjs:irToMistral` (same).
- Coordinating every future role addition (e.g., if OpenAI adds another role tomorrow) across N provider plugins.
- Wider IR surface area = more drift-prone over time.
Normalize-at-entry centralizes role-spec-evolution handling in one file. ADR 0003's IR-design principle ("encode the common subset every provider plugin can consume") supports keeping the IR minimal.
- **Forward note:** Future OpenAI role additions follow the same pattern: extend `normalizeRole()`. If a role genuinely conveys provider-distinguishable semantics (e.g., a hypothetical role that meaningfully changes anthropic vs codex behavior), the calculus flips and a IR-level addition would be justified. That decision goes through a new ADR 0003 amendment.
- **Cache-key impact:** After this amendment, a request whose first message uses `role: "developer"` and one using `role: "system"` with otherwise-identical content produce the **same** IR (because normalization happens before IR construction) → the **same** cache key (per ADR 0005 cache key composition). This is intentional and matches OpenAI's own backward-compat behavior ("system message with reasoning models is treated as developer"). If a future debug session is investigating "why does my new `developer` request hit a cache entry from an old `system` request" — this is by design.
- **Tests:** Suite IR translation in `test-features.mjs` gains three pin tests: (a) `role: "developer"``role: "system"` translation, (b) mixed-role array including developer validates cleanly through to IR, (c) negative control — an unknown role (e.g. `"admin"`) still raises `BadRequestError`, confirming the normalize-at-entry mapping did not accidentally widen the role allow-list.
- **Authority:** OpenAI Responses API spec — developer role documented as high-priority developer-authored instructions for o1/o3+ reasoning models (https://platform.openai.com/docs/api-reference/responses). Hermes Agent / Cline / Continue.dev tracking the same convention. Reproduced live on PI230 → PI231 OLP 2026-05-27.
### Amendment 2 — 2026-05-24: Correct model-mapping example; document verbatim-pass-through design (D32 F2)
- **Finding:** Round-4 cold-audit F2 (P3 ADR example vs implementation drift) — § Decision "Required fields" item `model` reads: "The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6``claude-sonnet-4-6-20260301` for Anthropic)." This is WRONG per the D17 SPOT decision (commit `cb86807`): OLP does NOT perform a model-alias mapping inside the provider plugin. `irRequest.model` is passed verbatim to the provider CLI (`claude -p --model <model>`, `codex exec --model <model>`, etc.); each provider's CLI resolves its own aliases natively per its documented behaviour.
+77 -2
View File
@@ -7,6 +7,55 @@
## 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)
- **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.
- 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`.
- **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.
- **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.
#### 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.
### 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`.
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.
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).**
- `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-Cache: hit | miss | bypass`
- `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.
+133
View File
@@ -7,6 +7,139 @@
## 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)
- **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.
+422
View File
@@ -0,0 +1,422 @@
# 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.
**`tried_providers` semantics (clarification, D53 / 2026-05-25).** The field captures the list of providers the server **actually dispatched a spawn against** for this request. A provider that was configured in the chain but filtered out by `providers_enabled` gating (resulting in 403 `key_no_provider_access`) is NOT included — the key didn't try the provider, the gate did. On the 403 path `tried_providers` is the empty array. The configured-but-blocked chain providers appear in the human-readable error message returned to the client but are intentionally NOT surfaced in the audit event, so downstream queries like "which providers did key X actually call" stay accurate. This semantic was implicit in the D45 implementation (where the field was set to the original chain on 403, misrepresenting "tried"); D53 corrects the implementation + amends this section to spell out the intent.
**Rotation.** Phase 2 does NOT rotate `audit.ndjson`. Rotation policy ships in Phase 3 — daily rotation via `lib/audit.mjs` `_maybeRotateAudit` synchronous trigger on first append after UTC date change + optional `bin/olp-audit-rotate.mjs` external cron. See ADR 0008 § 5.
---
## 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.
+520
View File
@@ -0,0 +1,520 @@
# ADR 0008 — Dashboard + Audit Query Layer (Phase 3)
- **Date:** 2026-05-25
- **Status:** Accepted (D48, design-only — implementation D-days D49D54 follow; Phase 3 close = v0.3.0)
## Amendments
### Amendment 2 — 2026-05-27: v0.5.1 quota_v2 richer failure-mode shape (codex finding F3)
**Scope:** v0.5.1 hotfix extends `ProviderQuotaEntry` and `aggregateProviderQuota()` to surface richer failure-mode detail, addressing codex review finding F3 (operator cannot distinguish failure modes from the `unavailable` catch-all). Authority: ADR 0013 Rule 6 + codex review findings F1F3.
#### 1. Extended `ProviderQuotaEntry` shape
```js
{
provider: string,
// v0.5.1: 'unreachable' added (probe enabled, creds present, but no cache + probe failed)
status: 'live' | 'stale' | 'unreachable' | 'unavailable',
reason?: string, // only when status === 'unavailable' (no API or disabled)
schema_version: string|null,
last_fresh_at: number|null,
utilization: { '5h': number|null, '7d': number|null } | null,
reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
representative_claim: string|null,
fallback_percentage: number|null,
overage: { status: string|null, disabled_reason: string|null } | null,
raw_available: boolean,
// v0.5.1 (F3 — ADR 0013 Rule 6):
failure: { kind, message, backoff_until? } | null,
failure_kind: 'no_credentials'|'auth_failed'|'rate_limited'|'schema_drift'|'network'|'other' | null,
// Note: 'opt_in_off' is NOT in this enum — when probe is opted out, the row's status
// is 'unavailable' (not 'unreachable'); failure_kind stays null. Distinguishing
// "user opted out" from "provider has no API" requires reading config separately.
backoff_until: number | null, // epoch-ms when next probe attempt is allowed
}
```
Status semantics:
- `'unavailable'` — probe disabled (`quota_probe_enabled: false`) OR provider has no public quota API (codex, mistral). `failure`, `failure_kind`, `backoff_until` are null.
- `'live'` — probe succeeded within TTL. `failure` is null.
- `'stale'` — probe failed but stale cache exists. `failure.kind` describes why the last probe failed. `last_fresh_at` is the epoch of the last successful probe. `backoff_until` tells when the next attempt is scheduled.
- `'unreachable'` (new) — probe enabled + creds present (or missing!) but no cache available + probe failed. `failure.kind` distinguishes: `no_credentials`, `auth_failed`, `rate_limited`, `schema_drift`, `network`, `other`. `utilization` and `reset` are null (no data).
#### 2. `quotaStatus()` v0.5.1 return contract
`null` is now RESERVED for `quota_probe_enabled: false` only. All other failure paths return a structured shape:
```js
null // ONLY: opt-in off
{ probe_status: 'live', ... } // cache fresh
{ probe_status: 'stale', ..., failure: { kind, message, backoff_until } } // cache stale + backoff
{ probe_status: 'unreachable', source, schemaVersion, failure: { ... } } // no cache + failed
```
The `stale: boolean` field is retained for backwards-compat (`stale: false` on live, `stale: true` on stale). New code should use `probe_status`.
#### 3. `dashboard.html` unreachable rendering
A new CSS class `.provider-row.unreachable` (red border + light red background) and `.unreachable-reason` text style handle the new status. `failure.message` and `failure_kind` are surfaced as a short text line under the provider badge. `failure.backoff_until` renders a "backoff active: Xs remaining" note if within window.
#### 4. Authority
- ADR 0013 Rule 6 (failure transparency mandate)
- Codex review findings F1 (doctor bypass), F2 (200+empty-headers → schema_drift), F3 (failure-mode collapse)
- v0.5.1 hotfix PR
---
### Amendment 1 — 2026-05-26: D81 Phase 5 quota_v2 shape + aggregateProviderQuota()
**Scope:** D81 (Phase 5 / ADR 0012 D81) extends the audit-query layer and dashboard-data endpoint to surface the new per-provider quota shape introduced by D80 (`lib/providers/anthropic.mjs:quotaStatus()`). This amendment documents the three new interfaces.
#### 1. `models-registry.json` — new `quota_probe` top-level key
D81 adds a `quota_probe` key at the root of `models-registry.json` per ADR 0013 Rule 5 (schema_version in registry so downstream consumers can detect schema drift):
```json
{
"quota_probe": {
"schema_version": "2026-05-26",
"anthropic": {
"source": "anthropic-ratelimit-unified-headers",
"endpoint": "https://api.anthropic.com/v1/messages",
"fields_pinned": [ ...13 field names... ]
}
}
}
```
`fields_pinned` is load-bearing: if Anthropic adds/renames a header in a future CLI version, dashboard consumers comparing field-presence against this list can flag "schema drift detected" per the ADR 0013 Rule 5 drift-detection runbook. This field must be updated alongside the parser whenever a drift event occurs.
`lib/providers/anthropic.mjs` reads `quota_probe.schema_version` from the registry at call time (via `_resolveSchemaVersion()`) with the module-level `QUOTA_SCHEMA_VERSION` constant as fallback. No hard dependency on the registry — the constant is the safety net.
#### 2. `lib/audit-query.mjs` — new `aggregateProviderQuota()` export
```js
export async function aggregateProviderQuota({
providers, // Map<name, plugin> or plain object
getQuotaStatus, // optional injectable getter (name) => Promise<shape|null>
}): Promise<Array<ProviderQuotaEntry>>
```
For each provider, calls `quotaStatus()` (already cached at the plugin layer per ADR 0013 Rule 3) and normalizes to the `ProviderQuotaEntry` shape:
```js
{
provider: string,
status: 'live' | 'stale' | 'unavailable',
reason?: string, // only when status === 'unavailable'
schema_version: string|null,
last_fresh_at: number|null, // epoch-ms of last successful probe
utilization: { '5h': number|null, '7d': number|null } | null,
reset: {
'5h': number|null, '7d': number|null,
overall: number|null, overage: number|null,
} | null,
representative_claim: string|null,
fallback_percentage: number|null,
overage: { status: string|null, disabled_reason: string|null } | null,
raw_available: boolean,
}
```
Providers returning `null` from `quotaStatus()` (codex, mistral — no public quota API; or probe disabled) produce `{ status: 'unavailable', reason: 'no public quota api or probe disabled', ...null fields }`.
Providers whose `quotaStatus()` throws produce `{ status: 'unavailable', reason: <error.message>, ...null fields }`.
This function does NOT scan ndjson files; it calls live provider plugins. It is audit-query-adjacent (normalized query shape for the dashboard layer) but not audit-derived. Query model remains Lane 2 = A (in-memory, no SQLite).
#### 3. `/v0/management/dashboard-data` and `/v0/management/quota` — new `quota_v2` field
Both endpoints now return TWO quota keys:
- **`quota`** (legacy, unchanged): `Array<{ provider, ...rawQuotaStatus, available }>`. Kept for backwards compatibility with the existing `dashboard.html` (D82 will switch consumers to `quota_v2`).
- **`quota_v2`** (D81 new): `Array<ProviderQuotaEntry>` — the normalized shape from `aggregateProviderQuota()` above. This is what D82's enriched dashboard UI will consume.
Both fields are computed from the same underlying `quotaStatus()` call. The legacy `quota` key calls `quotaStatus()` independently from `quota_v2`; since the probe is cached at the plugin layer (ADR 0013 Rule 3), the double call incurs no extra API requests.
**Deprecation timeline:** the legacy `quota` key is deprecated as of D81. Target removal: v1.0.0 or when D82 completes the dashboard migration (whichever comes first). Removal requires a separate PR with a CHANGELOG entry.
#### 4. Failure handling
`aggregateProviderQuota()` never throws to the dashboard endpoint. Per-provider failures are absorbed as `{ status: 'unavailable', reason: <error> }` entries. If `aggregateProviderQuota()` itself throws (implementation bug), `handleManagementDashboardData` and `handleManagementQuota` catch the error, log `dashboard_data_quota_v2_failed` / `management_quota_v2_failed`, and return `quota_v2: []` so the rest of the payload is unaffected.
#### 5. Authority citations for this amendment
- **ADR 0012 D81** — the D-day this amendment documents.
- **ADR 0013 Rule 5** — mandate for `quota_probe.schema_version` in `models-registry.json`.
- **D80 PR #52 commit 82d2e1c** — the producer of the `quotaStatus()` shape this amendment normalizes.
- **ADR 0008 Lane 2 = A** — query model unchanged; `aggregateProviderQuota()` does not scan ndjson.
---
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:**
- OLP v0.1 spec § 4.6 (Dashboard requirements — port from OCP with multi-provider support) and § 4.7 (observability endpoints)
- ADR 0007 § 12 (Phase 3+ out-of-scope: Dashboard, audit query layer, rotation) — this ADR opens those deferrals
- ADR 0007 § 13 (Option 3 hybrid migration to SQLite) — explicitly **NOT** triggered by Phase 3; in-memory ndjson scan is the v0.3.0 query model
- ADR 0007 § 7 (Identity classes) — Dashboard auth gating reuses owner-vs-non-owner pattern (Dashboard is owner-only)
- ADR 0007 § 8 (Audit ndjson schema) — the data source the query layer reads
- ADR 0004 Amendment 2 (soft triggers deferred to v1.x) — quota panel sources from `provider.quotaStatus()` which is a contract method; per-provider returns what it can or `null`
- D45 reviewer P2 deferral on `tried_providers` semantics — addressed in this Phase as D53 (separate D-day; not in ADR 0008 scope)
- **Phase 3 kickoff authority:** maintainer "go" + standing-autopilot grant (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a` — the grant explicitly excludes Phase 3+ as needing new authorization; the "go" supplied that)
- **Lanes pinned by maintainer 2026-05-25 (Phase 3 kickoff brief):**
- Lane 1 Dashboard tech stack: **A** — static HTML + vanilla JS + fetch (no build step; matches OLP "no bundler" ethos)
- Lane 2 Audit query model: **A** — in-memory scan of audit ndjson per request (O(N) per query; family-scale acceptable; SQLite deferred to Option 3 trigger per ADR 0007 § 13)
- Lane 3 Audit rotation: **B** — daily rotation, files named `audit-YYYY-MM-DD.ndjson` (UTC date)
- Lane 4 Refresh: **A** — page poll every 30s (no SSE infra introduction)
- Lane 5 Dashboard scope: **B** — full per spec § 4.6 (quota + per-provider counts/cache/fallback last 24h + multi-provider spend trend last 30d + top fallback chains by trigger count)
---
## 1. Context
OLP v0.2.0 ships per-key audit ndjson at `~/.olp/logs/audit.ndjson` (ADR 0007 § 8) but provides no aggregate query surface or visualization. The audit file grows unbounded, and operators must `tail`/`grep` to observe basic facts ("which provider served the most requests today", "what's my cache hit rate", "how often did the chain fall back to OpenAI"). Phase 3 closes this gap with:
1. **`lib/audit-query.mjs`** — an in-memory aggregator that scans `~/.olp/logs/audit-*.ndjson` files in a configurable time window and returns shaped summaries.
2. **`server.mjs` `/v0/management/*` endpoints** — three owner-only JSON endpoints exposing the aggregate data: `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`.
3. **`dashboard.html`** — a single static HTML file served from `/dashboard` (owner-only) that fetches the JSON endpoints, renders 4 panels, and polls every 30s.
4. **Daily audit rotation** — at UTC midnight (or on first append after a UTC-date change), the live `audit.ndjson` is renamed to `audit-YYYY-MM-DD.ndjson` and a fresh `audit.ndjson` opens. Cross-file queries handle the rolling 30-day window.
Phase 3 deliberately **does not** add a build step, a database, or per-key UI write surface. The first three are out of scope (Option 3 hybrid is § 13's forward path; SQLite has a Node-baseline blocker per § 11). The last is a security surface that warrants a separate review pass (Phase 4+).
Phase 3 is the natural home for OCP's `dashboard.html` port (v0.1 spec § 4.6 — "Port OCP's `dashboard.html` with multi-provider support"). OCP's dashboard was single-provider; OLP's is multi-provider, which is the substantive change. The HTML structure is otherwise lifted.
---
## 2. Decision
The five lanes above are normative. The deviation lattice they sit in:
| Lane | Pinned | Rejected (why) |
|---|---|---|
| **1. Tech stack** | Static HTML + vanilla JS + fetch | SSR (Node template literals) — adds server-side render path; CSP harder. SPA — adds build step, violates ethos. |
| **2. Query model** | In-memory scan of `audit-*.ndjson` per request | SQLite indexed mirror — touches ADR 0007 § 13 migration → engines bump prerequisite. In-memory rolling aggregate — complex (per-write incremental + window expiry), correctness risk. |
| **3. Rotation** | Daily UTC rotation (`audit-YYYY-MM-DD.ndjson`) | No rotation — single file grows unbounded at family scale ~10100 lines/day but a year is ~1050k lines, still ok but no natural query unit. Size-based (100MB / keep 5) — equivalent complexity, query range less intuitive. |
| **4. Refresh** | Page poll every 30s | SSE push — adds server-side subscriber state; not justified at family scale. Static-at-load — UX too poor. |
| **5. Dashboard scope** | Full per spec § 4.6 | Minimal (quota + cache + fallback only) — spec is already drafted; full is ~1 D-day more for trend + top-chains panels. Plus key-mgmt UI — security surface delta, Phase 4+. |
**Phase 3 does not** introduce: a database, a build step, an SPA framework, SSE for dashboard, or web-side key management. Each is a future-phase concern (Option 3 hybrid; SSE for dashboard if poll latency becomes pain; key-mgmt UI after a security review pass).
---
## 3. Storage layout (`~/.olp/logs/`)
```
~/.olp/logs/
audit.ndjson — live append target (Phase 2 D45)
audit-2026-05-24.ndjson — yesterday (after rotation)
audit-2026-05-23.ndjson
audit-2026-05-22.ndjson
...
```
Files are append-only (no in-place edits). Rotation atomically renames the live file and opens a fresh one. All files have mode 0600; the `logs/` directory is 0700.
**Retention policy at v0.3.0:** unbounded by default (operator manages disk). A Phase 3+ amendment may add automatic retention (`olp_audit_max_days` config) once an observed operational need exists.
---
## 4. Audit query layer (`lib/audit-query.mjs`)
A new module that reads the rotated + live audit files in a date range and returns aggregate summaries. Per-call O(N) where N = total lines in the date range (family scale: thousands per day = trivial).
### 4.1 Public API
```js
// Read all audit lines in [startMs, endMs); returns iterator of parsed events.
// Skips malformed lines (logs warn) so a corrupted day doesn't kill the query.
export function* readAuditWindow({ startMs, endMs, olpHome }): Iterator<AuditEvent>;
// Aggregate request shape over a window. Returns:
// {
// window: { startMs, endMs },
// request_count, status_2xx, status_4xx, status_5xx,
// by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, fallback_count } },
// by_owner_tier: { owner: N, guest: N, anonymous: N },
// by_path: { '/v1/chat/completions': N, '/v1/models': N },
// median_latency_ms, p95_latency_ms,
// }
export function aggregateRequests({ windowMs, olpHome }): RequestAggregate;
// Top-N fallback chains by trigger count in window. Returns sorted array:
// [{ chain: ['anthropic', 'openai'], count: 42, first_seen, last_seen }, ...]
export function topFallbackChains({ windowMs, limit, olpHome }): FallbackChainSummary[];
// Daily series of request_count + latency_median over N days. Returns sorted array:
// [{ date: '2026-05-22', request_count, median_latency_ms, by_provider }, ...]
export function spendTrendDaily({ days, olpHome }): DailySpendEntry[];
// Cache hit rate snapshot (in-memory cacheStore stats + audit-derived numerator).
// Differs from /cache/stats: that returns the live in-memory CacheStore stats;
// this is the audit-side derived rate over the window.
export function cacheHitRateWindow({ windowMs, olpHome }): CacheHitRateSummary;
```
### 4.2 Window semantics
`windowMs` is a duration ending at "now"; `[now - windowMs, now)`. The implementation walks files whose date overlaps that range: today's `audit.ndjson` always; `audit-YYYY-MM-DD.ndjson` for each prior date in range. A line is included only if its `ts` (ISO-8601) falls in the window.
For the 30-day spend trend, `windowMs = 30 * 86400 * 1000`. The implementation buckets per UTC day and returns one entry per day, including days with zero requests (sparse-fill).
### 4.3 PII discipline
Per ADR 0007 § 8, audit events contain no message content, no response content, no raw tokens. The query layer relays only the schema fields. It MUST NOT introduce derived fields that reveal content (e.g., "first 50 chars of prompt").
### 4.4 Error handling
- Missing file → empty iteration (not an error).
- Malformed JSON line → log warn `audit_query_skip_malformed` + skip; continue.
- File-read error (EACCES, etc.) → throw to caller; the dashboard endpoint surfaces 500 with diagnostic message.
---
## 5. Audit rotation
### 5.1 Trigger
Rotation fires on the **first append after a UTC date change**. Implementation lives in `lib/audit.mjs` (extended at D49). On each `appendAuditEvent` call:
1. Compute `today = new Date().toISOString().slice(0, 10)` (e.g., `'2026-05-25'`).
2. Read a module-scoped `_currentDate` cached at startup.
3. If `today !== _currentDate` AND `audit.ndjson` exists AND it is non-empty:
- Rename `audit.ndjson``audit-${_currentDate}.ndjson` (the previous day's date).
- Set `_currentDate = today`.
- Continue with the append (new `audit.ndjson` opens via append-create).
The check is per-call (microsecond cost). The rename is the only filesystem heavy op and fires once per UTC day.
### 5.2 External-cron alternative (`bin/olp-audit-rotate.mjs`)
An auxiliary script is shipped at D52 for operators who prefer cron-driven rotation (e.g., to rotate exactly at 00:00:00 UTC rather than "first request after midnight"). The script does the same rename + state-bump logic but can be invoked from a host cron / launchd job. The in-server check remains as a safety net; both can coexist.
### 5.3 Concurrent-rotation safety
In-process: the rotation logic is wrapped in a per-process lock (`Map<key='audit-rotate', Promise>`) so two concurrent `appendAuditEvent` calls don't both attempt the rename. External cron + in-server check: the in-server check sees the rename has already happened (file with today's date already exists if cron beat it); the no-op fallback is "if `audit.ndjson` exists, append; else create + append" — POSIX semantics.
### 5.4 Renamed-file query path
The query layer (§ 4) walks `audit-${date}.ndjson` files for any date in the window. Today's file is always `audit.ndjson` (not renamed yet); yesterday + prior are date-suffixed.
---
## 6. Dashboard panels (per spec § 4.6, Lane 5 = B full)
`dashboard.html` is a single static file served from `/dashboard`. Renders 4 panels in a 2×2 grid:
### 6.1 Panel 1 — Per-provider quota / credit pool
For each loaded provider, calls `provider.quotaStatus()` (ADR 0002 Provider contract). Returns whatever the provider can report (e.g., Anthropic Plan limits remaining, Codex credit pool balance) OR `null` (provider opts out — Phase 2 mistral has no quota API).
Each row shows:
- Provider key + display name
- Quota remaining / quota total (or "n/a" if null)
- Last poll timestamp
### 6.2 Panel 2 — Per-provider request count + cache hit rate + fallback rate (last 24h)
Uses `aggregateRequests({ windowMs: 86400 * 1000 })`. One row per provider showing:
- Request count (total served by that provider, regardless of chain position)
- Cache hit rate (% of requests where `cache_status === 'hit'`)
- Fallback rate (% of requests where `fallback_hops > 0`)
- 5xx error rate (% of requests where `status_code >= 500`)
### 6.3 Panel 3 — Multi-provider unified spend trend (last 30 days)
Uses `spendTrendDaily({ days: 30 })`. Shows a sparkline-style chart (vanilla SVG, no library) with:
- X axis: 30 daily buckets
- Y axis: request count per day (stacked by provider color)
- Hover tooltip: per-day breakdown by provider
Note: "spend trend" is the spec's term — at v0.3.0 we don't have provider-side cost integration (Anthropic Plan is flat-rate per Anthropic 2026-06-15 split per the learning memory). So "spend" is proxied by request count. A future ADR may add cost weights per provider when commercial cost-tracking lands.
### 6.4 Panel 4 — Top fallback chains by trigger count
Uses `topFallbackChains({ windowMs: 86400 * 1000, limit: 10 })`. Lists top 10 chains:
- Chain shape (e.g., `anthropic → openai`)
- Trigger count
- First / last seen timestamps
### 6.5 Refresh model (Lane 4 = A)
The dashboard sets a 30s `setInterval` that calls `fetch('/v0/management/dashboard-data')` + updates DOM in place (no full reload). Initial fetch on page load. The interval pauses when the page is hidden (via `document.visibilityState` listener) to avoid useless background polls.
### 6.6 Localhost-bound by default
The dashboard is served from the existing OLP HTTP port (default 4567 since v0.4.0 / D60; 3456 pre-v0.4.0) which is already bound to `127.0.0.1` per `server.mjs` startup (`server.listen(PORT, '127.0.0.1', ...)`). No additional binding logic. Remote operators access via SSH tunnel; ADR 0007 § 7 owner-only auth provides the per-request gate.
---
## 7. Server endpoints (D50)
All endpoints are owner-only per ADR 0007 § 7 (owner-tier validation through `authenticate`). Non-owner identities get 401 / 403 per existing patterns; anonymous (when `allow_anonymous: true`) gets 401 (these are management endpoints, not user-facing).
### 7.1 `GET /dashboard`
Serves `dashboard.html`. Owner-only gated. Content-Type `text/html; charset=utf-8`. Static file read once at server startup + cached in memory (small, no need to re-read per request).
### 7.2 `GET /v0/management/dashboard-data`
Returns the JSON payload the dashboard's 30s poll consumes. Shape:
```json
{
"generated_at": "<ISO-8601>",
"window_24h": <RequestAggregate from §4.1>,
"quota": [
{ "provider": "anthropic", "quota_remaining": 1234, "quota_total": 5000, "polled_at": "<ISO-8601>" },
{ "provider": "openai", "quota_remaining": null }
],
"spend_trend_30d": <DailySpendEntry[] from §4.1>,
"top_fallback_chains_24h": <FallbackChainSummary[] from §4.1>,
"cache_stats": <stats from server.mjs cacheStore.stats() — global aggregate>
}
```
### 7.3 `GET /v0/management/quota`
Returns just the quota array (subset of dashboard-data; useful for scripted monitoring).
### 7.4 `GET /cache/stats`
Returns the live in-memory `cacheStore.stats()` shape. Planning authority is **OLP v0.1 spec § 4.6** (which names `/cache/stats` explicitly); ADR 0005's `Consequences/Mitigations` paragraph (~ line 279) references it as the monitoring surface for per-`(provider, model)` cache hit-rate breakdown.
**Shape gap to resolve at D50.** The current `cacheStore.stats()` in `lib/cache/store.mjs:320-350` returns `{ hits, misses, size, inflightCount }` — global aggregate only, no per-`(provider, model)` breakdown. If D50 reveals the shape is insufficient for Panel 2 (per-provider 24h cache hit rate, currently sourced from `aggregateRequests` audit-side rather than `cacheStore.stats`), the dashboard endpoint is satisfied. If a future panel needs the per-`(provider, model)` breakdown that spec § 4.6 implies, D50 amends the store shape + an ADR 0005 amendment fires at that time. Phase 3 acceptance criteria do not require the breakdown.
### 7.5 Audit on management endpoints
All four endpoints append an audit row via the existing `appendAuditEvent` pattern. Path values are `/dashboard` / `/v0/management/dashboard-data` / `/v0/management/quota` / `/cache/stats`. The 30s poll generates 2880 dashboard rows per day per owner — manageable at family scale, but noted as a knob (a future amendment may suppress audit for these paths if they become noise-dominant).
---
## 8. Auth gating
Reuses ADR 0007 § 7 owner-vs-non-owner model + introduces a second gating mode.
**Two gating modes (this ADR formalizes the distinction):**
- **`owner_only_trim`** (Phase 2 / D46 model) — non-owner identities receive a 200 response with a trimmed payload (e.g., `/health` returns `{ ok, version }` only). Used when the endpoint has a baseline payload that is safe to share with all identities and an enriched payload only for owners.
- **`owner_only_block`** (Phase 3 / D48 new) — non-owner identities receive `401 invalid_or_revoked_key` (or `401 auth_required` if no token). Used when the entire payload is sensitive and there is no safe baseline to share (Dashboard quota stats, fallback chains by trigger, etc. all reveal operational behaviour that should not leak to non-owner identities).
The four new endpoints (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`) are `owner_only_block`. `/health` remains `owner_only_trim`.
The owner_only_endpoints config gains four entries; the gating-mode distinction is implementation-side (the handler decides whether to trim or block based on the endpoint). Server startup defaults `owner_only_endpoints` to include `/health` + the four new ones (Phase 3 default; operator can opt-out per-endpoint via config). Pre-Phase-3 deployments with `owner_only_endpoints: ['/health']` continue to work — the new endpoints will 401 for non-owner under that legacy config because the handler is `owner_only_block`-mode regardless of the config list (the config controls /health's trim/full toggle only; the management endpoints are not opt-out-able to a non-401 response).
`401` shapes match Phase 2 / D45 pattern: JSON `{ error: { message, type } }` with `type: 'auth_required'` or `'invalid_or_revoked_key'`.
---
## 9. Failure modes + graceful degradation
| Failure | Behavior |
|---|---|
| `audit.ndjson` absent (fresh install) | Empty arrays in all aggregates; dashboard shows "No requests in window" panels |
| `audit-YYYY-MM-DD.ndjson` corrupted (one bad line) | Skip line + log warn; continue; dashboard rendering unaffected |
| Provider `quotaStatus()` throws | Panel shows that row as `quota: "error"`; other providers' rows render normally |
| Dashboard `/v0/management/dashboard-data` query >5s | Dashboard JS shows "Loading…" with a timeout; second poll attempts after 30s |
| `audit.ndjson` rotation fails (rename EACCES) | `appendAuditEvent` warn `audit_rotate_failed` + continues appending to the un-rotated file; next call retries the rotation |
| File handle limit hit during 30-day query (many files open) | Query reads one file at a time (no parallel reads); never opens >2 simultaneously |
The dashboard degrades visibly (per-panel error states) rather than failing whole-page.
---
## 10. Acceptance criteria
Implementation D-days (D49+) MUST land tests covering:
1. **`readAuditWindow`** correctly iterates events from today's `audit.ndjson` + N prior rotated files within the window.
2. **`readAuditWindow`** skips malformed lines without throwing; logs warn for each.
3. **`aggregateRequests`** correctly counts by provider + cache_status + owner_tier + path; correctly computes median + p95 latency.
4. **`topFallbackChains`** returns sorted by count descending; ties broken by first-seen timestamp ascending.
5. **`spendTrendDaily`** sparse-fills zero-request days; window respects UTC day boundaries.
6. **Daily rotation** — writing past UTC midnight renames `audit.ndjson``audit-<yesterday>.ndjson` and continues appending to a fresh `audit.ndjson`.
7. **Cross-file query** — a 30-day window with mixed rotated files returns correctly merged results.
8. **Concurrent rotation safety** — N concurrent `appendAuditEvent` calls during a UTC date change result in exactly one rename + all lines append to the correct file.
9. **`GET /dashboard`** returns 200 HTML to owner; 401 to non-owner. This includes the case where `allow_anonymous: true` AND no Authorization header is presented: the authenticate middleware produces an anonymous identity, the `owner_only_block` mode then rejects with 401 (per § 8 — anonymous is non-owner; management endpoints block, do not trim). When `allow_anonymous: false` + no header, 401 fires earlier at the authenticate middleware itself. Test must cover both cases.
10. **`GET /v0/management/dashboard-data`** returns 200 JSON to owner with all required fields populated.
11. **`GET /cache/stats`** returns 200 JSON to owner with the live in-memory cache stats shape.
12. **Dashboard HTML smoke** — fetched via test http client + parsed → has the 4 panel containers + 30s poll script; no JS console errors when loaded in a real browser (manual or playwright; manual is acceptable at Phase 3).
13. **Audit on management endpoints** — calling `/v0/management/dashboard-data` appends an audit row with `path: '/v0/management/dashboard-data'` and `status_code: 200`.
14. **Graceful degradation** — when a provider's `quotaStatus()` throws, the dashboard endpoint still returns 200 with that provider's quota row showing `"quota_remaining": null` and an error indicator.
15. **PII guard** — every aggregate query function asserts at the test level that returned data does NOT include any message content; the `prompt`/`messages`/`response`/`content` fields MUST NEVER appear in any output shape.
---
## 11. Forward path (Phase 4+)
Items deliberately deferred:
- **SQLite migration (Option 3 hybrid)** — trigger: query latency >2s on a typical owner session, OR Dashboard usage scales beyond family (>5 owners polling). Preconditions per ADR 0007 § 13: engines bump + CI matrix change as a separate prior PR.
- **SSE push for dashboard live updates** — trigger: 30s poll feels stale, OR operator wants real-time view of streaming requests. Reuses existing streaming infra from ADR 0005 Amendment 8 (v1.x streaming SF when it ships).
- **Key-mgmt UI from dashboard** — owner can create/revoke/edit keys from the web UI rather than CLI. Out of Phase 3 because (a) it adds a write surface to the dashboard requiring careful CSRF handling, (b) security review of the auth flow is non-trivial, (c) the CLI surface from D47 covers the same use cases.
- **Cost weights per provider** — once provider-side cost tracking is feasible, "spend trend" can show actual dollars. At v0.3.0 it's a request-count proxy.
- **Audit retention / max-days policy** — currently unbounded; operator manages disk. A Phase 3+ amendment adds `audit_max_days` config when an operational need emerges.
- **Per-key dashboard views** — owner sees aggregate; per-key drill-down is a future amendment.
---
## 12. Out of scope (explicitly NOT in Phase 3)
- Per-key per-provider auth artifact mapping (ADR 0007 § 12; Phase 4+).
- `tried_providers` schema semantics fix on `key_no_provider_access` 403 — D45 reviewer P2 deferral. Tracked as a Phase 3 implementation D-day (D53) but NOT part of ADR 0008; documented in ADR 0004 amendment or ADR 0007 § 8 amendment at D53.
- All ADR 0007 § 12 deferrals other than Dashboard + audit query layer + rotation.
- Externally-visible Dashboard (anything bound to 0.0.0.0 / public). Operator SSH-tunnels.
---
## 13. Phase 3 sprint shape
| D-day | Deliverable | Type |
|---|---|---|
| **D48** | This ADR (0008 draft) | ADR-only |
| **D49** | `lib/audit-query.mjs` + Suite 23 unit tests | impl |
| **D50** | `server.mjs` `/v0/management/*` endpoints + `/dashboard` route + Suite 24 HTTP tests | impl |
| **D51** | `dashboard.html` + render JS + 30s poll | impl |
| **D52** | Audit daily rotation (`lib/audit.mjs` extension + `bin/olp-audit-rotate.mjs` + Suite 25 rotation tests) | impl |
| **D53** | `tried_providers` schema fix (D45 P2 deferral; small) | impl |
| **D54** | E2E browser smoke (manual or playwright) + AGENTS / README polish | tests + docs |
| **D55** | Phase 3 close → v0.3.0 (release-kit PR per `phase_close_trigger`) | release |
Each D-day = implementor + fresh-context opus reviewer per Iron Rule 10. Estimated wall-clock: similar to Phase 2 cadence (1 intense session per D-day under standing autopilot).
---
## Consequences
**Positive:**
- Operators get an at-a-glance view of OLP's behaviour (was a tail/grep exercise pre-Phase-3).
- Audit layer becomes queryable, not just append-only; `lib/audit-query.mjs` is reusable for future CLI tools (`olp-audit search` etc.).
- Daily rotation bounds per-file size + creates a natural archival unit.
- Owner-only gating reuses Phase 2 auth model (no new auth surface).
- v0.1 spec § 4.6 Dashboard requirements are met without introducing a build step / database / framework.
**Negative / trade-offs:**
- In-memory ndjson scan is O(N) per query; if audit grows to millions of lines (single-host years), the 30-day query becomes slow. Mitigation: ADR 0007 § 13 Option 3 hybrid is the documented next step; trigger is observed slowness.
- 30s poll generates baseline traffic when dashboard is open (2880 management requests/day per owner). Not a real cost but worth observing.
- Audit rotation is "first append after UTC midnight" which means a server with zero requests overnight rotates lazily (first request of the new day triggers it). External cron at D52 covers the strict-midnight case.
- No automatic audit retention. A multi-year-running server accumulates files; operator manages.
**Reversibility:**
- Dashboard is a static file + 4 endpoints. Removable in a future revert PR if Phase 3 retrospectively proves unwanted.
- Audit rotation is additive — disabling reverts to single-file behaviour without code change (operator never invokes the cron + the in-server rotation can be guarded by a config flag).
- The `lib/audit-query.mjs` module is consumed by Dashboard endpoints + can be used standalone; removing it requires unrelated endpoint surgery.
---
## Authority citations
- **OLP v0.1 spec § 4.6 + § 4.7** (Dashboard + observability endpoints) — at `~/.cc-rules/memory/projects/olp_v0_1_spec.md` on the maintainer's workstations.
- **OCP `dashboard.html`** (prior-art reference for the multi-panel HTML structure) — at `~/ocp/dashboard.html` on the maintainer's workstation; OCP production reference.
- **ADR 0007 §§ 7 / 8 / 12 / 13** (owner-gating model; audit ndjson schema; Phase 3 scope opening; SQLite forward path).
- **ADR 0002** (Provider contract — `quotaStatus` method that Panel 1 consumes).
- **OLP v0.1 spec § 4.6** (planning authority for `/cache/stats` endpoint name + Dashboard requirements). ADR 0005's `Consequences/Mitigations` paragraph references the endpoint as the monitoring surface for per-`(provider, model)` cache hit-rate breakdown; that breakdown is a Phase 4+ amendment trigger if needed (see § 7.4 above).
- **ADR 0004 Amendment 5** (D40 X-OLP-Fallback-Detail — top-fallback-chains panel data shape lineage).
- **Standing-autopilot grant** (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`) — Phase 3 kickoff via maintainer "go" + lane pin.
- **Phase 2 kickoff handoff pattern** (`~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md` in cc-rules `d9da966`) — this ADR follows the same structure.
- **CLAUDE.md `release_kit.phase_rolling_mode current_phase: Phase 3`** — confirms this ADR lands in the Phase 3 sprint.
@@ -0,0 +1,309 @@
# ADR 0009 — Anthropic Interactive-Mode Path (Placeholder)
- **Date:** 2026-05-25 (Placeholder); 2026-05-27 Amendment 1 (Accepted)
- **Status:** **Accepted** (post-Amendment 1 — OLP self-spike supersedes OCP-wait; implementation D-day scheduled this Phase 6)
- **Authors:** project maintainer (with AI advisory drafting)
- **Related:**
- **OCP ADR 0007** (Interactive-Mode Execution Pool, stream-json) — at `~/ocp/docs/adr/0007-interactive-mode-pool.md` on the maintainer's workstation. Pin reference at the time of this writing: OCP ADR 0007 is Draft status pending the same P0 outcome.
- OLP ADR 0001 (Project Founding) — establishes the 2026-06-15 Anthropic billing-split trigger that motivated OLP's multi-provider posture in the first place.
- OLP ADR 0006 (Provider Inclusion / Risk Tier Framework) — anthropic is currently a Tier-D Candidate; this ADR amends the operational shape of that plugin if/when P0 succeeds.
- OLP ADR 0007 (Multi-Key Auth) — Phase 2 design; per-key cache + audit layer that any future interactive-mode implementation must continue to satisfy.
- OLP ADR 0008 (Dashboard + Audit Query) — Phase 3 design; any interactive-mode change must not regress the Dashboard's per-provider observability fields.
- **Standing autopilot grant note:** Phase 4 is a "new authorization required" scope per `~/.cc-rules/memory/auto/standing_autopilot_phase_2.md`. This placeholder ADR is recorded NOT as implementation work but as the maintainer's "do not forget this when planning Phase 4" anchor. No implementation D-day is scheduled until P0 lands AND maintainer issues a Phase 4 "go" specific to this ADR.
---
## 1. Context
### 1.1 The triggering external work
OCP shipped ADR 0007 (`docs/adr/0007-interactive-mode-pool.md` in `dtzp555-max/ocp`) on 2026-05-25, draft status. The ADR designs a dual-path execution model for the post-2026-06-15 Anthropic billing split:
- **Current `claude -p` path**: programmatic billing → Agent SDK $100/month credit pool (~2050 heavy coding sessions/month).
- **Proposed interactive-mode path**: spawn Claude without `-p`, communicate via either (Transport A) piped NDJSON over stdio or (Transport B) `node-pty` PTY — possibly classified as interactive billing → subscription pool.
The OCP team accepted the *concept* but rejected an external contributor's PR #101 implementation (tmux + hook-file polling + `--dangerously-skip-permissions`) on alignment + security grounds, then drafted ADR 0007 as the clean redesign.
### 1.2 Why this is OLP's concern
OLP's founding premise (ADR 0001) was that OCP would become uneconomical post-2026-06-15, motivating a multi-provider hedge. OLP's `lib/providers/anthropic.mjs` today uses the same `claude -p` invocation OCP uses → same billing consequence post-2026-06-15.
If OCP's ADR 0007 P0 experiment confirms that an interactive-mode spawn (Transport A or B) bills against subscription rather than Agent SDK credit, the implementation pattern is directly portable to OLP's anthropic provider plugin. OLP would inherit the same billing benefit without needing to commission an independent P0.
If P0 fails for both transports, OLP's anthropic provider remains stuck on `-p` post-June-15; the multi-provider routing (OpenAI Codex, Mistral) becomes the operational mitigation, exactly per OLP's original founding logic.
### 1.3 The unverified premise (binding caveat)
Per OCP ADR 0007 § "Unverified Premise":
> "TTY detection matters. Local testing (Claude Code 2.1.150) shows: in a real TTY, `claude` without `-p` enters the TUI and does not emit NDJSON. With piped stdin/stdout (`child_process.spawn`), it emits NDJSON even without `-p`. This means Anthropic could use `isTTY` as the billing signal, not the `-p` flag."
OLP cannot independently confirm or refute this prior to 2026-06-15 — Anthropic's billing pool signaling is not exposed on any observable surface until the billing split goes live. **Any OLP implementation that bets on Transport A (stdio pipe) without P0 confirmation risks burning real Agent SDK credit on every Anthropic request.**
---
## 2. Decision
**Option 3 — Wait for OCP ADR 0007 P0 experiment outcome, then port the validated approach.**
Rationale:
- **Avoid duplicated P0 risk.** Both OCP and OLP would run the same experiment against the same Anthropic billing pool. OCP is already designed to run it; OLP riding their result is a free observation.
- **Lower decision-tree noise.** P0 has three outcomes (Transport A wins / Transport B wins / both fail). OLP's right answer differs per outcome; making the decision before the data is speculation.
- **No code is wasted.** OLP currently routes anthropic via `claude -p`, which works (just expensive post-June-15). The cost during the wait window is bounded by the Agent SDK $100/month credit + OLP's family-scale request volume.
What "wait" means concretely:
1. **No code change to `lib/providers/anthropic.mjs`** until OCP ADR 0007 transitions from Draft → Accepted (which requires P0 success per OCP ADR 0007 § "Status").
2. **No Phase 4 D-day scheduled for this scope** until that transition AND maintainer issues an explicit Phase 4 "go" naming this ADR.
3. **This placeholder ADR stays Draft** until either OCP P0 lands AND OLP decides to port, OR OCP P0 fails decisively AND OLP marks this ADR Rejected with a "shelved per upstream P0 failure" note.
---
## 3. P0 outcome → OLP action decision tree
Recorded here so a future Phase 4 brief can act mechanically once OCP P0 lands.
```
OCP ADR 0007 P0 result
├─ Transport A (stdio pipe) confirmed interactive-billing
│ → OLP Option 1 OR Option 2 (see § 4); maintainer decides.
│ Likely Option 1: port the lib/interactive-pool.mjs +
│ billing-router.mjs pattern into lib/providers/anthropic.mjs.
│ Updates ADR 0006 to spell out the new spawn mode.
├─ Transport B (PTY) confirmed; Transport A fails
│ → OLP Option 1 with PTY adapter; node-pty dependency added
│ under engines-bump scrutiny (this triggers a separate prior
│ PR per ADR 0007 § 11-like discipline: native addon adds
│ CI matrix work).
├─ Both transports billed as programmatic
│ → OLP marks this ADR Rejected. Anthropic provider stays on
`claude -p`. Operational mitigation: family-scale users
│ either (a) accept the Agent SDK $100 cap, (b) bring their
│ own API key (BYOK env path is already there), or (c) shift
│ volume to other providers (Codex / Mistral) via OLP's
│ existing fallback chain.
└─ P0 results unobservable (billing signals not exposed)
→ Continue waiting. Re-evaluate one billing cycle (30 days)
post-2026-06-15. OLP Anthropic provider remains on `-p`
path during the wait; users see Agent SDK credit consumption
as the cost signal.
```
---
## 4. Implementation lanes (to be selected when P0 lands)
This section is informational only. No lane is selected at placeholder time.
### Option 1 — OLP parallel implementation
OLP's `lib/providers/anthropic.mjs` reimplements OCP's interactive pool natively. Replaces the current `claude -p` spawn with a pool-managed warm process + adapter selected per P0 outcome.
- **Pros:** OLP self-contained; no runtime dependency on OCP being installed.
- **Cons:** Duplicates substantial logic (pool lifecycle, transport adapter, crash backoff DEGRADED, permission auto-response). Two codebases drift over time.
### Option 2 — OLP chains OCP as backend
OLP's `lib/providers/anthropic.mjs` invokes OCP (via its existing HTTP entry surface, or via a future direct-spawn API) rather than spawning `claude` directly. OLP becomes a multi-provider layer ON TOP OF OCP for the Anthropic provider; other providers (Codex, Mistral) continue to be direct.
- **Pros:** Zero duplication; OLP benefits from OCP's P0-validated work automatically. Architectural separation: OCP owns Claude execution, OLP owns multi-provider routing.
- **Cons:** OLP gains a runtime dependency on OCP being installed + running. Double caching (OCP cache + OLP cache; cache key composition needs to avoid stampede). OCP's HTTP shim is OpenAI-spec-compatible but adds an extra hop's latency. OCP failure modes propagate.
### Option 3 — Both (default to OCP backend if available, fallback to local pool)
A hybrid: OLP detects OCP installed locally, prefers chaining; otherwise falls back to the parallel implementation. Most defensive but most complex.
**Default at placeholder time:** Option 1 is the simpler ship if P0 transports prove out. Option 2 is the cleaner architecture but adds operational coupling. Maintainer decides at P0-resolution time.
---
## 5. Risk assessment (placeholder snapshot)
| Risk | Likelihood (now) | Impact | Mitigation pending P0 |
|---|---|---|---|
| OLP forgets this ADR exists | Medium (multi-month wait) | High (would mean OLP misses the post-June-15 window) | This ADR + cc-rules memory `~/.cc-rules/memory/learnings/ocp_adr_0007_interactive_mode_pool.md` |
| OCP ADR 0007 P0 fails entirely | Medium | High for OLP Anthropic users (Agent SDK $100 cap binds) | OLP's multi-provider fallback (Codex/Mistral) already shipped as Phase 1 work; users have a path |
| OCP ADR 0007 ships incomplete (interim) | Medium | Medium (OLP can't reliably port) | Wait for OCP to mark Accepted; don't port from Draft |
| Anthropic policy changes mid-wait | Medium | Depends — could obsolete the whole approach | Re-read OCP ADR 0007 + this ADR before any Phase 4 anthropic work; no decisions on stale information |
---
## 6. Out of scope (explicitly NOT in this ADR)
- **Any code change.** This is a placeholder + decision-tree record only.
- **OCP P0 design.** That work is OCP's responsibility per OCP ADR 0007 § "Implementation phases".
- **A new OCP-OLP integration protocol.** If Option 2 is selected at P0-resolution time, the integration shape is a separate ADR.
- **Engines-bump for node-pty.** Only relevant if P0 picks Transport B and Option 1 is selected. Then it lands as a separate prior PR per the ADR 0007 § 11 pattern.
---
## 7. Phase 4 priority interaction
This ADR is recorded BEFORE Phase 4 implementation scope is finalized. Phase 4 currently lists (per OLP v0.3.0 CHANGELOG):
- Per-key per-provider auth artifact mapping (ADR 0007 § 12 deferral)
- Audit retention policies (ADR 0008 § 11 deferral)
- SQLite hybrid migration (ADR 0007 § 13 trigger)
- Provider-cost weights for spend trend (ADR 0008 § 11 deferral)
If OCP P0 succeeds, **the interactive-mode port likely jumps to the top of Phase 4** (highest user impact: keeps OLP Anthropic users on subscription billing). The other items remain Phase 4 but reorder downstream.
If OCP P0 fails, **this ADR is shelved** and Phase 4 ordering is unchanged.
---
## Consequences
**Positive:**
- OLP retains a documented anchor for the interactive-mode option without committing implementation effort prematurely.
- Future Phase 4 planning has a structured decision tree, not a vague "we should look at OCP someday".
- Cross-machine + cross-session memory (cc-rules) ensures the dependency is visible to any future session reading the OLP project context.
**Negative:**
- During the wait window (now → P0 outcome ≥ 2026-07-15), OLP Anthropic users consume Agent SDK credit post-2026-06-15. Bounded by family-scale request volume but a real operational cost.
- Some risk that "wait" turns into "forget" if multiple unrelated Phase 4 priorities crowd the agenda. Mitigated by this ADR + the cc-rules memory pointer.
**Reversibility:**
- This is a placeholder. Either P0 result transitions it (Accepted → port, Rejected → shelve) cleanly. The placeholder itself doesn't lock OLP into anything.
---
## Authority citations
- **OCP ADR 0007** at `~/ocp/docs/adr/0007-interactive-mode-pool.md` (maintainer workstation). Project repo: `dtzp555-max/ocp`.
- **PR #101** to `dtzp555-max/ocp` — external contributor's tmux-based prototype that triggered the OCP ADR 0007 redesign.
- **Anthropic 2026-06-15 billing-split announcement** — see `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`.
- **OLP ADR 0001** (Project Founding) — establishes the original billing-split → multi-provider motivation.
- **OLP ADR 0006** (Provider Inclusion + Risk Tier Framework) — the anthropic plugin's tier classification + the surface this ADR would amend.
- **OLP ADR 0007** (Multi-Key Auth, Phase 2) — per-key audit + cache layer that must continue to work across any anthropic execution-mode change.
- **OLP ADR 0008** (Dashboard + Audit Query, Phase 3) — Dashboard per-provider fields must continue to populate.
- **CLAUDE.md `release_kit.phase_rolling_mode`** — current_phase: Phase 4 (post-v0.3.0); this ADR explicitly does NOT consume a Phase 4 D-day until P0 lands.
- **Standing autopilot grant** (`~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` in cc-rules `bf0ed9a`) — Phase 4+ requires new authorization; this placeholder is a decision-tree pre-record, not Phase 4 implementation.
---
## Status transitions (recorded for clarity)
- 2026-05-25 — Created as Draft (Placeholder). OCP ADR 0007 also Draft.
- 2026-05-27 — Amendment 1 promotes to **Accepted**. OLP self-spike + empirical Transport-A confirmation on `claude` CLI v2.1.104 superseded wait-for-OCP. OCP is now in maintenance mode (per maintainer statement 2026-05-27 session) — OLP leads. Implementation lane: **Option 1 (parallel implementation, no warm pool, no PTY)**, scope reduced from "10-day warm pool with billing router" to "2-3-day stateless stream-json adapter".
---
## Amendment 1 — 2026-05-27: Self-spike supersedes wait-for-OCP; lock Option 1 with stream-json-no-`-p` transport
### Trigger
Two findings on 2026-05-27 changed the placeholder's premises:
1. **OCP is no longer the lead project.** The maintainer stated in the 2026-05-27 session: "OCP 不会大改动…主力方向放到 OLP." The "wait-and-port" strategy implicitly assumed OCP would do the P0 first. With OCP in maintenance mode, OLP cannot wait — the 2026-06-15 Anthropic billing split is 19 days out from this amendment.
2. **OLP self-spike confirmed Transport A (`--output-format stream-json --verbose` without `-p`) emits NDJSON on Claude Code v2.1.104.** The placeholder ADR § 1.3 cited OCP's "v2.1.150 only" observation as the binding caveat. Local empirical re-test on 2026-05-27 against PI231's deployed `claude` v2.1.104 produced the full NDJSON event stream (system/init + stream_event token deltas + message_stop + result + rate_limit_event) for invocations **without** `-p`. The `claude --help` text saying "(only works with --print)" is misleading — the flags accept invocation without `-p` and produce the documented NDJSON shape.
### Additional spike findings (2026-05-27 billing classification)
A separate web/GitHub research spike on the 2026-06-15 billing classification returned:
- Anthropic's published policy is **intent-based, not mechanism-based**. The Agent SDK credit pool covers: Agent SDK Python/TypeScript packages, `claude -p`, GitHub Actions, **and "third-party apps that authenticate with your Claude subscription through the Agent SDK"**. Subscription pool covers "Claude Code in the terminal or your IDE in interactive mode."
- The third-party-app clause is the load-bearing ambiguity. OLP qualifies as a third-party app regardless of which CLI mode it spawns. If Anthropic tightens that clause from "via Agent SDK" to "any third-party app," OLP is caught regardless of `-p` flag presence.
- Behavioral fingerprinting (request cadence, OAuth-scope patterns, isTTY absence) is a separate detection vector Anthropic could deploy without policy-text changes.
The spike's recommendation: "viable bridge for ~30-60 days post-2026-06-15, NOT durable solution."
### Value re-anchoring
The placeholder framed interactive-mode as "the durable answer to keep OLP anthropic subscription value past 2026-06-15." The 2026-05-27 spike re-anchors the value:
| Value | Placeholder framing | 2026-05-27 framing |
|---|---|---|
| Keep subscription pool 6.15+ | **Primary value** | **Uncertain bridge** (30-60 day plausibility) |
| Hallucination fix (env-block / cwd injection) | (Not addressed) | **Primary value** — empirically proven |
| Cost reduction (drop default tool descriptions) | (Not addressed) | **Primary value** — ~30% input token / ~64% per-request cost reduction measured against `--system-prompt` override |
| Observability (rate_limit / cache / usage per request) | (Not addressed) | **Primary value** — NDJSON events expose data the current `--output-format text` path discards |
| Protocol foundation for future tool-call passthrough | (Not addressed) | **Secondary value** — same NDJSON parser is reusable for Phase 8+ tool passthrough work |
**Net**: even if Anthropic immediately reclassifies third-party apps to Agent SDK pool on 2026-06-15 — making the bridge worthless — the implementation still earns its keep through the other four values.
### Locked decision
**Option 1 — Parallel implementation in OLP's `lib/providers/anthropic.mjs`.**
Lane: stream-json output, no `-p` flag (Transport A confirmed), stateless per-request spawn (no warm pool, no PTY, no node-pty dependency).
Rejected lanes and why:
- **Option 2 (chain OCP)** — OCP is in maintenance mode; coupling OLP's anthropic provider to OCP's HTTP shim is the wrong direction.
- **Option 3 (both)** — premature complexity; pick the simple lane first.
- **Warm-process pool** — OLP is stateless per AGENTS.md § "No conversation state". Pool lifecycle, crash backoff, and permission auto-response from OCP ADR 0007 § 4 are unnecessary for OLP's per-request model.
- **PTY (Transport B with node-pty)** — Transport A worked; engines-bump for a native addon is unjustified when the simpler transport produces the documented NDJSON.
### Implementation scope (Option 1, this Phase 6)
| Change | Surface | Authority |
|---|---|---|
| `buildCliArgs(model)` drop `-p` and `--output-format text`; add `--output-format stream-json`, `--verbose`, `--no-session-persistence`, `--model` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--output-format` / § `--verbose` |
| `buildCliArgs(model, systemPrompt)` accepts optional system prompt; spawns with `--system-prompt "<OLP wrapper text>"` | `lib/providers/anthropic.mjs` | `claude --help` (v2.1.104) § `--system-prompt` |
| OLP-managed system prompt construction (extract client `role:system` IR messages, prepend OLP wrapper saying "you are accessed via HTTP proxy; no local env/fs/shell access; respond directly") | `lib/providers/anthropic.mjs` `irToAnthropic` | This ADR § "OLP system prompt wrapper" below |
| New `anthropicStreamJsonChunkToIR` parser replacing/supplementing `anthropicChunkToIR` — handles NDJSON event types `system/init`, `stream_event/content_block_delta`, `assistant`, `result`, `rate_limit_event` | `lib/providers/anthropic.mjs` | This ADR § "NDJSON event handling" below |
| New tests verifying NDJSON parsing, system-prompt construction, env-block absence | `test-features.mjs` | (test surface; no external authority) |
| README troubleshooting / supported-providers § note about the bridge nature | `README.md` | (docs surface) |
The `irToAnthropic` text serialization path is preserved for client messages (`role: user`, `role: assistant`); the `role: system` extraction goes to `--system-prompt`.
### OLP system prompt wrapper
The wrapper text injected via `--system-prompt`:
```
You are accessed via the OLP HTTP proxy. You do NOT have access to any local
filesystem, working directory, shell, git status, or machine environment.
Do not infer or invent such information from any context you observe.
Respond only based on the conversation provided.
```
If the client IR request contains `role: system` messages, their concatenated `content` is appended after a blank line.
### NDJSON event handling
The parser must yield IR chunks based on the event stream:
| NDJSON event | IR yield | Notes |
|---|---|---|
| `{type:"system", subtype:"init"}` | None (consumed for session_id tracking) | First event always; ignore |
| `{type:"stream_event", event:{type:"content_block_delta", delta:{type:"text_delta", text:"..."}}}` | `{type: "delta", content: "<text>"}` | Token-by-token streaming |
| `{type:"assistant"}` | None (already captured by per-token deltas) | Aggregate message; ignore (or use for verify, optional) |
| `{type:"result", subtype:"success"}` | `{type:"stop", finish_reason:"stop"}` | Marks end |
| `{type:"rate_limit_event"}` | None (consumed for audit/dashboard) | Forward to OLP audit/observability layer later (Phase 6+ enhancement) |
| `{type:"control_request"}` | Log + ignore | Per Anthropic stream-json docs |
The cache key composition (ADR 0005) is unchanged — same IR request hash; the on-the-wire format change is internal to the anthropic plugin.
### Token cost measurement (binding evidence)
Two requests against PI231 v2.1.104 on 2026-05-27 with identical user prompt `"reply: OK"`, model `claude-sonnet-4-6`:
- Default invocation (no `--system-prompt`): `cache_creation_input_tokens=4785`, `cache_read_input_tokens=11816`, total input ≈ 16,601 tokens, `total_cost_usd=$0.0216`.
- With `--system-prompt "You are a chat assistant. Respond directly."`: `cache_creation_input_tokens=1306`, `cache_read_input_tokens=9394`, total input ≈ 10,700 tokens, `total_cost_usd=$0.0078`.
**Net**: ~30% input token reduction, ~64% per-request cost reduction. Replicable by anyone with `claude` v2.1.104 + OAuth on a similar setup.
### Caveats binding the implementation
1. **Bridge value uncertain.** The 30-60 day estimate is a spike judgment, not Anthropic-confirmed. Implementation must continue to function correctly if Anthropic re-routes this path to Agent SDK billing on 2026-06-15 — the only consequence is the bridge value disappears, but the other four values (hallucination / cost / observability / protocol foundation) remain.
2. **No claim about durability.** This ADR amends only as far as "the bridge is worth the 2-3 day investment given the orthogonal values." A future ADR (likely Phase 7 sandbox-runtime + Phase 8 multi-provider robustness) will revisit the anthropic provider's strategic role once the post-2026-06-15 picture clarifies.
3. **Sandbox-runtime still required for real multi-tenant deployment.** Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation). This ADR does NOT substitute for that work; sandbox-runtime remains Phase 7 scope and is a hard prerequisite before any cloud deployment per `docs/plans/cloud-deployment-family.md`.
4. **CLI version pin guidance.** Stream-json without `-p` was confirmed on v2.1.104. Future versions may tighten this; the plugin's spawn should emit a warning to OLP server log if `claude --version` falls outside a `v2.1.100``v2.1.149` range. Hard failure on out-of-range version is NOT required; warning is sufficient for v0.6.x.
### Updated authority citations (in addition to placeholder § Authority citations)
- **OLP self-spike — 2026-05-27 session live transcripts** (PI231 ssh; `claude -p --output-format stream-json --verbose` and `claude` no-`-p` variants captured in session log; retained in cc-mem post-implementation).
- **P0 billing classification spike — 2026-05-27** subagent transcript; sources include Anthropic published docs at `code.claude.com/docs/en/headless`, `support.claude.com/en/articles/15036540`, `support.claude.com/en/articles/11145838`.
- **claude CLI v2.1.104 `--help`** (live capture on PI231) § `--output-format`, § `--verbose`, § `--system-prompt`, § `--no-session-persistence`.
- **CLAUDE.md `release_kit.phase_rolling_mode.current_phase`** — Phase 6; this ADR consumes a Phase 6 D-day per the amendment, NOT a Phase 4 D-day (the placeholder's hypothetical scheduling).
@@ -0,0 +1,162 @@
# ADR 0010 — Phase 4 Charter: Operator + Client UX
**Status:** Accepted (Phase 4 open as of 2026-05-26)
**Date:** 2026-05-26
**D-day:** D60 (charter + default port change)
---
## Context
Phases 1 — 3 shipped OLP's structural core: HTTP entry surface, IR, provider plugins, fallback engine, content-addressed cache (including streaming-path singleflight at D57+D58 → v0.3.2), multi-key auth + audit ndjson + daily rotation, owner-only management endpoints + dashboard. v1.x roadmap items #1 / #2 / #4 / #7 are closed. Items #3 / #5 / #6 remain trigger-gated.
Two complementary brainstorm passes (2026-05-26) — a comprehensive OCP feature audit + a multi-provider proxy / IDE integration prior-art survey — converged on a clear gap: **OLP's operator and client surfaces are 0% inherited from OCP**. Today OLP has `bin/olp-keys` and `bin/olp-audit-rotate` as the entire operator CLI, no `olp doctor` / no `olp-connect`, no Telegram/Discord integration, no SSE heartbeat for long-running streams behind reverse proxies. Family members get OLP API keys via out-of-band paste, point their IDEs at OLP via the README's one-line example, and discover failure modes via curl. OCP's UX worked because of a load-bearing combination: README `paste-this-prompt-to-Claude-Code` instructions + machine-readable `ocp doctor next_action.ai_executable[]` + `ocp-connect` zero-config LAN setup + `/health.anonymousKey` self-advertising token + `/ocp` Telegram slash commands. **Phase 4 brings these forward as OLP-native primitives.**
A separate strategic decision — should OLP add `/v1/messages` (Anthropic-shape entry surface) for Claude Code support — was considered and **rejected for Phase 4** (see § "Out of Phase 4 scope" below). The decision is recorded with an explicit re-open trigger.
---
## Decision
Phase 4 scope is **Operator + Client UX**. The phase opens 2026-05-26 with D60 (this charter + default port change). Phase 4 close ships v0.4.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
### In scope — Phase 4 D-day plan (~13 D-days)
| D-day | Deliverable | Authority | Estimate |
|---|---|---|---|
| **D60** | Default port `3456 → 4567` + this ADR 0010 charter + README / CHANGELOG / ADR 0001 + ADR 0008 amendments | This charter | 0.5d |
| **D61 — D63** | SSE heartbeat (opt-in via `streaming.heartbeat_interval_ms` config; eager-headers-post-spawn; `X-Accel-Buffering: no` constant) + `recentErrors[20]` ring buffer + `/status` combined endpoint | Port OCP `server.mjs:660-685` + `301-358` + `1151-1188`; OCP `docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md` | 2.5d |
| **D64 — D67** | `olp` Node-based CLI scaffold (subcommands `status / health / usage / models / logs / cache / providers / chain show / restart / doctor`) + `olp doctor` machine-readable `next_action.ai_executable[]` framework + one fix-template per shipped provider plugin | Port OCP `ocp` bash wrapper (translated to Node — bash dep on python3 is a known fragile point) + OCP `scripts/doctor.mjs` framework | 4d |
| **D68 — D70** | `olp-connect <ip>` client-side IDE auto-config (Cline / Continue.dev / Cursor / Aider / Claude Code / OpenClaw detection) + `/health.anonymousKey` field (opt-in via `auth.advertise_anonymous_key` config; default off) + ADR 0011 (anonymous-key deployment-context limits — trusted-LAN-only invariant explicit) | Port OCP `ocp-connect` + `server.mjs:1454,1488` | 3d |
| **D71 — D73** | `olp-plugin/` (OpenClaw gateway plugin for `/olp` Telegram/Discord slash commands; subcommand parity with `olp` CLI minus mutations) + `docs/integrations/{continue.md,cline.md,cursor.md,aider.md,claude-code.md,openclaw.md}` IDE setup docs | Port OCP `ocp-plugin/index.js`; cross-ref Prior-Art § 3 + § 4 | 3d |
| **close** | v0.4.0 release PR — `package.json` bump, CHANGELOG promotion, `release_kit.phase_rolling_mode` advance to Phase 5 pre-release identifier | `CLAUDE.md release_kit overlay` | maintainer-triggered |
### Out of Phase 4 scope (with explicit triggers)
#### `/v1/messages` — Anthropic-shape entry surface
**Status:** Deferred. Re-enable strictly gated on ADR 0009 P0 success.
**Value matrix (decisive):**
| Scenario | Without `/v1/messages` | With `/v1/messages` |
|---|---|---|
| Maintainer's own Claude Code usage | Direct via Anthropic OAuth → subscription (today) or Agent SDK pool (post-2026-06-15) | Same — maintainer never routes own CC through OLP per stated workflow |
| Family member wanting CC access | Not supported (OAuth is full-account; OLP CLI tokens are scoped) | CC via `ANTHROPIC_BASE_URL=http://olp:4567` + `olp_*` token |
| **P0 succeeds** (ADR 0009 interactive-mode bills as subscription) | OpenAI-shape IDE clients (Cline/Continue/Cursor) all benefit automatically via OLP's anthropic plugin | CC users additionally benefit; both subscription-billed |
| **P0 fails** (interactive-mode bills as Agent SDK same as `-p`) | OpenAI-shape clients still work; no billing change | CC users get same billing as direct OAuth; **fallback to codex/mistral degrades Anthropic-specific features (tool_use schema mismatch / cache_control drop / computer_use no-op / thinking-block drop)** more severely than OpenAI-shape clients which speak the multi-provider lingua franca |
**Rationale.** Under P0 failure, `/v1/messages` provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients (because OpenAI tool schema is the cross-provider standard). The security benefit (no OAuth exposure) is equally achievable via Cline/Continue/Cursor. **Net non-positive under P0 failure.**
**Re-open condition.** (a) ADR 0009 P0 confirms interactive-mode billing classification as subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens Phase 5 "Anthropic-shape hub" scope with the name of at least one family member who wants CC access. If only (a) fires without (b), `/v1/messages` is reconsidered at the start of whichever phase covers it but is not auto-opened.
**README posture (Phase 4).** README § Supported Clients explicitly lists OpenAI-compatible clients (Cline, Continue.dev, Cursor, Aider, OpenClaw bots). Claude Code is listed as **Not supported as an OLP client**, with the explicit alternative "Cline + OLP" (same fallback chain available, better cross-provider compatibility). README links to this ADR for the reasoning.
#### Other deferred items
- **v1.x roadmap #3 (soft trigger reactivation)**, **#5 (provider `cacheKeyFields` mask)**, **#6 (streaming SPAWN_FAILED salvage)** — trigger conditions per `docs/v1x-roadmap.md` have not fired. Not in Phase 4.
- **Anthropic / codex billing audits** — date-gated (`anthropic.mjs:53, 416, 441` say 2026-06-16; `codex.mjs:572` post-D7 E2E audit). Not in Phase 4.
- **`context_window_exceeded` fallback trigger** (LiteLLM prior-art) — small ADR amendment + trigger taxonomy add; opportunistically in Phase 5 unless trigger fires sooner.
- **`X-OLP-Cost-USD` per-request response header** — depends on provider-cost weights table (Phase 5 prerequisite).
- **per-(provider, model) live stats Map** (replacing audit-query scan for dashboard 30s poll) — current scan latency adequate; Phase 5+.
- **OpenTelemetry GenAI span emission**`npm` dep + ~150 LOC; family-scale ROI marginal. Phase 6+ unless Langfuse self-host requested.
- **Intent-based routing**, **stackable transformer plugin model** — explicit non-goals per Prior-Art § 8 anti-patterns.
### Opportunistic Phase 4 micro-additions (not blocking)
Items small enough to land alongside a planned D-day without scope creep, if encountered:
- Env-var deny-list before provider plugin `spawn` (per OCP `server.mjs:531-534`; each plugin declares its own list)
- 5 MB request body cap with HTTP 413 (per OCP `server.mjs:1270,1278-1281`)
- Error-response path-sanitization (per OCP `server.mjs:1395`)
- Stable node-path resolution in launchd plist (Homebrew `/Cellar/<ver>/``/opt/` rewrite; per OCP `setup.mjs:344-351`)
- Legacy model alias resolution in `models-registry.json` (`aliases:` field; per OCP `legacyAliases`)
### Exit gate — v0.4.0 close criteria
1. D60 — D73 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
2. CI green on every D-day merge commit and on the v0.4.0 release commit head.
3. README § Operator CLI + § IDE Setup + § Telegram/Discord Usage sections present.
4. ADR 0010 (this charter) + ADR 0011 (anonymous-key deployment-context limits) on disk.
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.4.0 — <date>"` with D60 — D73 entries.
6. `package.json` bumped to `0.4.0`.
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 4 → Phase 5`; `current_pre_release_identifier` advances `0.4.0-phase4 → 0.5.0-phase5`.
8. Standing autopilot grant covers D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered.
---
## Default port change (D60 specific)
The default `OLP_PORT` value moves `3456 → 4567` at this D-day. Rationale:
- OCP defaults to 3456 and the maintainer's existing OCP installs stay on 3456 indefinitely.
- A standard `olp` install on the same host without overriding `OLP_PORT` collides at bind time.
- Setting `OLP_PORT=4567` as the default makes co-host the recommended steady state during the migration window (and beyond — there is no enforced deprecation of OCP).
- Existing OLP deployments wanting the pre-D60 default can set `OLP_PORT=3456` in the launchd plist / shell env.
**Tested invariants preserved by the port change:**
- All `test-features.mjs` suites use `port: 0` (ephemeral assigned port) — no test depends on the default value. Verified via `grep -nE '\\b3456\\b' test-features.mjs` returning empty.
- All cache / fallback / provider plugin code is port-agnostic.
- Dashboard 30s poll uses relative paths — no port change required in `dashboard.html`.
- `/v0/management/*` endpoints use relative paths — no client-side update required.
**Files amended at D60:**
- `server.mjs:17` — env-var doc comment
- `server.mjs:74` — default value
- `README.md` quick start + Environment Variables table + Migration from OCP § note
- `docs/adr/0001-project-founding.md` § "Decision" paragraph about port conflict (struck and amended)
- `docs/adr/0008-dashboard-and-audit-query.md` § 6.6 port reference
- `CHANGELOG.md` Unreleased entry
- This ADR
---
## Consequences
**Positive.**
- Family member onboarding goes from "maintainer texts API key + edits IDE config" to `curl -fsSL .../olp-connect | bash -s -- <ip>`.
- `paste-this-prompt-to-Claude-Code` self-installation pattern unlocks AI-driven setup / upgrade / repair, eliminating the maintainer's Tier-1 support role.
- Long-reasoning streams behind nginx / Cloudflare / Tailscale Funnel no longer 502 at 60s idle.
- `/olp` Telegram slash commands enable "is OLP up?" / "show usage" / "rotate key" from anywhere with chat access.
- OCP and OLP co-host on the same workstation, lowering the maintainer's cost of running both.
**Negative.**
- Phase 4 is the first phase whose scope is primarily about *operator experience* rather than functional capability. The work doesn't unlock new requests OLP can serve; it makes OLP's existing capability survive contact with real users.
- The `olp-connect` IDE auto-detect logic accumulates IDE-specific quirks (Cline base-URL UI regressions per their issue #7128; Cursor's malformed-request-when-OpenRouter behavior; etc.). Maintenance burden grows.
- README size grows substantially with Operator CLI + IDE Setup + Telegram/Discord sections. Discoverability of the existing technical reference (ADRs, environment variables) may degrade unless the navigation is refactored.
**Neutral.**
- Phase 4 deliberately spends 0 D-days on `/v1/messages`. If ADR 0009 P0 succeeds in Q3 2026, Phase 5 "Anthropic-shape hub" becomes the natural next phase, with the prerequisite IR work that Phase 4 surfaces (every IDE doc page is a test of which IR fields actually flow through). If P0 fails, `/v1/messages` shelves indefinitely and the README simply documents CC as out-of-scope.
---
## Alternatives considered
1. **Phase 4 = `/v1/messages` first, operator UX later.** Rejected. The brainstorm matrix demonstrated `/v1/messages` is value-positive only if ADR 0009 P0 succeeds, and operator UX gains accrue regardless. Building speculative infrastructure ahead of P0 risks 5-7 D-days of work shelving.
2. **Phase 4 = operator + client UX + `/v1/messages` together (full kitchen sink).** Rejected. ~20 D-days lengthens the Phase 4 close window unnecessarily; the natural review chunks blur; maintainer review fatigue is real.
3. **Phase 4 = just D60 + opportunistic SSE heartbeat, no CLI / no plugin / no docs bundle.** Rejected. Each of the operator-UX items individually has small ROI; the value compounds when they ship together (CLI surfaces data → `/status` exposes shape → Telegram plugin renders → IDE docs reference → `olp-connect` automates). Splitting them across phases loses the compounding.
4. **Defer Phase 4 entirely; jump to Phase 5 Anthropic-shape hub when P0 lands.** Rejected. Operator UX is needed now (this session is itself evidence — the maintainer spent ~30 minutes confirming OCP feature inheritance because there's no `olp doctor` answer). Waiting for P0 stalls progress on independently-valuable work.
---
## Authority
- `docs/v1x-roadmap.md` — Phase 4 was named as the canonical destination for the post-cleanup batch since v0.3.0 close.
- `CLAUDE.md release_kit.phase_rolling_mode``current_phase: Phase 4` already; this charter formalizes the contents.
- OCP comprehensive feature audit (2026-05-26 subagent output, summarized in `~/.cc-rules/memory/auto/MEMORY.md` and in this session's transcript).
- Multi-provider proxy / IDE integration prior-art survey (2026-05-26 subagent output).
- ADR 0009 (Anthropic interactive-mode path placeholder) — establishes the gate for `/v1/messages` re-consideration.
- ADR 0001 (project founding) — § "Decision" paragraph about port conflict, amended at this D-day.
- ADR 0008 (dashboard + audit query) — § 6.6 default-port reference, amended at this D-day.
- `~/.cc-rules/memory/auto/standing_autopilot_phase_2.md` — standing autopilot grant covering D-day-by-D-day execution; v0.4.0 close PR is maintainer-triggered per `release_kit.phase_close_trigger`.
---
## Procedural mechanism
CC 开发铁律 v1.6 § 5.5 (release-kit overlay drives Phase boundaries) + § 10 (independent reviewer on every implementation D-day) + § 11 (minimum reviewable unit per PR — this charter ships as D60 PR alongside the default port change because both are governance-class and small).
@@ -0,0 +1,358 @@
# ADR 0011 — Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant)
**Status:** Accepted (2026-05-26)
**Date:** 2026-05-26
**D-day:** D70 (lands alongside D68 `olp-connect` + D69 `/health.anonymousKey`)
---
## Context
ADR 0010 § Phase 4 D68-D70 charter scoped a three-deliverable bundle:
- **D68** `olp-connect <ip>` — client-side bash script that auto-configures a
family member's machine to point at a remote OLP instance, including IDE
detection (Cline / Continue.dev / Cursor / Aider / OpenClaw) and rc-file +
system-level env var writes.
- **D69** `/health.anonymousKey` field — opt-in (`auth.advertise_anonymous_key:
true` in `~/.olp/config.json`; default `false`) surface that emits the
plaintext of a designated guest-tier key so `olp-connect` can pick it up
with zero-config (no out-of-band token paste).
- **D70** this ADR — codifies the deployment-context limits that make D69
safe.
The D69 mechanism is a deliberate port of OCP's clever `PROXY_ANONYMOUS_KEY` +
`/health.anonymousKey` pattern (OCP `server.mjs:148, 1454, 1488, 1555`,
shipped 2026-04 under OCP issue #12 § 14 Path A) which made family-member
onboarding go from "maintainer texts API key + edits IDE config" to
`curl -fsSL .../ocp-connect | bash -s -- <ip>`. The OCP pattern works on
trusted family LAN deployments; it would catastrophically fail on a public
internet deployment. ADR 0011 makes the trust assumption explicit before OLP
inherits the pattern.
The ADR also pins three implementation details that are NOT obvious from
reading the D69 patch alone:
1. The plaintext token must live on disk SOMEWHERE for the server to surface
it. ADR 0007 § 5 + § 6.2 explicitly forbid plaintext storage in the
default manifest. D69 introduces an explicit **opt-in** `plaintext_advertise`
manifest field for ONLY the advertised key — every other key retains the
ADR 0007 § 5 hash-only contract.
2. The advertised key is **guest-tier**, not owner-tier. Owner-tier
advertisement is rejected at keygen time AND at config-load time —
exposing the owner identity unauthenticated would grant any LAN caller
`/health` full payload, `/v0/management/*` mutating access, and
`X-OLP-Fallback-Detail` visibility — the exact inverse of the advertise
key's intent (a low-privilege zero-config tier).
3. Three prerequisites MUST hold simultaneously for `/health.anonymousKey`
to be emitted; missing any one is logged at startup but the server still
boots — graceful-degrade rather than refuse-to-start.
---
## Decision
### Three-prerequisite gate (server-side)
`/health` emits the `anonymousKey` field if and only if ALL THREE hold:
1. `auth.advertise_anonymous_key === true` in `~/.olp/config.json` (default
`false` — opt-in).
2. `auth.allow_anonymous === true` (the anonymous tier must be reachable for
the advertised key to be meaningful to zero-config callers; advertising a
key into a deployment that rejects anonymous requests is incoherent).
3. At least one active (`revoked_at === null`) manifest under `~/.olp/keys/`
carries a non-empty `plaintext_advertise: "olp_..."` field.
When prerequisite (1) holds but (2) or (3) fails, the server logs a
startup warn (`anonymous_key_advertised_but_denied` or
`anonymous_key_advertised_but_no_anonymous_key_exists`) but starts normally
and simply omits the field from `/health` responses.
### Plaintext storage mechanism (`plaintext_advertise` manifest field)
ADR 0007 § 5 forbids plaintext storage anywhere. D69 introduces a single,
**explicitly opt-in** exception: the manifest of the designated advertised
key gains a `plaintext_advertise` field whose value is the plaintext token.
This field is written ONLY when the operator runs:
```
olp-keys keygen --anonymous --advertise
```
(or `--advertise` alone on a guest-tier `keygen` invocation; `--anonymous`
is a friendly shorthand for `--tier=guest --name=anonymous`). The keygen
command surfaces an explicit `WARNING` to stderr at creation time:
```
WARNING: this key's plaintext is now stored on disk + will be exposed via
/health.anonymousKey when auth.advertise_anonymous_key=true AND
auth.allow_anonymous=true. Use ONLY on a trusted LAN. See ADR 0011.
```
Every other key (every existing key, and every newly-created key without
`--advertise`) retains the ADR 0007 § 5 hash-only contract — `manifest.json`
contains `token_hash` and NEVER `plaintext_advertise`.
**Schema-version note (D69 reviewer P2-2).** This adds a new optional field
to the manifest. Per ADR 0007 § 4 ("Increment `schema_version` on any
non-additive change"), additive optional fields do NOT require a
`schema_version` bump — older parsers ignore unknown fields per the same
section's forward-compat rule. The manifest stays at `schema_version: 1`.
Documented here so a future archaeologist asking "why didn't D69 bump
`schema_version`?" has a one-line answer.
**`listKeys()` redaction (D69 reviewer P2-1).** `lib/keys.mjs listKeys()`
strips BOTH `token_hash` AND `plaintext_advertise` from its return value.
Callers wanting the advertised plaintext for the `/health` publication
path MUST go through `findAdvertisedKey()` — the only sanctioned read
site. This protects against a future caller of `listKeys()` accidentally
emitting the plaintext into logs / HTTP responses / dashboards.
### Tier restriction (guest only)
`createKey()` rejects `plaintext_advertise: true` for `owner_tier: 'owner'`
with the error `createKey: plaintext_advertise requires owner_tier="guest"`.
The CLI also rejects `--owner --advertise` with a clear error pointing at
this ADR.
Rationale: owner-tier confers `/health` full payload visibility,
`/v0/management/*` mutating access, and `X-OLP-Fallback-Detail` header
visibility. Advertising owner-tier plaintext unauthenticated would let any
LAN caller assume the owner identity — the exact opposite of the design
intent.
### Trusted-LAN deployment invariant
`auth.advertise_anonymous_key: true` is permitted ONLY when the OLP server
is bound to a trust-equivalent address space:
| Tier | Address space | Permitted? |
|------|---------------|------------|
| Loopback | `127.0.0.0/8` | yes |
| RFC 1918 LAN | `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | yes |
| Tailnet | `100.64.0.0/10` (CGNAT range used by Tailscale) | yes |
| Localhost domains | `localhost`, `*.local`, `*.internal` | yes |
| Public internet | any routable IPv4/IPv6 outside the above | NO |
This is a **soft constraint** at v0.4.0 — OLP does not enforce IP-allowlist
or BIND_ADDRESS inspection. The constraint is documented here, surfaced in
README § "Anonymous-key advertise mode (trusted-LAN-only)", and warned-but-
not-blocked at server startup when `auth.advertise_anonymous_key=true` and
the bind address looks public.
Hard enforcement (refuse to start when bind is public + advertise enabled)
is deferred. The maintainer's deployments are LAN-only, the family-scale
audience cannot tolerate a startup-refuse mode that bricks the proxy on
ambiguous network topology (e.g., TLS-fronted private network where the
underlying bind IP IS public but the network itself is trusted), and the
trade-off in ADR 0010 explicitly accepted operator-discretion gates for
soft constraints of this class.
---
## Threat model
The advertised anonymous key is **public** within the boundary of "anyone
who can reach `GET /health`." Anyone within that boundary can read the
plaintext from `/health.anonymousKey` and use it for `/v1/chat/completions`,
`/v1/models`, etc.
| Deployment | Boundary | Acceptable? |
|------------|----------|-------------|
| Mac mini + Tailscale, only family devices on tailnet | family devices | YES |
| Home LAN with no guest WiFi, no port-forward | household + neighbors-within-WiFi-range | YES (within risk tolerance) |
| Home LAN with guest WiFi joined to same VLAN as proxy | EVERYONE who visits and connects to guest WiFi | borderline; treat with caution |
| Coffee shop / open WiFi | EVERYONE physically present | NO |
| Public internet via Cloudflare Tunnel / port-forward / VPS | EVERYONE on the internet | NO — instant compromise |
The capability gain for an attacker who reads `/health.anonymousKey` is
**equal to the capability the operator deliberately granted the
anonymous-tier key**:
- `providers_enabled` (when `'*'`, the attacker can dispatch any provider —
burning the operator's subscription quotas).
- `/v1/chat/completions` access (LLM use under the operator's billing).
- Cache pollution under `__anonymous__` namespace (per ADR 0007 § 7.1; the
advertised guest key uses its own `<key-id>` namespace — but anonymous
callers who DON'T present the key use `__anonymous__`).
What the attacker does NOT get:
- `/health` full payload — gated to `owner_tier === 'owner'` (ADR 0007 § 7.1).
- `/v0/management/*` mutating endpoints — gated to owner (ADR 0008 § 7).
- `X-OLP-Fallback-Detail` header — `'owner_only'` policy default (ADR 0007 § 7.2).
- The owner key's plaintext (which is never stored anywhere; only its
`token_hash` is on disk per ADR 0007 § 5).
The "burn the operator's subscription quotas" failure mode is bounded by
the per-provider quota limits AND the maintainer's monitoring (`/health`
owner-view shows quota status per provider; `/v0/management/audit` shows
per-key usage). Detection is fast; the question is how much quota the
attacker can burn between compromise and key revocation.
---
## `olp-connect` integration (D68 client-side)
`bin/olp-connect <ip>` queries `GET /health` as its first action. If the
response contains `anonymousKey: "olp_..."`, the script uses that value
silently for the rest of the run (printing a one-line `Using server-
advertised anonymous key: olp_...XXXX` notice + a pointer to this ADR).
This is what makes `olp-connect <ip>` a true zero-config command — no
out-of-band token paste needed.
If `anonymousKey` is absent (the default, when `auth.advertise_anonymous_key`
is false), the script falls back to interactive prompt or `--key` flag.
`olp-connect` does NOT perform any of the trusted-LAN soft-checks itself —
it trusts that an operator who set `auth.advertise_anonymous_key: true`
knows their deployment context. The script does, however, document the
trade-off in its `--help` output and prints the ADR 0011 reference
alongside the "using server-advertised key" notice.
---
## Deployment configurations (D76 amendment, 2026-05-26)
Original ADR 0011 referenced a `BIND_ADDRESS` concept that did not exist in the v0.4.0v0.4.2 codebase — the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`. D76 closes this gap by adding the `OLP_BIND` env var (default `127.0.0.1`), making the deployment-context discussion below operational rather than aspirational.
Three deployment configurations are supported:
| `OLP_BIND` value | Reachability | Anonymous-key publication |
|---|---|---|
| `127.0.0.1` (default) | Loopback only | Safe with any auth posture (no LAN exposure at all) |
| RFC1918 IP / tailnet IP / `0.0.0.0` on a trusted LAN | LAN clients only | Safe when `advertise_anonymous_key: true` — the documented "trusted-LAN" zero-config family onboarding flow |
| Public IP / `0.0.0.0` on a public-facing host | Public internet | **Incompatible with `advertise_anonymous_key: true`.** Operator MUST keep `advertise_anonymous_key: false` (default). |
The server emits a startup warn event `anonymous_key_advertised_with_lan_bind` when `OLP_BIND` is non-loopback AND `advertise_anonymous_key: true` (per the `lib/keys.mjs` + `server.mjs` checks). The warn is a **checkpoint, not a hard gate** — the server cannot tell from the bind address alone whether the operator is on a trusted LAN (RFC1918 / tailnet) or has accidentally exposed a public IP. The Re-evaluation trigger #1 below escalates to a hard gate when OLP gains a public-internet deployment mode.
`olp-connect <ip>` consumes `/health.anonymousKey` over the network — therefore requires `OLP_BIND` to include the LAN interface on the server side. Without setting `OLP_BIND=<lan-ip>` (or `0.0.0.0`), `olp-connect <ip>` will fail with `connect ECONNREFUSED` because the server only accepts loopback connections.
---
## Re-evaluation triggers
Re-open this ADR when ANY of the following fires:
1. OLP gains a "expose to public internet" deployment mode in the README
(e.g., Cloudflare Tunnel guidance, ngrok recipe). At that point the
soft-constraint MUST become a hard constraint (bind-address inspection
at startup, refusal to enable `advertise_anonymous_key` when bind is
public — likely with a separate `OLP_TRUSTED_PUBLIC_OVERRIDE=1` env
escape hatch for operators who run their own TLS termination).
2. The OCP `/health.anonymousKey` model is found to have caused a
real-world quota-burn incident; that learning amends this ADR.
3. Phase 5 introduces multi-tenant SaaS-like deployments (currently
non-goal per ADR 0001); the entire family-scale assumption is
re-examined.
---
## Consequences
**Positive.**
- Family-member onboarding becomes a single command: `olp-connect <ip>`. No
out-of-band token paste. No "wait, what's the API key?" friction loop.
- The trust trade-off is now an explicit, single-knob config decision, not
an implicit consequence of OCP-pattern inheritance.
- The `plaintext_advertise` field is a single auditable on-disk surface —
`grep plaintext_advertise ~/.olp/keys/*/manifest.json` answers "which key
is advertised?" definitively, and an operator who wants to disable the
feature can simply revoke that key.
- Owner-tier advertisement is impossible (both at keygen and at config
load), eliminating an entire class of foot-gun.
**Negative.**
- ADR 0007 § 5's "no plaintext on disk, ever" property is weakened to "no
plaintext on disk except for ONE explicitly-opted-in field on ONE key."
The exception is narrow and audit-grep-able but the property is no
longer absolute.
- Operators who enable advertise mode then move the deployment from LAN to
public internet (e.g., add a Cloudflare Tunnel without revisiting the
config) silently invert the threat model. The startup warn for "public
bind detected" does not currently fire (soft constraint per § "Trusted-
LAN deployment invariant" above).
- The OCP precedent shows operators sometimes share `olp-connect <ip>`
invocations in chat / docs that include their IP; an LLM training corpus
could harvest these IPs. The advertised key is only useful while the
network reaches the IP, but the IP-disclosure surface grows.
**Neutral.**
- The plaintext storage is per-key, not global. Revoking the advertised key
removes the plaintext exposure within one filesystem write (the manifest
stays on disk for audit attribution per ADR 0007 § 6.1, but `revoked_at`
becomes non-null and `findAdvertisedKey()` skips revoked manifests).
---
## Alternatives considered
1. **Store plaintext in `config.json` directly.** Rejected. Mixes secrets
with operational config; complicates git-crypt boundary; loses the
per-key revocation path (you'd have to edit JSON to "revoke" the
exposure rather than running `olp-keys revoke --id=<id>`).
2. **Add an `anonymous` owner_tier instead of using `guest` + `plaintext_
advertise`.** Rejected. Bumps ADR 0007 § 4 schema version (a
non-additive change), adds a third identity class that the rest of the
codebase (cache namespacing, /health gating, audit attribution) has no
reason to know about, and conflicts with ADR 0007 § 7.1's "anonymous =
no auth header + allow_anonymous=true" definition. A single optional
field on the manifest is strictly less invasive.
3. **Hard-enforce trusted-LAN bind address at startup.** Rejected for
v0.4.0; deferred until a public-deployment-mode README section ships
(see Re-evaluation triggers § 1). Soft constraint + startup warn is
appropriate while OLP has zero public-internet deployment recipes.
4. **Encrypt `plaintext_advertise` at rest with a key derived from
`OLP_HOME` path or a separate `OLP_ADVERTISE_KEY` env var.** Rejected.
The threat model is "anyone who can read `/health` reads the plaintext
token over the wire," not "anyone who can read `~/.olp/keys/`." Both
require LAN-reach; encrypting on-disk doesn't change the over-the-wire
exposure. Adds complexity for no security gain in the relevant attack
model.
5. **Make `--advertise` allowed only when `--name` is exactly `anonymous`.**
Rejected as over-restrictive. The CLI's `--anonymous` shorthand
defaults `--name=anonymous`, but operators may legitimately want a
named advertised key (e.g., `family-guest`, `lan-zero-config`). The
discriminator is the field, not the name.
---
## Authority citations
- **ADR 0007 § 7** (Identity-class table — anonymous tier definition;
`__anonymous__` keyId).
- **ADR 0007 § 5** (Token format — establishes hash-only on-disk; D69 is
the explicit opt-in exception).
- **ADR 0007 § 4** (Manifest schema — D69 adds optional `plaintext_advertise`
field; § 4 already specifies "unrecognized fields cause a warn but not a
reject (forward-compat)" so the addition is non-breaking for older
parsers).
- **ADR 0007 § 7.2** (Configuration — D69 adds `auth.advertise_anonymous_key`
alongside existing `allow_anonymous` / `owner_only_endpoints` /
`fallback_detail_header_policy`).
- **ADR 0010 § Phase 4 charter D68-D70 row** (scope authority for this ADR).
- **OCP `server.mjs:148, 1454, 1488, 1555`** (prior-art for the
`PROXY_ANONYMOUS_KEY` env + `/health.anonymousKey` pattern; OCP v3.13.0).
- **OCP issue #12 § 14 Path A** (the original anonymous-key decision
context for OCP; the "Path A" label is OCP-specific and not used in
OLP).
- **`bin/olp-connect`** (D68 client-side consumer of `/health.anonymousKey`).
- **`bin/olp-keys.mjs`** (D69 keygen `--advertise` flag implementation).
- **`lib/keys.mjs` `findAdvertisedKey()`** (D69 server-side resolver).
- **`server.mjs handleHealth`** (D69 emission point + startup-warn site).
---
## Procedural mechanism
CC 开发铁律 v1.6 § 10 (independent reviewer per implementation D-day) — D68
+ D69 + D70 ship as ONE PR per Iron Rule 11 IDR (the three deliverables
are mutually constituting: `olp-connect` consumes `/health.anonymousKey`,
`/health.anonymousKey` is governed by ADR 0011, ADR 0011 documents
`olp-connect`'s trust posture). The reviewer is a fresh-context opus
subagent.
@@ -0,0 +1,147 @@
# ADR 0012 — Phase 5 Charter: Provider Quota Probes + Dashboard Enrichment
**Status:** Accepted (Phase 5 open as of 2026-05-26)
**Date:** 2026-05-26
**D-day:** D79 (charter + ADR 0002 Amendment 8 + ADR 0013 land together as the constitutional layer of Phase 5)
## Amendments
### Amendment 1 — 2026-05-26: D84 Mistral probe NO-GO (post-D79-close spike)
The D-day table originally listed D84 as "optional, depends on D79-close 30-min Mistral docs spike". The spike completed 2026-05-26 with verdict **NO-GO** — Mistral does not expose a programmatic quota/usage endpoint **accessible to Vibe / Le Chat member / La Plateforme API keys** (the key tier OLP uses for spawning the `vibe` CLI):
- `docs.mistral.ai/api` (the public API spec) covers Chat, FIM, Embeddings, Classifiers, Files, Models, Batch, OCR, Audio, Events, Beta (Agents/Conversations/Libraries/Workflows/Observability). No usage/quota/credits/billing/limits endpoint accessible to a member API key.
- Direct probe `https://api.mistral.ai/v1/usage` returns 404.
- Mistral's "Limits and Usage" help article documents limit viewing via the `admin.mistral.ai/plateforme/limits` web console.
- No `x-ratelimit-*` response headers documented on `/v1/chat/completions`. (Third-party summaries mentioning these headers are unsourced — appears to be OpenAI-convention extrapolation.)
- OLP `lib/providers/mistral.mjs` already records this independently — DL-7 comment: "If quota/budget API surfaces in Le Chat Pro, pin the endpoint here."
**Out-of-scope but worth pinning for future revisit.** Mistral's [Admin API](https://docs.mistral.ai/admin/security-access/admin-api) DOES expose programmatic "Billing and usage queries", and the [Usage limits docs](https://docs.mistral.ai/admin/user-management-finops/usage-limits) describe usage/cost queries via that surface. The Admin API requires an **org-admin scoped API key** (separate from the member key OLP uses). For OLP's family-tier deployment posture (a maintainer's personal Le Chat Pro / La Plateforme account, not an organization's admin console), provisioning + storing an org-admin token raises the credential-scope ceiling beyond what the trusted-LAN deployment context (ADR 0011) was designed for. The NO-GO at v0.5.0 is therefore "out of scope for OLP's current deployment posture", NOT "Mistral has no programmatic surface". If the deployment posture expands to an org-admin context (e.g., a small-business multi-user deployment), this decision should be re-evaluated.
**Disposition:**
- D84 row dropped from D-day plan (struck through below).
- Mistral dashboard row in D82 UI shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates (request count, estimated cost from `estimateCost()`).
- `DL-7` in `mistral.mjs` is the documented re-entry point if Mistral ever publishes a usage endpoint.
- Phase 5 total D-day budget revised: ~5 D-days (down from ~6).
---
## Context
Phase 4 (ADR 0010) shipped OLP's operator + client UX layer — `bin/olp` operator CLI, `olp doctor` framework, `olp-connect` zero-config IDE wiring, OpenClaw `/olp` slash commands, anonymous-key deployment-context limits, SSE heartbeat. v0.4.4 is the current shipped state. Phase 4 closed every gap on the OCP-feature-parity matrix EXCEPT one: **live quota / plan-usage surfacing**.
Today `lib/providers/anthropic.mjs:445` has a stub `quotaStatus()` returning `null` (D4 placeholder). The OLP dashboard's quota panel renders "—" for all providers. OCP, in contrast, exposes a live "39% session / 30% weekly" panel — the maintainer uses this multiple times per day to decide when to throttle voluntary `claude -p` traffic away from interactive sessions. OLP cannot become an OCP successor in practice (vs. just feature-parity-on-paper) until quota surfacing works.
A pre-flight institutional-knowledge audit (2026-05-26 — see `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`) confirmed:
1. **The OCP probe still works today** — Anthropic returns the same `anthropic-ratelimit-unified-*` headers on every `POST /v1/messages` call. Tested live 2026-05-26 from PI231 OAuth credentials.
2. **Schema added 3 fields since OCP's 2026-04 capture**`5h-status`, `7d-status` (per-window status), `overage-reset` (only on active overage). No fields removed or renamed.
3. **Verification protocol has shifted** — Claude Code v2.1.x is now a **compiled binary** (Mach-O / ELF), not bundled JS. OCP's "grep cli.js" approach no longer applies; the replacement protocol is `strings` against the binary + periodic live probe diff.
4. **OAuth refresh path unchanged**`platform.claude.com/v1/oauth/token` + `9d1c250a-...` client_id + 60s-3600s exponential backoff.
The audit makes Phase 5 implementation low-risk: this is a port of a working OCP function, not a re-derivation. The work is mechanical + adapter-layer plumbing into OLP's plugin contract.
A parallel maintainer request (2026-05-26, with reference screenshot of claude.ai/settings/usage) asked for Claude.ai-style dashboard enrichment: per-row utilization bars, reset countdown, 1-minute auto-refresh, manual refresh button. P5-1 (probe) + P5-2 (dashboard) together unlock both: the data plus the surface. v1.x roadmap #8 is closed by P5-2.
---
## Decision
Phase 5 scope is **Provider quota probes + dashboard enrichment**. The phase opens 2026-05-26 with D79 (this charter + ADR 0002 Amendment 8 + ADR 0013 OAuth READ-ONLY consumption rules). Phase 5 close ships v0.5.0; per `CLAUDE.md release_kit.phase_rolling_mode`, the close PR is maintainer-triggered.
### In scope — Phase 5 D-day plan (~6 D-days)
| D-day | Deliverable | Authority | Estimate |
|---|---|---|---|
| **D79** | This charter ADR 0012 + ADR 0002 Amendment 8 (direct-API READ-ONLY) + ADR 0013 (OAuth READ-ONLY consumption rules + schema-drift mitigation) + `package.json` `current_pre_release_identifier``0.5.0-phase5` + `CLAUDE.md release_kit.phase_rolling_mode.current_phase` → Phase 5 | This charter + audit memory | 0.5d |
| **D80** | `lib/providers/anthropic.mjs:quotaStatus()` ported from OCP `server.mjs:842-1109` — full probe with macOS-keychain auth read added (existing OLP reader only handles env + `.credentials.json`) + 5min cache + 60s-3600s refresh backoff + stale-cache-on-429 + all 13 headers parsed (including new 5h-status / 7d-status / overage-reset) | Port OCP probe + ALIGNMENT.md Rule 2 exemption per ADR 0002 Amendment 8 + audit memory | 2d |
| **D81** | `lib/audit-query.mjs` + `/v0/management/dashboard-data` extended to surface the new quota shape per provider (utilization, reset, representative-claim, fallback-percentage, overage-status). Audit-query stays in-memory scan per ADR 0008 Lane 2 = A (no SQLite). Schema migration documented in ADR 0008 § Amendment | ADR 0008 + this charter | 1d |
| **D82** | `dashboard.html` Claude.ai-style restructure — per-provider rows replace the current single Quota panel; each row: provider badge, model placeholder, utilization bar (5h + 7d), reset countdown ("Your limit will reset at HH:MM AM/PM" format from the user-shared claude.ai screenshot), status badge, representative-claim hint. 1-minute auto-refresh via `setInterval` with `document.visibilityState` guard. Manual refresh button calls `/v0/management/dashboard-data` directly | v1.x roadmap #8 + maintainer reference screenshot | 1.5d |
| **D83** | Test coverage — Suite 38 quota-probe unit tests (mock HTTP server returning the 13 headers; assert parse + cache + backoff + stale-on-429); Suite 39 dashboard rendering smoke (curl `/dashboard` after pre-seeding mock quota cache; assert HTML contains expected utilization strings); update Suite 33 doctor checks for new `anthropic.quota_probe_reachable` check | Test convention from existing suites | 1d |
| ~~D84~~ **DROPPED** | ~~Mistral `quotaStatus()` port — depends on D79-close spike~~ **NO-GO per 2026-05-26 spike (see § Amendment 1).** Mistral dashboard row in D82 shows "spend tracking only" badge sourced from `audit-query.mjs` aggregates. `DL-7` hook point in `mistral.mjs` already marks the location for future upgrade if Mistral ever publishes a usage endpoint. Codex permanently skipped (no public API). | n/a (dropped) | 0d |
| **close** | v0.5.0 release PR — `package.json` `0.4.4 → 0.5.0`, CHANGELOG promotion, `release_kit.phase_rolling_mode.current_pre_release_identifier` advance to Phase 6 token | `CLAUDE.md release_kit overlay` | maintainer-triggered |
### Out of Phase 5 scope (with explicit triggers)
#### `X-OLP-Cost-USD` per-request response header
**Status:** Deferred to Phase 6. Was listed in ADR 0010 § Out-of-scope as "Phase 5 prerequisite". The prerequisite (provider-cost weights table) is non-trivial — needs per-(provider, model) `input_cost_per_1k_tokens` / `output_cost_per_1k_tokens` / `cache_read_discount` data sourced from each provider's published pricing page. Phase 5 already pulls in two new ADRs; adding a third data-onboarding ADR is scope creep.
**Re-open condition.** Phase 6 unless a maintainer reports a cost-attribution debugging need that warrants pulling forward.
#### `context_window_exceeded` fallback trigger (LiteLLM prior-art)
**Status:** Deferred. ADR 0010 listed this as opportunistic-in-Phase-5 unless the trigger fires sooner. The trigger has not fired in Phase 4 production traffic. Continue to defer.
#### per-(provider, model) live stats Map (replacing audit-query scan)
**Status:** Deferred. Current scan latency is ~20ms at 7-day depth. Acceptable until volume grows (>100k requests/day). Re-evaluate at Phase 6 if dashboard latency degrades.
#### Anthropic interactive-mode P0 (ADR 0009)
**Status:** Still trigger-gated on Anthropic's 2026-06-15 billing-split rollout. Phase 5 does NOT depend on P0 — the quota probe reads `anthropic-ratelimit-unified-*` headers regardless of which billing pool the spawn path consumes. If P0 succeeds Phase 7+ Phase 5's probe code remains unchanged; if P0 fails Phase 5's probe code remains unchanged. The probe is billing-pool-agnostic because the headers are subscription-pool metadata, not Agent-SDK-Credit metadata.
#### `/v1/messages` Anthropic-shape entry surface
**Status:** Still deferred per ADR 0010 § Out-of-scope. No change in Phase 5.
#### v1.x roadmap #3 / #5 / #6
**Status:** Still trigger-gated per `docs/v1x-roadmap.md`. None has fired. Continue to defer.
### Opportunistic Phase 5 micro-additions (not blocking)
Items small enough to land alongside a planned D-day without scope creep, if encountered:
- README § Dashboard screenshot update (post-P5-2 enrichment) — capture from MacBook test path per `~/.cc-rules/memory/feedback/mac_mini_never_for_testing.md`.
- `olp usage` CLI subcommand (bin/olp.mjs) surfaces the parsed quota shape in terminal form. Already partially exists (cmdUsage in bin/olp.mjs); confirm payload alignment after D80.
- Add `claude_code_oauth_client_id` config override in `~/.olp/config.json` so power users can override the hardcoded `9d1c250a-...` UUID without env-var fiddling. Mirrors compiled binary's `CLAUDE_CODE_OAUTH_CLIENT_ID` env support.
- `docs/provider-audits/anthropic.md` re-capture with current `claude --version` (v2.1.142 MacBook / v2.1.150 PI231) + binary distribution layout note.
### Exit gate — v0.5.0 close criteria
1. D79 — D84 all merged with fresh-context opus reviewer APPROVE per Iron Rule 10.
2. CI green on every D-day merge commit and on the v0.5.0 release commit head. `alignment.yml` blacklist re-confirmed (no new hallucinated tokens introduced).
3. README § Quota / Plan Usage section present with screenshot of the enriched dashboard. README § Supported Providers table updated to note "quota probe: anthropic ✅, mistral ⚠️/✅ (D84 outcome), codex ❌ (no public API)".
4. ADR 0012 (this charter) + ADR 0002 Amendment 8 + ADR 0013 (OAuth READ-ONLY consumption) on disk.
5. `CHANGELOG.md "Unreleased"` promoted to `"## v0.5.0 — <date>"` with D79 — D84 entries.
6. `package.json` bumped to `0.5.0`.
7. `CLAUDE.md release_kit.phase_rolling_mode.current_phase` advances `Phase 5 → Phase 6`; `current_pre_release_identifier` advances `0.5.0-phase5 → 0.6.0-phase6`.
8. Standing autopilot grant covers D-day-by-D-day execution; v0.5.0 close PR is maintainer-triggered.
9. Live MacBook E2E verification — dashboard renders enriched panel with real quota data (probe live, not mocked).
---
## Consequences
**Positive.**
- OLP finally has the load-bearing observability OCP had — maintainer can see live "39% session / 30% weekly" and decide whether voluntary `claude -p` traffic stays or moves.
- Family members on the LAN see real reset times instead of "—", which makes the "wait 2 hours" guidance concrete vs. abstract.
- The institutional-knowledge audit captured the schema in a memory file pinned with date stamps — future ports (mistral, future provider) re-use the verification protocol without re-deriving.
- v1.x roadmap #8 (Dashboard enrichment per Claude.ai-style usage page) closes inside Phase 5 rather than waiting for a separate phase.
- Compiled-binary-distribution awareness ("no more cli.js to grep") is now codified in OLP governance; the next time Anthropic ships a major CC version, the verification protocol is already written.
**Negative.**
- ADR 0002 gains another amendment (Amendment 8). The constitution surface area for `anthropic.mjs` grows. Counter-pressure: the alternative (probe lives in `server.mjs`, like OCP) violates the plugin-architecture principle that per-provider knowledge stays in `lib/providers/`. Amendment 8 is the smaller violation.
- The probe makes one `/v1/messages` call per 5min cache miss. That's ~12 calls/hour worst case across the whole proxy (probe is per-credentials, not per-key). With `max_tokens: 1` the cost is < $0.01/day at family-scale traffic. Negligible but not zero.
- Schema-drift risk over the long horizon. Anthropic could rename or remove headers in a future version. The mitigation protocol (strings + live probe diff) is in place, but it's a manual check — needs to be invoked by the maintainer or scheduled.
- Dashboard refactor introduces a breaking-change risk for the existing dashboard.html consumers (none today, but conceptually). Bumping to v0.5.0 signals this clearly.
**Neutral.**
- Phase 5 has more ADR work than Phase 4 (3 governance docs vs. 2). The constitutional layer is deliberately heavier because direct-API access is the single biggest authority decision since the plugin contract itself.
---
## Authority + cross-references
- **Iron Rule 11 (IDR)** — Phase 5 ships across 6 D-days, each a minimum reviewable unit. The governance trio (this ADR + Amendment 8 + ADR 0013) lands at D79 as a single coupled commit (reviewing them separately cannot verify consumer-producer alignment), per ADR 0002 Amendment 7's precedent.
- **Iron Rule 12 (prior-art search)** — discharged via the audit memory at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. Memory committed prior to D80 implementation.
- **ALIGNMENT.md Rule 1 (citation)** — D80 commit must cite compiled-binary `strings` evidence per audit memory § Path A (Claude Code v2.1.x has no traditional `§ section` structure because it is a Mach-O / ELF compiled binary) plus the audit memory file path. Live-probe transcript MUST be included in the commit body.
- **ALIGNMENT.md Rule 2 (provider-CLI-as-authority)** — direct-API access bypasses the spawn-binary contract. Amendment 8 is the explicit exemption. Without Amendment 8, the D80 commit is unalignable.
- **ALIGNMENT.md Rule 5 (CI alignment.yml)** — must continue to pass. `api.anthropic.com/v1/messages` is NOT on the blacklist (correct — that's the real endpoint). The hallucinated `/api/oauth/usage` IS on the blacklist (transitive from OCP) and must remain.
- **ADR 0002 Amendment 8** — companion ADR. Direct-API access scoping; READ-ONLY constraint; opt-in via config flag (default off).
- **ADR 0013** — companion ADR. OAuth credentials shared between spawn path + probe path; refresh backoff; schema-drift mitigation protocol.
- **CLAUDE.md release_kit** — Phase boundary triggers maintainer-led version bump. D-day commits within Phase 5 stay under "Unreleased". `0.5.0-phase5` is the pre-release identifier during the phase.
@@ -0,0 +1,197 @@
# ADR 0013 — OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol
**Status:** Accepted (2026-05-26)
**Date:** 2026-05-26
**D-day:** D79 (lands alongside ADR 0012 Phase 5 charter + ADR 0002 Amendment 8 as the constitutional trio of Phase 5)
---
## Context
ADR 0002 Amendment 8 permits `quotaStatus()` to call provider HTTP APIs directly, subject to a READ-ONLY constraint. That Amendment opens the door but does not specify HOW READ-ONLY discipline is preserved across credential lifecycle events (refresh, expiry, revocation), nor how OLP detects when the upstream API schema drifts. ADR 0013 fills both gaps.
The motivating concern: a provider that ships its CLI as a **compiled native binary** (Anthropic Claude Code v2.1.x is now Mach-O on macOS, ELF on Linux) closes off the previous schema-verification path (grep `cli.js`). If OLP's probe parser silently breaks because a header was renamed, the dashboard shows stale or wrong numbers, and the maintainer's load-bearing throttling decision is based on bad data. This ADR establishes the verification protocol that survives the binary-distribution shift.
A second motivating concern: the OAuth credentials used by the probe are the SAME credentials the spawn path uses for `claude -p`. Both paths consume them; the probe must not interfere with the spawn path's ability to refresh or invalidate them. Concretely: the probe must not write to the credentials artifact, must not race the spawn path on refresh, and must not amplify a 429 into a refresh storm.
---
## Decision
### Rule 1 — Credential reuse is mandatory
The probe MUST consume the same OAuth artifact the spawn path reads via the plugin's `readAuthArtifact()`. No new OAuth grant. No alternate credential store. No environment-variable-only fallback (env var `CLAUDE_CODE_OAUTH_TOKEN` is supported as an override consistent with the spawn path, but is not the probe's primary source).
Precedence order (mirrors OCP `getOAuthCredentials` 2026-04-stable):
1. `process.env.CLAUDE_CODE_OAUTH_TOKEN` if non-empty (manual override; common in CI / dev / one-off debugging).
2. `~/.claude/.credentials.json``claudeAiOauth.accessToken` (Linux + macOS without keychain access).
3. macOS Keychain: `security find-generic-password -a "${USER}" -s "Claude Code-credentials" -w` (preferred on macOS — current `lib/providers/anthropic.mjs` only covers (1) + (2); D80 adds (3)).
Rationale: a separate OAuth grant would require the maintainer to repeat `claude setup-token` against an OLP-specific scope, doubling credential exposure and divergence risk. Reusing the spawn path's credentials guarantees the probe never has more permission than the spawn path itself.
### Rule 2 — READ-ONLY at the wire
The probe MUST issue exactly one HTTP request per cache miss. Method MAY be POST (Anthropic's ratelimit headers come back on `POST /v1/messages`; this is the only way to read them). Request body MUST minimise side effects:
- `max_tokens: 1` (cost: ~$0.000001 per probe)
- `messages: [{role: "user", content: "hi"}]` (any minimal valid payload)
- Model: cheapest available in the plan (`claude-haiku-4-5` at v0.5.0)
- Do NOT include `system` prompts, `tools[]`, `tool_choice`, large content arrays, or anything that the upstream might bill differently.
The probe MUST discard the response body. Only response headers are parsed.
The probe MUST NOT call any other HTTP path on the provider's API. No `/v1/models` enumeration, no admin endpoints, no `/v1/messages/<id>` retrievals. The only permitted endpoint is `POST /v1/messages`.
### Rule 3 — Cache TTL and refresh discipline
- Cache TTL: 5 minutes. Cache miss triggers a real probe. Cache hit returns the cached value.
- The dashboard refreshes every 1 minute; that's served from the cache between probes. A manual refresh button MAY force-clear the cache (per maintainer request 2026-05-26); ADR 0012 D82 documents the button.
- On refresh failure (token expired, 401/403/429, network error), the probe schedules an exponential backoff: minimum 60s, maximum 3600s. The cache entry is NOT invalidated during backoff; `quotaStatus()` returns the stale cache marked `{ stale: true, last_fresh_at: <epoch> }`. If no stale entry exists, returns an `unreachable` shape (v0.5.1+) rather than `null`.
- Successive successful probes reset the backoff to the minimum.
- Token refresh (`POST https://platform.claude.com/v1/oauth/token`) follows the same backoff discipline. The probe MUST NOT refresh a token more than once per backoff window. The refresh path is shared with the spawn path; both observe the same backoff.
- **All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly.** `_probeOnce()` is an internal implementation detail. Routing doctor checks through `quotaStatus()` ensures the cache+backoff discipline is enforced for every caller — including operators running `olp doctor` in a debug loop. (Clarification added v0.5.1 to address codex finding F1: the original doctor check bypassed backoff by calling `_probeOnce` directly.)
### Rule 4 — Opt-in via config
A new config field at `~/.olp/config.json` controls per-provider opt-in:
```json
{
"providers": {
"anthropic": {
"enabled": true,
"quota_probe_enabled": false
}
}
}
```
Default: `false`. The maintainer must explicitly opt in after credentials are configured. Reasoning: a fresh install on a machine without OAuth credentials should not bombard `api.anthropic.com` with 401-bound probes.
`olp doctor` adds a per-provider check `<provider>.quota_probe_reachable` (only runs if `quota_probe_enabled: true`). Failed check provides a `next_action.ai_executable[]` recipe to either re-authenticate or disable the probe.
### Rule 5 — Schema-drift mitigation protocol (minimum-viable-schema gate)
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol for proactive monitoring, AND enforces a minimum-viable-schema gate at parse time:
**Minimum-viable-schema gate (v0.5.1+).** `_probeOnce()` requires at least these 4 fields present (non-null after parse) before treating a response as successful:
- `anthropic-ratelimit-unified-5h-utilization`
- `anthropic-ratelimit-unified-5h-reset`
- `anthropic-ratelimit-unified-7d-utilization`
- `anthropic-ratelimit-unified-7d-reset`
If any of these 4 is absent, `_probeOnce()` classifies the probe as a schema-drift failure (`failureKind = 'schema_drift'`), schedules backoff, and returns `null`. This means a 200 OK with zero `anthropic-ratelimit-*` headers (e.g. a server-side change, a proxy stripping headers, or a mock returning `{}`) is immediately caught as drift rather than silently cached as "live" data. The other 9 fields are tolerated as absent (overage fields are conditional; top-level status fields may be absent on edge cases). The 5h/7d core 4 are load-bearing — the dashboard's progress bars depend on them. (Gate added v0.5.1 to address codex finding F2.)
The CC binary-distribution shift means OCP's "grep cli.js" verification is no longer applicable. OLP adopts a two-path protocol:
**Path A — Compiled-binary string extraction.** Run `strings` over the platform-specific binary in the claude-code distribution. Captures all hardcoded header names the binary expects:
```bash
BIN_DIR=$(npm root -g)/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-*
strings "$BIN_DIR/claude" | grep -iE "anthropic-ratelimit|/v1/(messages|oauth)|platform\.claude\.com"
```
**Path A prerequisites.** GNU or BSD `strings` (part of binutils/coreutils on Linux + macOS — always present on a normal developer machine; Windows requires WSL or `binutils-mingw`). A locally installed Claude Code v2.1.x (npm-global or volta-managed). A reviewer without `claude` installed can still run Path B but Path A is gated on having the binary on disk. A future Claude Code version that ships as a different distribution shape (e.g. Rust binary, statically linked Go) keeps the protocol valid: `strings` works on any ELF/Mach-O regardless of compile source.
**Path B — Live API probe.** Run the actual probe against `api.anthropic.com` with valid OAuth credentials. Captures what the server returns today:
```bash
curl -s -i -m 10 -X POST https://api.anthropic.com/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-beta: oauth-2025-04-20" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model":"claude-haiku-4-5","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \
| grep -iE "^anthropic-ratelimit"
```
Path A tells you what the client expects. Path B tells you what the server actually emits. The diff is the actionable schema delta.
**Required cadence.** The diff MUST be re-run at every major `claude --version` bump (v2.x → v3.x is the next trigger). The current pinned schema lives at `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`. After re-verification, that memory file MUST be updated (or a successor file written with a new date stamp; the old one cross-linked).
**Trigger for re-running the diff.** There is no automated detector for a major `claude --version` bump at v0.5.0. Three explicit hooks share this responsibility:
1. **Annual Alignment Audit** (`ALIGNMENT.md` § Annual Alignment Audit, every 14 May) — diff is mandatory as part of the audit checklist.
2. **`olp doctor anthropic.quota_probe_reachable` failure** — if the probe returns non-2xx for any reason other than 401/403/429/network (typical schema breaks manifest as 422 or 400), `olp doctor` surfaces a `kind: fix_provider` recipe whose first step is "re-run the Rule 5 dual-path diff".
3. **Manual maintainer attention at a major Claude Code release** — if the maintainer sees a major version bump in `claude --version`, kick off the diff before the next Phase opens. Rolling-mode discipline (CLAUDE.md release_kit) means major-version bumps usually intersect with Phase boundaries.
If the diff is missed across a major version bump, the failure mode is graceful degradation: the parser silently drops unknown headers; the dashboard shows older values (cached stale) or `null` per Rule 3; `olp doctor` surfaces the staleness.
**Required action on drift detection.** If a header is renamed or removed:
1. File a Phase-N issue tagging the maintainer.
2. Update the parser in `lib/providers/anthropic.mjs:quotaStatus()` to handle both names (graceful migration), prefer the new name.
3. Update the audit memory file with a "drift event" section recording: date, old field, new field, evidence URLs.
4. Bump the `models-registry.json` `quota_probe.schema_version` (NEW field added at D80) so downstream consumers can detect.
If a new header appears in the live response that the parser doesn't read: low-priority enhancement; add to the parser, document in the audit memory, no schema_version bump required.
### Rule 6 — Failure transparency
The probe's failure modes are visible to the operator:
- `/v0/management/dashboard-data` includes per-provider `{ quota_probe: { status: 'ok' | 'stale' | 'failed' | 'disabled', last_fresh_at, last_error?, backoff_until? } }`.
- `olp doctor` surfaces probe failure as `kind: fix_oauth` (if 401/403) or `kind: fix_provider` (if 429 with no stale cache or network error).
- The dashboard row badge shows the status; clicking a failed row shows the last error (truncated to 200 chars, no full credential traces).
### Rule 7 — Out-of-scope
This ADR does NOT govern:
- Spawn-path OAuth refresh (the spawn path's refresh logic predates this ADR and is governed by the underlying CLI). The probe shares the credential artifact but does not own the refresh.
- Anthropic-specific bearer revocation (Anthropic side). Revocation manifests as 401 to the probe, which falls into Rule 6.
- Non-Anthropic provider OAuth flows. Mistral / future providers MAY adopt this protocol via plugin-specific ADRs; ADR 0013 establishes the template.
---
## Consequences
**Positive.**
- The probe is bounded — Rule 2 caps the wire traffic, Rule 3 caps the refresh rate, Rule 4 caps activation surface.
- Schema-drift detection is procedural and reproducible — Rule 5 gives the maintainer a runbook that doesn't depend on Anthropic publishing a deprecation notice.
- Failure is visible — Rule 6 means a broken probe shows up in `olp doctor` and the dashboard, not as a silent "—" in the quota row.
- Credential reuse (Rule 1) keeps the security surface area minimal.
**Negative.**
- The `quota_probe_enabled` opt-in adds a configuration step. Mitigated by `olp doctor` surfacing the recipe when credentials are present but the probe is off.
- The schema-drift protocol is manual. Anthropic could ship a v3.x binary tomorrow and the verification only happens when the maintainer or a doctor probe failure prompts it. Counter-pressure: drift events at OCP scale (~12 months) suggest manual verification on major version bumps is sufficient.
- Stale-cache-on-failure (Rule 3) means the dashboard could show 30-minute-old data without an obvious "stale" indicator unless the UI explicitly renders the `stale: true` marker. ADR 0012 D82 requires the dashboard to surface staleness; reviewing that during P5-2 implementation.
**Neutral.**
- The protocol is portable. Future provider plugins adopting direct-API probes (mistral if its `/v1/usage` exists) can reuse the same six rules with provider-specific endpoint substitution.
---
## Alternatives considered
### A — Probe lives in `server.mjs` (OCP-style)
OCP's probe is in `server.mjs:842-1109` because OCP is single-provider and pre-plugin-architecture. Porting that pattern to OLP would violate ADR 0002 (per-provider knowledge stays in `lib/providers/`). Rejected.
### B — Spawn `claude -p --dry-run` and parse ratelimit headers
`claude -p` does not expose response headers; the CLI consumes and discards them. Even if it did, parsing CLI stdout is fragile. Rejected.
### C — Wait for Anthropic to publish a public quota API
The 2026-06-15 Agent SDK Credit billing-split announcement does not include a public quota API. Anthropic may publish one in the future; this ADR is forward-compatible (Rule 7 explicitly notes "if Anthropic publishes a public ratelimit API, this entire workaround becomes obsolete — re-evaluate"). Rejected for v0.5.0 (no ETA).
### D — Mandate token-rotation in OLP
Tempting (auditability), but OCP's experience shows token rotation breaks the spawn path more often than it improves security at family-scale deployment. The credential rotation cadence is Anthropic-side (token TTL); OLP respects whatever Claude Code does. Rejected.
---
## Authority + cross-references
- **ADR 0002 Amendment 8** — the contract-level permission. ADR 0013 is the implementation discipline for that permission.
- **ADR 0012** — Phase 5 charter that schedules D80 implementation.
- **ADR 0011** — anonymous-key deployment-context (LAN-only). Separate scope; this ADR does not amend it.
- **`~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md`** — the live schema pin. Updated on every drift event per Rule 5.
- **`alignment.yml`** — must continue to blacklist `/api/oauth/usage` and related hallucinated tokens. Must NOT add `/v1/messages` to the blacklist (legitimate endpoint).
- **OCP `server.mjs:842-1109`** — the source-of-truth port reference for D80.
- **OCP `ALIGNMENT.md`** — the institutional precedent (2026-04-11 drift → ALIGNMENT introduction) this ADR consolidates for OLP.
@@ -0,0 +1,291 @@
# ADR 0014 — Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
**Status:** Accepted (PR-A — deps + doctor + ADR only; PR-B/C/D pending)
**Date:** 2026-05-28
**Phase:** Phase 7
---
## Related
- **ADR 0001** (Project Founding) — OLP's multi-provider rationale and "no conversation state" principle.
- **ADR 0009 Amendment 1** (stream-json transport, Phase 6) § Caveats #3: "Sandbox-runtime still required for real multi-tenant deployment."
- **ADR 0002** (Plugin Architecture) — Provider contract; `spawn()` is the surface this ADR will wrap in PR-B/C.
- **ADR 0006** (Provider Inclusion / Risk Tier Framework) — classifies providers by deployment risk; sandbox status is a gating condition for Tier-A (cloud-deployed).
- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud rollout.
- **cc-mem incident memory**`~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` — the multi-tenant security gap that motivates this ADR.
---
## 1. Context
### 1.1 The multi-tenant security gap
OLP is a personal-scale proxy (ADR 0001 § Non-commercial). However, the "family-scale" deployment model means multiple human callers share a single OLP instance — each with their own OLP API key (ADR 0007) but all using the same underlying `claude` or `codex` CLI installation on the server host.
The security gap, identified in the 2026-05-27 session and captured in cc-mem incident memory § 3, is:
1. **OAuth token exposure.** A malicious (or misbehaving) prompt to the Anthropic provider could elicit a `cat ~/.olp/keys/...` or similar read of any file the OLP process user can access — including the OAuth credentials file that allows the attacker to impersonate the server-side identity.
2. **Codex shell-tool execution.** The `codex exec` path exposes a shell tool to the model. With OLP acting as a relay, a prompt to codex from one client could execute arbitrary commands in the server process's working directory, reading or writing files belonging to other clients.
3. **Cross-tenant data leakage.** Even without adversarial prompts, a model that freely accesses the filesystem could inadvertently leak one client's cached context to another client's response.
The 2026-05-27 prior-art search (incident memory § 4) surveyed the multi-tenant LLM proxy ecosystem (LiteLLM, OpenCode, CLIProxyAPI, open-source Anthropic proxies) and found that **none solve multi-tenant file-system and tool isolation at the OS level**. The field's typical answer is "don't run multi-tenant" or "use a separate VM per tenant" — neither applicable at OLP's family scale.
### 1.2 Anthropic's official answer: `@anthropic-ai/sandbox-runtime`
The `@anthropic-ai/sandbox-runtime` package (Anthropic Experimental org, `anthropic-experimental/sandbox-runtime`, v0.0.52 as of this ADR) is Anthropic's open-source solution to wrapping security boundaries around arbitrary processes. It is the library that Claude Code itself uses internally to sandbox MCP servers and tool execution.
The library provides:
- **Linux:** bubblewrap (`bwrap`) namespace isolation + socat network bridge + seccomp filter via `apply-seccomp-filter` binary. Ripgrep (`rg`) is required for deny-path glob expansion.
- **macOS:** `sandbox-exec` seatbelt profile, which is a built-in OS facility (no additional packages required).
Both paths enforce filesystem read/write restrictions and network policy at the kernel level, not at the process level. A `cat ~/.olp/keys/...` inside the sandbox fails at the syscall layer regardless of what the shell or model requests.
### 1.3 The 2026-05-28 spike
A PoC spike was conducted on PI231 (arm64 Debian Bookworm) on 2026-05-28. Key findings:
1. **`npm install @anthropic-ai/sandbox-runtime@0.0.52` succeeds cleanly** on arm64 Linux. No native build step; prebuilt binaries were available.
2. **`SandboxManager.isSupportedPlatform()` returns `true`** on PI231 (Linux, not WSL).
3. **`SandboxManager.checkDependencies()` reports errors**: `bubblewrap (bwrap) not installed`, `socat not installed`, `ripgrep (rg) not found`. These are the three OS-level deps that must be installed separately (not bundled in the npm package).
4. **The install fix is a one-liner**: `sudo apt-get install -y bubblewrap socat ripgrep`. This is a 5-minute operational task, not a code change.
5. **Three PoC scripts** were parked at `/tmp/sandbox-spike/` on PI231 verifying: dependency check return shapes, `SandboxManager.wrapWithSandbox` call signature, and filesystem-deny path behaviour.
Verdict: **YELLOW** — architecturally green (the library works and the platform is supported), operationally blocked on apt deps. PR-A lays the dependency + doctor layer. PR-B wraps the anthropic spawn after apt install.
---
## 2. Decision
### 2.1 Layered rollout (Iron Rule 11 — minimum reviewable unit)
The sandbox integration is split into four discrete PRs, each independently reviewable and independently safe to land or revert:
| PR | Scope | Blocking condition | Status |
|---|---|---|---|
| **PR-A** (this PR) | npm dep `@anthropic-ai/sandbox-runtime ^0.0.52` + `lib/sandbox/doctor.mjs` (preflight module) + `/health` `sandbox` field + ADR 0014 | None — no runtime initialization | ✅ Accepted |
| **PR-B** | `lib/sandbox/manager.mjs` (bootstrap + spawn-wrap) + `lib/providers/anthropic.mjs` spawn wrapped + server startup wiring + `/health.sandbox.active` + Suite 43/44 tests | `bubblewrap` + `socat` + `rg` installed on PI231 (`sudo apt-get install -y bubblewrap socat ripgrep`) | ✅ Implemented — pending PI231 validation (Suite 44) + opus reviewer |
| **PR-C** | `lib/providers/codex.mjs` spawn wrapped with `enableWeakerNestedSandbox: true` | PR-B accepted + codex PoC on PI231 | 🔲 Blocked on PR-B |
| **PR-D** | `docs/plans/cloud-deployment-family.md` § "Phase 7 prerequisite met" update; cloud rollout unblocked | PR-B + PR-C accepted | 🔲 Blocked on PR-C |
Rationale for the split:
- **PR-A is safe without bwrap.** The doctor module and `/health` field add observability with no runtime side effects. No `SandboxManager.initialize()` call. No sandbox spawned.
- **PR-B is the load-bearing security gate.** Wrapping `anthropic.mjs` spawn requires empirical negative-test confirmation (in-sandbox `cat ~/.olp/keys/...` MUST fail). This cannot be verified until PI231 has bwrap installed.
- **PR-C follows PR-B** because codex has a distinct issue: codex itself uses bubblewrap internally (`codex exec` spawns its own sandbox). `enableWeakerNestedSandbox: true` is required to allow the inner sandbox to function inside the outer OLP sandbox.
- **PR-D is documentation-only** and depends on the runtime PRs being proven in production.
### 2.2 PR-A specific scope (binding)
PR-A MUST NOT include:
- Any call to `SandboxManager.initialize()` (no real sandbox created)
- Any modification to `lib/providers/anthropic.mjs`, `lib/providers/codex.mjs`, or `lib/providers/mistral.mjs`
- Any new HTTP endpoint (no `/metrics`, no new dashboard endpoint)
- Any modification to `models-registry.json`
PR-A MUST include:
- `package.json` dependency: `"@anthropic-ai/sandbox-runtime": "^0.0.52"`
- `lib/sandbox/doctor.mjs`: pure preflight module (no state; no initialization)
- `/health` response: top-level `sandbox` field (`available`, `missing`, `platform`, `message` when unavailable)
- `docs/adr/0014-sandbox-runtime-integration.md` (this document)
- `CHANGELOG.md` Unreleased entry
- `test-features.mjs` Suite 42 (8 new tests, all passing)
---
## 3. `lib/sandbox/doctor.mjs` design
### 3.1 Exports
```javascript
// Returns { available: boolean, missing: string[], details: { ... } }
export async function checkSandboxAvailability() { ... }
// Returns { ok: boolean, message: string } — human-readable summary
export async function describeSandboxStatus() { ... }
```
### 3.2 `checkSandboxAvailability` algorithm
1. Probe OS deps independently via `child_process.execFileSync('which', [binary])`:
- `bwrap` (Linux only — macOS uses built-in `sandbox-exec`)
- `socat` (Linux only)
- `rg` (ripgrep — Linux only; macOS seatbelt profiles use regex patterns natively)
2. Call `probeLibrary()` which `import()`s `@anthropic-ai/sandbox-runtime` and calls:
- `SandboxManager.isSupportedPlatform()` — platform classification
- `SandboxManager.checkDependencies(undefined)` — library's own dep check (called without initialize, falling back to PATH lookup)
3. Compute `missing[]`: on Linux, add 'bubblewrap', 'socat', 'ripgrep' for each absent dep; if library import failed, add that too.
4. `available = libLoaded && isSupportedPlatform && missing.length === 0`
`probeLibrary()` wraps everything in try/catch — any library-side error becomes `{ libLoaded: false, libError: '<reason>' }` rather than an unhandled rejection.
### 3.3 `/health` integration
The `sandbox` field is added to the full (owner-tier) payload only. For trimmed payloads (guest/anonymous per ADR 0007 § 7.1), the field is absent (consistent with the existing trim model). This prevents leaking infrastructure details to non-owner callers.
The result is memoized process-wide via `_sandboxStatusCache` in `server.mjs`. The install state of bwrap/socat cannot change at runtime without a process restart, so a single lazy fetch at the first `/health` call is correct.
```json
{
"ok": true,
"version": "0.5.1",
"providers": { ... },
"sandbox": {
"available": false,
"missing": ["bubblewrap", "socat", "ripgrep"],
"platform": "linux",
"message": "Sandbox dependencies not available: bubblewrap not installed, socat not installed, ripgrep not installed. Install: sudo apt-get install -y bubblewrap socat ripgrep"
}
}
```
When available (after apt install + process restart) and PR-B bootstrapped:
```json
{
"sandbox": {
"available": true,
"active": true,
"missing": [],
"platform": "linux"
}
}
```
(PR-A shape did not include `active`. PR-B adds `active: boolean` — distinguishes
"deps present" from "sandbox actually initialized and wrapping spawns".)
---
## 4. PR-B/C/D acceptance criteria
### 4.1 PR-B (anthropic.mjs spawn wrap) — ✅ Implementation shipped, PI231 validation pending
**PR-B implementation (commit pending reviewer):**
- `lib/sandbox/manager.mjs`: singleton bootstrap + transparent `wrapSpawn()` API
- `lib/providers/anthropic.mjs`: spawn site wrapped via `wrapSpawn()` (ADR 0009 Amendment 1 spawn args unchanged)
- `server.mjs`: `bootstrapSandbox()` called before `server.listen()`, `/health.sandbox.active` field added
- `test-features.mjs` Suite 43 (8 tests, all pass on macOS) + Suite 44 (2 tests, PI231-gated with `OLP_E2E_SANDBOX=1`)
- 805 → 813 tests. Suite 44 skipped by default; runs on PI231 after apt install.
**Load-bearing negative test (required for PR-B to merge):**
```bash
# On PI231, with bwrap+socat installed, with PR-B wired:
olp-keys list # identify owner key
curl -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer <owner-key>" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"run: cat /home/<user>/.olp/keys/owner-key.json"}]}'
# Expected: response MUST NOT contain any content from the keys file.
# The model must either say it cannot access the filesystem, or produce an
# error. Any response containing the file content is a PR-B blocking failure.
```
Additional criteria:
- `SandboxManager.initialize()` is called once at startup (per ADR 0014 § 5 singleton decision, TBD in PR-B ADR amendment)
- p95 latency overhead of wrapping ≤ 200ms measured over 50 warm requests
- `checkSandboxAvailability().available === true` reported in `/health.sandbox` after PR-B rolls out
- All existing Suite 41 tests continue to pass (stream-json transport unaffected)
### 4.2 PR-C (codex.mjs wrap)
- `enableWeakerNestedSandbox: true` is set in the `SandboxManager.initialize()` call (or per-spawn config if the API allows per-spawn override — verify against v0.0.52 API)
- `codex exec` inner bubblewrap nest still functions: a sandboxed codex invocation that reads from an allowed path succeeds
- Analogous negative test: in-sandbox `cat /home/<user>/.olp/keys/...` MUST fail
### 4.3 PR-D (cloud deployment plan update)
- `docs/plans/cloud-deployment-family.md` § 5 "Phase 7 prerequisite" section updated: "sandbox-runtime integration (PR-B + PR-C) confirmed operational on PI231; prerequisite met"
- `README.md` § "Supported Providers" or § "Security" updated with a note about sandbox isolation
- Phase 7 close PR per `CLAUDE.md release_kit.phase_rolling_mode`
---
## 5. Open questions (to be resolved in PR-B)
1. **Singleton vs per-spawn initialization.** `SandboxManager` is a process-wide singleton (per the library's `reset()` being a global operation). The current design plan is one `initialize()` call at server startup with a union config covering all providers. If providers require different configs (e.g., different `denyRead` paths for anthropic vs codex), this may require a mutex approach or separate singleton instances. Decision reserved for PR-B.
2. **`SandboxManager.reset()` in tests.** The singleton means test suites that call `initialize()` must call `reset()` in their `after()` hooks. PR-B must add this discipline or tests will leak sandbox state across suites.
3. **MITM proxy and Claude CLI cert pinning.** The sandbox-runtime network bridge on Linux uses a local MITM proxy to intercept HTTPS traffic. If `claude` CLI pins certificates (e.g., for `api.anthropic.com`), HTTPS through the bridge may fail. PR-B must empirically verify this on PI231 before merging.
4. **macOS `sandbox-exec` profile content.** macOS uses a seatbelt (SBPL) profile, not bwrap. The profile must explicitly allow `network outbound "api.anthropic.com"` etc. The default profile may be too restrictive for the Claude CLI's OAuth refresh calls. PR-B must test macOS as well as Linux.
5. **`getDefaultWritePaths()` output.** The library exports `getDefaultWritePaths()` which returns the paths the sandbox always allows writing to. OLP's spawn directory may not be in that list — PR-B must verify the working directory is writable or pass it explicitly in `filesystem.allowWrite`.
---
## 6. Pitfalls inherited from the spike (binding warnings for PR-B/C authors)
These were confirmed empirically or inferred from the library source during the 2026-05-28 spike:
1. **Three OS deps, not one.** The npm package bundles nothing. Linux requires: `bubblewrap` (bwrap), `socat`, `ripgrep` (rg). All three. Missing even one → `checkDependencies()` returns errors → `wrapWithSandbox` will fail at runtime.
2. **Linux deny-paths are literal, not glob.** The library's `linuxGetMandatoryDenyPaths()` uses ripgrep to expand glob patterns to concrete paths before passing them to bwrap. But custom `filesystem.denyRead` entries that contain glob chars (`~/.ssh/*`) must be either expanded manually OR passed as the glob form (the library expands them if `rg` is available). The safe convention for PR-B: use absolute literal paths (e.g., `/home/<user>/.ssh`) rather than `~/`-prefixed or glob paths.
3. **`enableWeakerNestedSandbox: true` is required for codex.** Codex's `exec` subcommand spawns its own bubblewrap sandbox internally. Without `enableWeakerNestedSandbox`, the outer OLP sandbox blocks the inner codex sandbox from creating user namespaces. The flag loosens the outer sandbox's seccomp filter specifically to allow `clone(CLONE_NEWUSER)` — the inner sandbox then runs with reduced but non-zero isolation.
4. **`SandboxManager.reset()` is process-wide.** Calling `reset()` anywhere (including test teardown) clears the singleton config. Any concurrent in-flight spawn that still holds a reference to the old sandbox state will break. PR-B's design must either (a) initialize once at boot and never reset, or (b) use a mutex to prevent concurrent init/reset.
5. **MITM CA generation is async and expensive.** `SandboxManager.initialize()` generates a self-signed CA certificate for the MITM proxy on Linux. This takes ~100-500ms. Initialize at server startup, not per-request.
---
## 7. Authority citations
- **`@anthropic-ai/sandbox-runtime` v0.0.52** — https://github.com/anthropic-experimental/sandbox-runtime
- `dist/sandbox/sandbox-manager.js``isSupportedPlatform()`, `checkDependencies()`, `SandboxManager` export shape
- `dist/sandbox/linux-sandbox-utils.js``checkLinuxDependencies()`, `whichSync` usage, `enableWeakerNestedSandbox` rationale
- `README.md` — installation prerequisites, platform support matrix
- **2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm)** — report at `/tmp/sandbox-spike/report.md` on PI231. Key findings: dep install clean; `isSupportedPlatform()=true`; `checkDependencies()` errors on bwrap+socat+rg absence; three PoC scripts parked. Verdict YELLOW.
- **cc-mem incident memory 2026-05-27**`~/.cc-rules/memory/projects/olp/incident_2026_05_27_spawn_cli_security.md` § 3 (gap description), § 4 (prior-art search showing ecosystem hasn't solved multi-tenant fs/tool isolation).
- **OLP ADR 0009 Amendment 1 § Caveats #3** — "Sandbox-runtime still required for real multi-tenant deployment. Per the 2026-05-27 session prior-art search, Anthropic's official multi-tenant answer is `@anthropic-ai/sandbox-runtime` (OS-level isolation)."
- **`docs/plans/cloud-deployment-family.md` § 5** — sandbox is a hard prerequisite before any cloud deployment.
- **OLP ALIGNMENT.md** — PR-A is library/doctor/governance; it does not touch provider plugins, the entry surface, or the IR. The authority citation for the npm dep is the official sandbox-runtime repo URL + the spike report (not a provider CLI, not the OpenAI spec, not an existing ADR — this is a new dependency decision, which is the correct scope for ADR 0014).
- **Iron Rule 11 (Incremental Diff Review)** — splits non-trivial work into the minimum reviewable unit. The 4-PR split (A/B/C/D) is the direct application of this rule to the sandbox integration: each PR is independently reviewable, independently safe to land or revert, and corresponds to one logical layer.
---
## 8. Consequences
### Positive
- **Multi-tenant isolation at the OS level.** After PR-B+C land, each provider spawn runs inside a bubblewrap (Linux) or sandbox-exec (macOS) boundary. A prompt-injected `cat ~/.olp/keys/...` hits a kernel-level deny. Cross-client filesystem leakage is structurally prevented, not just mitigated by prompt engineering.
- **Cloud deployment unblocked.** `docs/plans/cloud-deployment-family.md` § 5 cites sandbox as the hard prerequisite for moving from family-LAN to cloud. PR-D closes this gate.
- **Observability from day one.** The `/health.sandbox` field makes the install state machine-readable. Any monitoring script or dashboard can tell whether sandbox isolation is active without SSH access.
- **Anthropic's official library.** Using `@anthropic-ai/sandbox-runtime` rather than a home-grown bwrap wrapper means OLP inherits Anthropic's tested integration patterns (deny-path expansion, MITM proxy, seccomp, macOS seatbelt profiles) rather than reinventing them. When the library updates, OLP upgrades via `npm update`.
### Negative
- **Three new OS-level dependencies.** `bubblewrap`, `socat`, and `ripgrep` must be installed on every host running OLP with sandbox isolation active. Absent these deps, sandbox is unavailable (but OLP continues to function without isolation — degraded security, not degraded functionality). The `/health.sandbox.available` field makes this state explicit.
- **p95 latency overhead.** The spike did not measure sandbox wrapping overhead directly (blocked on apt install). Expected overhead per the sandbox-runtime README: ~100-200ms for sandbox initialization amortized over the process lifetime (one-time at startup); per-spawn overhead is the namespace clone + filesystem mount overhead, typically <50ms on modern kernels. PR-B's acceptance criteria gates on ≤200ms p95 overhead over 50 warm requests.
- **Codex inner-sandbox degradation.** `enableWeakerNestedSandbox: true` loosens the outer OLP sandbox's seccomp filter to allow `clone(CLONE_NEWUSER)`. The codex inner sandbox still runs with meaningful isolation (its own namespace, its own deny-list), but the combined depth of protection is less than ideal compared to a world where codex didn't self-sandbox.
- **Library is experimental.** The `anthropic-experimental` org signals this is not a production-stable API. The version pin (`^0.0.52`) provides a minor-range buffer but the API surface may change. If the library is deprecated or the API breaks, OLP's fallback is to remove the sandbox wrapping (reverting PRs B-D) until a replacement path is found. This is acceptable at family scale — security degradation is not a service outage.
### Reversibility
- **PR-A** is trivially reversible: `npm uninstall @anthropic-ai/sandbox-runtime` + delete `lib/sandbox/doctor.mjs` + revert server.mjs and CHANGELOG changes. No production behavior changes.
- **PR-B/C** are reversible by removing the `SandboxManager.wrapWithSandbox` call from each provider's `spawn()` method. The spawn falls back to the current unsandboxed path.
- **PR-D** is a documentation update; reverting it is a docs-only change.
---
## Status transitions
- 2026-05-28 — Created. Status: Accepted for PR-A scope. PR-B/C/D pending operational prereqs.
+7
View File
@@ -20,6 +20,13 @@ 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. |
| [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. |
| [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. |
| [0008](0008-dashboard-and-audit-query.md) | Dashboard + Audit Query Layer | Phase 3 design ADR (D48, 2026-05-25). Static HTML dashboard + vanilla JS + fetch (no build step). In-memory ndjson scan for aggregate queries (O(N) per call; family-scale acceptable; defers SQLite migration to Option 3 hybrid trigger). Daily audit rotation `audit-YYYY-MM-DD.ndjson` on first append after UTC midnight; cross-file query layer for rolling 30-day windows. Owner-only gating on `/dashboard` + 3 `/v0/management/*` JSON endpoints reusing ADR 0007 § 7 auth model. 30s page poll (no SSE infra). Panels: per-provider quota / 24h request+cache+fallback / 30d spend trend / top-N fallback chains per spec § 4.6. Opens ADR 0007 § 12 Phase 3 deferral (Dashboard + audit query + rotation). |
| [0009](0009-interactive-mode-path-placeholder.md) | Anthropic Interactive-Mode Path (Placeholder) | Placeholder ADR (2026-05-25, Draft) — blocked on OCP ADR 0007 P0 experiment outcome. Records the maintainer's "wait + port" decision: do NOT independently implement; ride OCP's P0 result. If P0 confirms Transport A (stdio NDJSON) or B (PTY) bills as subscription rather than Agent SDK credit, port to OLP `lib/providers/anthropic.mjs` (Option 1 parallel impl, or Option 2 OCP-as-backend; decision deferred to P0-resolution time). If P0 fails on both, shelve. No Phase 4 D-day scheduled until P0 lands AND maintainer issues explicit "go" naming this ADR. |
| [0010](0010-phase-4-charter-operator-and-client-ux.md) | Phase 4 Charter — Operator + Client UX | Phase 4 scope ratification (2026-05-26, Accepted). Phase 4 = operator + client UX (SSE heartbeat / `olp` CLI + doctor / `olp-connect` zero-config + Telegram-Discord plugin + IDE docs bundle). ~13 D-days, D60 → v0.4.0. Records the explicit decision to DEFER `/v1/messages` (Anthropic-shape entry surface) on the rationale that under ADR 0009 P0 failure it provides no billing benefit AND degrades worse on fallback than OpenAI-shape clients. Re-open trigger: ADR 0009 P0 success + maintainer-named family CC user. Also closes the OCP-OLP port co-host ambiguity from ADR 0001 (default `OLP_PORT` 3456 → 4567). |
| [0011](0011-anonymous-key-deployment-context.md) | Anonymous-Key Deployment-Context Limits (Trusted-LAN Invariant) | D70 (2026-05-26, Accepted). Codifies the trust posture for `/health.anonymousKey` opt-in field (D69) + `bin/olp-connect` zero-config consumer (D68). Three-prerequisite gate (`auth.advertise_anonymous_key=true` + `auth.allow_anonymous=true` + an active key with `plaintext_advertise` field). Guest-tier-only restriction (`createKey()` + CLI reject owner+advertise). Trusted-LAN deployment invariant (loopback / RFC1918 / tailnet / `.local` / `.internal` — soft constraint at v0.4.0; hard enforcement deferred until OLP gains a public-deployment recipe). Re-evaluation trigger: any "expose to public internet" README mode. |
| [0012](0012-phase-5-charter-quota-probes-dashboard.md) | Phase 5 Charter — Provider Quota Probes + Dashboard Enrichment | Phase 5 scope ratification (2026-05-26, Accepted). Phase 5 = port OCP's plan-usage probe to `lib/providers/anthropic.mjs:quotaStatus()` + Claude.ai-style dashboard enrichment (1-min auto-refresh + manual refresh + per-provider rows with utilization bars, reset countdowns, status badges) + optional mistral probe at D84 (codex explicitly skipped — no public API). ~6 D-days, D79 → v0.5.0. Companion to ADR 0002 Amendment 8 (direct-API READ-ONLY exemption) + ADR 0013 (OAuth READ-ONLY consumption rules). Closes v1.x roadmap #8 (dashboard enrichment). Re-confirmed schema 2026-05-26 via compiled-binary `strings` + live API probe; 3 new fields since OCP 2026-04 capture, no removals. |
| [0013](0013-oauth-read-only-consumption-and-schema-drift.md) | OAuth READ-ONLY Consumption Rules + Schema-Drift Mitigation Protocol | D79 (2026-05-26, Accepted). Implementation discipline for ADR 0002 Amendment 8. Seven rules covering: credential reuse with spawn path (no new OAuth grant); READ-ONLY at the wire (one probe per cache miss, `max_tokens:1`, headers-only parse, discard body); cache TTL 5min + 60s-3600s exponential refresh backoff + stale-cache-on-failure; opt-in via `~/.olp/config.json providers.<name>.quota_probe_enabled` (default false); schema-drift mitigation via dual-path verification (compiled-binary `strings` + live API probe diff); failure transparency through `olp doctor` + dashboard staleness markers; out-of-scope clarifications. Bound by `~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md` as the live schema pin. |
## When to write a new ADR
+34
View File
@@ -0,0 +1,34 @@
{
"phase": "v0.5.1 post-release",
"purpose": "Refresh dashboard screenshot with live MacBook data after v0.5.1 hotfix (replacing D82's synthetic-data render)",
"captured_at_utc": "2026-05-27T01:26:17.304Z",
"host": "maintainer's MacBook (Mac client test target per project test-envs; specific IP / Tailscale node redacted per public-repo hygiene)",
"server_version": "0.5.1 (main @ commit fa2d1af \u2014 F4+#7 post-merge)",
"olp_port": 14567,
"endpoint_tested": "/v0/management/dashboard-data",
"auth": "owner-tier OLP key (temp, revoked post-test)",
"result_summary": {
"anthropic": {
"status": "live",
"schema_version": "2026-05-26",
"utilization_5h": 0.06,
"utilization_7d": 0.38,
"representative_claim": "five_hour",
"failure": null
},
"openai": {
"status": "unavailable",
"reason": "no public quota api or probe disabled"
}
},
"v0_5_1_contract_verified": [
"quota_v2[i].status enum includes 'live' (anthropic) and 'unavailable' (openai) \u2014 both rendered correctly",
"quota_v2[i].failure is null for healthy live status (per ADR 0013 Rule 6 \u2014 failure info only on stale/unreachable)",
"quota_v2[i].schema_version pinned at 2026-05-26 \u2014 matches models-registry.json quota_probe.schema_version"
],
"post_test_cleanup": [
"temp owner key (id=0m6s2s97, name=v0.5.1-screenshot) revoked",
"~/.olp/config.json providers.anthropic.quota_probe_enabled flag removed (config restored to baseline)",
"test server (pid varies, port=14567) terminated"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+47
View File
@@ -0,0 +1,47 @@
# OLP IDE & client integrations
This directory documents per-tool setup for the IDEs and AI clients OLP
supports. Every page follows the same shape: one-line description, status
icon, copy-paste-able config block, known issues, OLP-specific notes, and a
one-line verification command.
## Index
| Tool | Status | Path | Notes |
|---|---|---|---|
| [Continue.dev](./continue.md) | ✅ Supported | VS Code / JetBrains extension | `config.yaml` (NOT `config.json`); supports custom headers |
| [Cline](./cline.md) | ✅ Supported | VS Code extension | OpenAI-compatible provider; UI field occasionally vanishes (Cline #7128) |
| [Cursor](./cursor.md) | ⚠️ Best-effort | Cursor editor | "Override OpenAI Base URL" — known fragile across releases |
| [Aider](./aider.md) | ✅ Supported | terminal CLI | `OPENAI_API_BASE` env + `openai/` model prefix |
| [Claude Code](./claude-code.md) | ❌ Not supported | terminal CLI | Anthropic wire format only; OLP serves OpenAI wire format. Use Cline instead. |
| [OpenClaw](./openclaw.md) | ✅ Supported | Telegram + Discord gateway | `/olp` slash command via the [`olp-plugin/`](../../olp-plugin/) plugin |
## Status legend
- ✅ **Supported** — works against OLP's OpenAI-compatible `/v1/chat/completions`
endpoint; the tool's IR fields flow through OLP's IR without lossy translation
warnings on the documented chain.
- ⚠️ **Best-effort** — works in current versions but the tool has known
upstream bugs around base-URL configuration; expect occasional weirdness.
- ❌ **Not supported** — the tool's wire protocol or transport is incompatible
with what OLP serves; recommended alternative is documented on the page.
## How OLP's response headers help debugging
Every response carries (see [README § Response Headers](../../README.md#response-headers)):
- `X-OLP-Provider-Used` — which provider's plugin served the request
- `X-OLP-Model-Used` — which model the served provider used
- `X-OLP-Fallback-Hops``0` = primary chain entry served it
- `X-OLP-Cache``hit | miss | bypass`
- `X-OLP-Latency-Ms` — end-to-end latency at the proxy
When something looks wrong in an IDE, the first sanity check is `curl -i`
against `/v1/chat/completions` with the same key — those headers tell you
whether the IDE config is broken or OLP routed somewhere unexpected.
## Cross-references
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter; documents why `/v1/messages` is not supported and points to Cline as the recommended Anthropic-CLI replacement.
- [ADR 0011](../adr/0011-anonymous-key-deployment-context.md) — trusted-LAN-only invariant for `auth.advertise_anonymous_key`.
- [`bin/olp-connect`](../../bin/olp-connect) — automated client setup helper (D68-D70).
+106
View File
@@ -0,0 +1,106 @@
# Aider + OLP
[Aider](https://aider.chat) is a terminal-native pair programmer that
edits files in your local git repo and commits each change. It speaks
OpenAI's `/v1/chat/completions` wire format via the `openai/` model
prefix.
**Status:** ✅ Supported.
**Tested against:** Aider v0.6x. Aider's OpenAI integration has been stable
across many releases — this is the most reliable IDE/CLI binding to OLP.
## Quick setup
Three knobs, all environment variables or `.env`:
```bash
# Required: point Aider at OLP's chat-completions endpoint
export OPENAI_API_BASE=http://127.0.0.1:4567/v1
# Required: OLP plaintext token
export OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Then invoke Aider with an OLP-routable model, prefixed `openai/`:
aider --model openai/claude-sonnet-4-5
```
The `openai/` prefix tells Aider to use its OpenAI-compatible adapter for
the named model. Aider's litellm layer parses this and sends the request
to whatever `OPENAI_API_BASE` resolves to.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=aider`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Aider's `.env` support
Aider auto-loads a `.env` file from the current directory or the git repo
root. The accepted keys are:
```bash
# .env at the project root
OPENAI_API_BASE=http://127.0.0.1:4567/v1
OPENAI_API_KEY=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Optional: Aider's own AIDER_-prefixed equivalents work too
AIDER_OPENAI_API_BASE=http://127.0.0.1:4567/v1
```
The `AIDER_` prefix wins over the bare prefix when both are set. Pick one;
mixing them invites surprises during debugging.
**Hygiene:** add `.env` to `.gitignore` if your repo doesn't already. The
OLP token is plaintext-recoverable from disk only because the chat surface
explicitly opts into it (see [ADR 0011](../adr/0011-anonymous-key-deployment-context.md))
— do not let your IDE bind unintentionally.
## Known issues
- **No custom-headers support.** Aider does not expose a way to set extra
HTTP headers on outgoing requests. OLP's optional `X-OLP-Chain` /
`X-OLP-Bypass-Cache` headers are therefore not available via Aider —
routing is determined by the model name alone.
- **`/v1` trailing matters.** `OPENAI_API_BASE` must end at `/v1` (without
`/chat/completions`); Aider appends the remainder. Setting it to the bare
host or with a trailing `/chat/completions` causes 404s.
- **Aider sends `max_tokens` by default.** OLP forwards `max_tokens` to
every provider. If you see "model X does not support max_tokens" errors,
the underlying provider rejects it — check `X-OLP-Provider-Used` and
filter that provider out of the chain for the affected model.
## OLP-specific notes
Aider's request shape is faithful to OpenAI's `/v1/chat/completions`
spec — `messages`, `model`, `max_tokens`, `stream`, `temperature`,
`tools`. All map cleanly into OLP's IR with no lossy-translation warnings.
For long-context work (codebase summaries, large diffs), set
`streaming.heartbeat_interval_ms: 15000` in `~/.olp/config.json` (see
[README § Environment Variables](../../README.md#configjson-keys-introduced-at-phase-4))
so the SSE stream stays alive through reverse proxies during silent
windows.
## Test it
```bash
# In a scratch dir:
aider --model openai/claude-haiku-4-5 --no-stream --message "say ok"
```
Then check OLP's audit log:
```bash
npx olp logs 5
```
The most recent entry should show `provider: anthropic` (or whatever
provider haiku routes to in your chain) and `cache_status: miss`.
## Cross-references
- Aider model config docs: https://aider.chat/docs/llms/openai-compat.html
- [`olp-connect`](../../bin/olp-connect) writes `~/.aider/.env` if Aider is
detected on PATH.
+89
View File
@@ -0,0 +1,89 @@
# Claude Code + OLP
[Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) is
Anthropic's official terminal-native agent. It speaks the Anthropic
`/v1/messages` wire format and cannot be configured to use an
OpenAI-compatible chat-completions endpoint.
**Status:** ❌ Not supported.
## Why
OLP serves only the OpenAI `/v1/chat/completions` wire format. Adding
`/v1/messages` (the Anthropic shape) was explicitly considered for
Phase 4 and rejected, per
[ADR 0010 § Out of Phase 4 scope](../adr/0010-phase-4-charter-operator-and-client-ux.md).
The short version of the rationale:
- **No billing benefit.** After Anthropic's 2026-06-15 split, `claude -p` /
Agent SDK / third-party agent traffic moves out of the Pro/Max
subscription pool and into a separate paid Agent SDK Credit pool. OLP's
fallback discipline ("when one provider's quota runs out, try the next")
does not save money for this traffic — it just routes the same paid
request to a different paid backend. The subscription leverage that
makes OLP valuable for OpenAI-shape traffic does not exist for
Anthropic-shape traffic.
- **Degrades worse on fallback.** When OLP's primary chain entry (Anthropic)
is exhausted, the fallback hop is typically OpenAI Codex or Mistral Vibe.
Those providers speak OpenAI tool-calling schema; OLP would have to
translate Anthropic's `/v1/messages` tool shape into OpenAI tool shape on
every fallback. That translation is lossy and is what ADR 0010 calls out
as "net non-positive under P0 failure".
- **Same outcome reachable via the recommended alternative.** Cline, Cursor,
Aider, and Continue.dev all speak OpenAI's wire format and have parity
with Claude Code on the "AI edits files in my repo" use case. OLP serves
them today.
## What to use instead
**Recommended:** [Cline](./cline.md). It's an in-IDE autonomous coder that
operates on the same loop Claude Code does (read files, propose edits,
run tools, iterate). The "OpenAI Compatible" provider points cleanly at
OLP's `/v1/chat/completions` endpoint. You get OLP's full fallback chain
(Anthropic → OpenAI Codex → Mistral) instead of being pinned to one
provider.
For terminal users specifically:
- **[Aider](./aider.md)** if you want the Claude-Code-style git-aware
pair programmer in the terminal.
- **OpenClaw** if you want Telegram/Discord-driven access to the
fallback chain (see [`openclaw.md`](./openclaw.md)).
## Re-open trigger
ADR 0010 documents the conditions under which OLP would reconsider
`/v1/messages`:
> (a) ADR 0009 P0 confirms interactive-mode billing classification as
> subscription (≥ 2026-07-15) AND (b) maintainer explicitly opens
> Phase 5 "Anthropic-shape hub" scope with the name of at least one
> family member who wants CC access.
Until both conditions fire, OLP intentionally does not implement
`/v1/messages`. The decision is recorded in ADR 0010 § "Out of Phase 4
scope" and ADR 0009 (Anthropic interactive-mode path placeholder).
## If you absolutely must use Claude Code
Point Claude Code at api.anthropic.com directly. OLP cannot proxy that
traffic. You will:
- Burn against the Anthropic Pro/Max OAuth subscription (pre-2026-06-15) or
the Agent SDK Credit pool (≥ 2026-06-15).
- Lose every fallback property OLP provides — when Anthropic's quota is
exhausted, Claude Code stops working until the quota resets.
- Lose OLP's response headers (`X-OLP-Provider-Used` etc.), audit log
entries, cache hits, and `/health` visibility.
This is documented here only so the trade-off is explicit, not as a
recommendation.
## Cross-references
- [ADR 0010](../adr/0010-phase-4-charter-operator-and-client-ux.md) § "Out of Phase 4 scope" — full defer rationale.
- [ADR 0009](../adr/0009-interactive-mode-path-placeholder.md) — Anthropic 2026-06-15 billing split and re-open trigger.
- [`cline.md`](./cline.md) — the recommended alternative for Claude-Code-style workflows.
+94
View File
@@ -0,0 +1,94 @@
# Cline + OLP
[Cline](https://github.com/cline/cline) is an autonomous-coder VS Code
extension. It speaks OpenAI's `/v1/chat/completions` wire format via its
"OpenAI Compatible" provider option.
**Status:** ✅ Supported.
**Tested against:** Cline v3.x (extension version visible in VS Code's
extension panel). Cline's settings UI has shipped multiple variants of the
base-URL field across 2025-2026; if your version doesn't show the field
described below, see the Known Issues section.
## Quick setup
1. Open the Cline panel in VS Code (sidebar icon).
2. Click the settings gear → "API Provider".
3. Select **OpenAI Compatible**.
4. Fill the fields:
| Field | Value |
|---|---|
| Base URL | `http://127.0.0.1:4567/v1` |
| API Key | `olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` |
| Model ID | `claude-sonnet-4-5` |
5. Save. Cline shows the model name in the bottom-right corner of the panel.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=cline`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Known issues
- **Cline issue [#7128](https://github.com/cline/cline/issues/7128) —
base-URL UI field intermittently disappears.** Several Cline releases in
2025-2026 shipped a settings UI where the "Base URL" field is hidden when
the "OpenAI Compatible" provider is freshly selected. Workaround: switch
to a different provider, save, switch back to "OpenAI Compatible" — the
field returns. Verify the field is visible in your version BEFORE
troubleshooting OLP itself.
- **Cline writes settings to `.vscode/settings.json` under a
`cline.apiConfiguration` key (workspace-scoped) and to the VS Code global
state (machine-scoped) depending on the "save to workspace" toggle.** If
Cline keeps "forgetting" the OLP base URL across VS Code restarts, the
workspace state is overriding the global state. Either save to workspace
explicitly, or clear the workspace key and use global state.
- **Cline sometimes lowercases the model ID before sending.** OLP's
`models-registry.json` uses canonical case (e.g. `claude-sonnet-4-5`).
This is fine — OLP's router lowercases the requested model for chain
lookup. But if you see `unknown model` errors, double-check the exact
string Cline sent via the OLP response headers (curl test below).
## OLP-specific notes
Cline does not expose a custom-headers field in its OpenAI Compatible
provider UI as of v3.x. The OLP routing chain is selected purely from the
model ID — pick the canonical name (e.g. `claude-sonnet-4-5`) that matches
a `routing.chains` key in your `~/.olp/config.json`.
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`,
`X-OLP-Latency-Ms`) are not visible in Cline's UI but are captured by VS
Code's Developer Tools Network panel when Cline runs the request.
## Test it
```bash
# 1. Verify OLP accepts Cline-shape requests
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5,"stream":false}' \
| grep -i x-olp
```
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider serves
sonnet in your chain) and `X-OLP-Cache: miss` on first request.
## Why not /v1/messages?
Cline supports Anthropic-shape requests via a separate "Anthropic" provider
in its UI. OLP does not implement `/v1/messages`. Use Cline's **OpenAI
Compatible** option pointed at OLP rather than Cline's **Anthropic** option
pointed at api.anthropic.com — the OLP chain gives you fallback to OpenAI
Codex / Mistral / etc. when the Anthropic subscription hits its quota
ceiling. See [ADR 0010 § /v1/messages defer rationale](../adr/0010-phase-4-charter-operator-and-client-ux.md).
## Cross-references
- Cline issue tracker: https://github.com/cline/cline/issues
- [`olp-connect`](../../bin/olp-connect) automates writing the Cline workspace
state.
+95
View File
@@ -0,0 +1,95 @@
# Continue.dev + OLP
[Continue.dev](https://continue.dev) is an open-source autocomplete +
chat extension for VS Code and JetBrains IDEs. It speaks OpenAI's
`/v1/chat/completions` wire format, so it works against OLP with no
shim layer.
**Status:** ✅ Supported.
**Tested against:** Continue.dev v0.10.x (`config.yaml` schema). The
older `config.json` schema (≤ v0.8) is **not** documented here — Continue
deprecated it in late 2025 and emits a one-shot migration warning.
## Quick setup
Edit `~/.continue/config.yaml` (or open the Continue config from the IDE's
extension panel and paste this in):
```yaml
models:
- name: olp-chat
provider: openai
model: claude-sonnet-4-5
apiBase: http://127.0.0.1:4567/v1
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
roles:
- chat
requestOptions:
headers:
# Optional: pin which routing chain key applies. If omitted, OLP
# looks up the chain via the model name above.
X-OLP-Chain: claude-sonnet-4-5
- name: olp-autocomplete
provider: openai
model: claude-haiku-4-5
apiBase: http://127.0.0.1:4567/v1
apiKey: olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
roles:
- autocomplete
```
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=continue-dev`. Family members on the LAN should substitute the OLP
host's IP for `127.0.0.1` (or use `olp-connect <ip>` to do this for them
automatically).
## Known issues
- **`apiBase`, NOT `baseURL`.** Continue's YAML schema uses `apiBase` (no
`URL` casing). The older `config.json` `baseURL` key was renamed during the
v0.10 schema cut. If you copy a snippet from a 2024 blog post and it
silently routes to api.openai.com, this is why.
- **Trailing `/v1` matters.** OLP's chat-completions endpoint is at
`/v1/chat/completions`; Continue appends `/chat/completions` to whatever
`apiBase` resolves to. Set `apiBase: http://host:4567/v1` (with `/v1`),
not the bare host.
- **Provider stays `openai`.** Continue's `provider: anthropic` would send
Anthropic-shape requests to `/v1/messages`, which OLP does not implement
(see [`claude-code.md`](./claude-code.md) for the rationale).
## OLP-specific notes
Continue's `requestOptions.headers` lets you pin OLP-specific routing
behaviour without altering the model name itself. Useful headers:
- `X-OLP-Chain: <chain-key>` — explicitly select the routing chain.
- `X-OLP-Bypass-Cache: true` — force a fresh spawn for the next request
(debugging cache-poisoning suspicions).
OLP's response headers (`X-OLP-Provider-Used`, `X-OLP-Cache`, etc.) are
visible via VS Code's `Developer: Toggle Developer Tools` → Network panel
when Continue runs the request.
## Test it
After config save, open the Continue chat panel and send a one-word
message ("ok"). Then on the terminal:
```bash
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"claude-haiku-4-5","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
| grep -i x-olp
```
Expect `X-OLP-Provider-Used: anthropic` (or whichever provider your chain
routes haiku to) and `X-OLP-Cache: miss` on first request, `hit` on the
second.
## Cross-references
- Continue.dev config reference: https://docs.continue.dev/customization/models
- [`olp-connect`](../../bin/olp-connect) automates the Continue.dev branch
of this setup.
+116
View File
@@ -0,0 +1,116 @@
# Cursor + OLP
[Cursor](https://cursor.com) is an AI-first VS Code fork. It has an
"Override OpenAI Base URL" setting that, when populated, routes its
default-model traffic to your URL using the OpenAI wire format.
**Status:** ⚠️ Best-effort.
**Reason:** Cursor's base-URL override is known to be fragile across
releases. Multiple 2025-2026 forum threads document the setting silently
reverting, model-list dropdowns not populating from the override URL, and
streaming responses falling back to the default backend on parse errors.
The behaviour is not specific to OLP — every OpenAI-compatible proxy
maintainer documents the same caveats — but Cursor's release cadence is
faster than most third-party proxies can test against.
## Quick setup
1. Open Cursor → Settings → "Models" → enable **OpenAI API Key**.
2. Paste your OLP plaintext token into the **API Key** field.
3. Click "Override OpenAI Base URL" and paste:
```
http://127.0.0.1:4567/v1
```
4. Click "Verify". Cursor sends a probe; on success the indicator turns
green.
5. **Crucial step:** in the model list, disable every model that is NOT
in your `~/.olp/config.json` `routing.chains`. Cursor's chat will round-
robin across enabled models and any model OLP can't route will error.
Replace the API key with the plaintext token printed by `olp-keys keygen
--name=cursor`. Family members on the LAN should substitute the OLP host's
IP for `127.0.0.1` (or use `olp-connect <ip>`).
## Known issues
- **Override URL silently reverts on Cursor update.** Two reported variants:
(a) the field empties; (b) the field shows the OLP URL but Cursor still
hits api.openai.com under the hood. Workaround: after every Cursor
update, re-open settings, click "Verify" again, and check the OLP
server's `/health` for incoming probe requests.
- **Model-list dropdown does not populate from the override URL.** Cursor
hardcodes its model list rather than reading `GET /v1/models`. This is
why step 5 above is required — there is no way to make Cursor "discover"
your models. You have to disable each model individually that OLP can't
serve.
- **Streaming response parsing is stricter than OpenAI's actual SSE spec.**
Cursor occasionally falls back to the default backend if the SSE stream
contains a slightly malformed chunk (e.g. an empty `data:` line that
OpenAI's API does emit but Cursor's parser doesn't expect). OLP's SSE
emitter follows the spec; this is on Cursor's side. If you see traffic
hitting api.openai.com despite the override, this is the most likely
cause.
- **Cursor's "Tab" autocomplete is NOT covered by the override.** Tab
completion uses a Cursor-proprietary endpoint that is not affected by the
OpenAI base URL setting. Only the chat panel is. This is documented
Cursor behaviour and is not a bug.
## OLP-specific notes
Cursor sends `model: "gpt-4"` or `model: "gpt-3.5-turbo"` (legacy aliases)
unless you explicitly select another from its dropdown. Add aliases to
your `~/.olp/config.json` `routing.chains` so these route somewhere sane:
```json
{
"routing": {
"chains": {
"gpt-4": [ { "provider": "openai", "model": "gpt-5" } ],
"gpt-3.5-turbo": [ { "provider": "openai", "model": "gpt-5-mini" } ]
}
}
}
```
(Substitute the OpenAI Codex model names listed by `olp models`.)
## Recommendation
**Do not engineer workarounds for Cursor-side bugs.** Cursor's release
cadence will fix or re-break the override URL handling at unpredictable
intervals. If your daily-driver flow is unreliable, switch to Cline (see
[`cline.md`](./cline.md)) — it has a stable OpenAI-compatible provider
that does not break across releases.
## Test it
```bash
curl -sI -X POST http://127.0.0.1:4567/v1/chat/completions \
-H "Authorization: Bearer olp_XXXXXX" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4","messages":[{"role":"user","content":"ok"}],"max_tokens":5}' \
| grep -i x-olp
```
After hitting "Send" in Cursor's chat, check the OLP server's recent
requests via:
```bash
npx olp logs 10
```
If you don't see Cursor's request in the audit log, traffic isn't reaching
OLP — re-check the override URL.
## Cross-references
- Cursor forum threads on base-URL fragility: https://forum.cursor.com/ (search "OpenAI base URL")
- [`olp-connect`](../../bin/olp-connect) writes Cursor's `cursorrc` if
detected, but cannot guarantee the override survives a Cursor update.
+370
View File
@@ -0,0 +1,370 @@
# OpenClaw + OLP
[OpenClaw](https://github.com/openclaw/openclaw) is a multi-bot gateway that exposes slash commands on Telegram, Discord, and other chat surfaces. OLP integrates with OpenClaw in two ways:
1. **`/olp` slash commands** via the [`olp-plugin/`](../../olp-plugin/) plugin (read-only parity to the local `olp` CLI).
2. **LLM routing** — OpenClaw's chat agent can route its model calls through your OLP server, giving you per-key audit + quota observability for every bot reply.
This doc covers both. **Status:** ✅ Supported.
## Two deployment modes — pick yours
The OpenClaw config differs significantly depending on whether OpenClaw runs on the same host as the OLP server or on a separate client machine talking to a remote OLP. Pick the right section.
| | **Mode A: Server-co-located** | **Mode B: Client-mode (recommended for multi-machine setups)** |
|---|---|---|
| OpenClaw runs on | the OLP server host (loopback) | a different machine (Mac mini, laptop, etc.) |
| OLP server runs on | localhost (same host) | a remote host (e.g. PI231) |
| `olp-claude` baseUrl | `http://127.0.0.1:4567/v1` | `http://<server-ip>:4567/v1` |
| Auth | `authHeader: false` (loopback trusted), OR anonymous-key if `auth.allow_anonymous: true` | `apiKey: "${OLP_OPENCLAW_BOT_TOKEN}"` env-var reference (NOT raw string, NOT `OPENAI_API_KEY` — see § Gotchas) |
| `/olp` slash plugin proxyUrl | `http://127.0.0.1:4567` | `http://<server-ip>:4567` |
## `/olp` slash commands you get
| Slash command | Maps to | Tier |
|---|---|---|
| `/olp status` | GET `/v0/management/status` | owner |
| `/olp health` | GET `/health` | public |
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
| `/olp models` | GET `/v1/models` | public |
| `/olp cache` | GET `/cache/stats` | owner |
| `/olp providers` | local registry view | public |
| `/olp chain show [model]` | local chain view | public |
| `/olp doctor` | informational (HTTP endpoint not yet shipped) | — |
| `/olp help` | usage text | — |
**Mutating subcommands are deliberately not exposed via chat.** `keygen`, `revoke`, `restart`, `logs` are SSH-only. See [`olp-plugin/README.md`](../../olp-plugin/README.md#what-you-can-not-do-from-chat-by-design) for the rationale.
---
## Mode A — Server-co-located install
OpenClaw + OLP on the same host. Auth is simpler because everything is on loopback.
### A1. Install the plugin
Two install paths — either works.
**Option A — OpenClaw CLI:**
```bash
openclaw plugins install /path/to/olp/olp-plugin/
```
**Option B — symlink:**
```bash
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
### A2. Mint a bot owner key
```bash
npx olp-keys keygen --owner --name=openclaw-bot
```
Capture the printed plaintext token — shown exactly once.
### A3. Configure (loopback recipe)
Edit `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"allow": ["...", "olp"],
"entries": {
"olp": {
"enabled": true,
"config": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
}
}
```
For LLM routing through OLP, add (or update) the `olp-claude` provider so the bot's default agent goes through OLP-spawned `claude -p`:
```json
{
"models": {
"providers": {
"olp-claude": {
"baseUrl": "http://127.0.0.1:4567/v1",
"api": "openai-completions",
"authHeader": false,
"models": [
{ "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
]
}
}
}
}
```
`authHeader: false` is safe on loopback. If you set `auth.allow_anonymous: true` on the OLP server, the bot doesn't even need a key for slash commands (the `apiKey` field can be omitted). Owner-only subcommands (`/olp status`, `/olp usage`, `/olp cache`) still need an owner-tier key.
### A4. Restart the gateway
```bash
openclaw gateway restart
```
---
## Mode B — Client-mode install (OpenClaw on different host than OLP)
OpenClaw on machine X (e.g., Mac mini), OLP server on machine Y (e.g., a Raspberry Pi or any LAN host). This is the common family deployment shape.
### B1. Install the plugin
Same as Mode A:
```bash
openclaw plugins install /path/to/olp/olp-plugin/
# OR
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
### B2. Mint a bot owner key (on the OLP server, NOT on the OpenClaw host)
SSH to the OLP server:
```bash
ssh user@olp-server
cd ~/olp
node bin/olp-keys.mjs keygen --owner --name=openclaw-<hostname>-bot
```
Capture the plaintext — shown exactly once. **This token will live in `~/.openclaw/openclaw.json` on your OpenClaw host**; pick a name that makes it independently revocable if that host is lost/compromised.
### B3. Set the bot-token env var (`OLP_OPENCLAW_BOT_TOKEN`)
OpenClaw's canonical pattern for custom-provider auth is `apiKey: "${VAR_NAME}"` — an env-var reference, NOT a raw token. Choose a **custom** variable name (NOT `OPENAI_API_KEY` — OpenClaw service-manages that one and clobbers it with its own ChatGPT key on every restart). Convention: `OLP_OPENCLAW_BOT_TOKEN`.
**macOS (gateway under launchd)**:
```bash
launchctl setenv OLP_OPENCLAW_BOT_TOKEN olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
Add the same `export` to `~/.zshrc` so it survives reboot:
```bash
export OLP_OPENCLAW_BOT_TOKEN=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
**Linux (gateway under systemd-user)**: drop a file at `~/.config/environment.d/openclaw-olp.conf`:
```
OLP_OPENCLAW_BOT_TOKEN=olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
Restart the gateway service so it picks up the new env.
### B4. Configure `~/.openclaw/openclaw.json`
Edit `~/.openclaw/openclaw.json` on the OpenClaw host:
```json
{
"plugins": {
"allow": ["...", "olp"],
"entries": {
"olp": {
"enabled": true,
"config": {
"proxyUrl": "http://<olp-server-ip>:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
},
"models": {
"providers": {
"olp-claude": {
"baseUrl": "http://<olp-server-ip>:4567/v1",
"api": "openai-completions",
"apiKey": "${OLP_OPENCLAW_BOT_TOKEN}",
"models": [
{ "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (via OLP)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
{ "id": "claude-opus-4-7", "name": "Claude Opus 4.7 (via OLP)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
{ "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5 (via OLP)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
]
}
}
}
}
```
Note: the `plugins.entries.olp.config.apiKey` field (line 11) IS allowed to be a raw token — it's a separate code path that doesn't suffer the service-managed-env clobber problem. Only the `models.providers.<id>.apiKey` field needs the `${VAR}` env-var-reference workaround.
### B5. Confirm the default agent model is on `olp-claude`
Check `agents.defaults.model.primary` in `openclaw.json`. It should be something like:
```json
{ "agents": { "defaults": { "model": { "primary": "olp-claude/claude-sonnet-4-6" } } } }
```
If it's pointing at one of OpenClaw's stock providers (`openai/...`, `anthropic/...`, `github-copilot/...`), free-text chat will **bypass OLP entirely** and hit your direct API account. You'll see no traffic in OLP's `/dashboard` and `/olp usage` will show no recent activity.
### B5. Restart the gateway
```bash
openclaw gateway restart
```
### B6. Verify routing
In Telegram or Discord, send a free-text message ("hello"). It should:
1. Return a normal LLM reply (not "Something went wrong")
2. Show up on the OLP dashboard's 24h-requests counter
3. Show up in `/olp usage` per-provider count
If you see "Something went wrong" — see § Troubleshooting below.
---
## Using codex / OpenAI models through OLP
By default the `olp-claude` provider only knows about Claude models. To route OpenAI / codex models through OLP (so bot calls to `gpt-5.5` etc. spawn `codex exec --json` on the OLP server and benefit from per-key audit + quota tracking), add a second provider:
```json
{
"models": {
"providers": {
"olp-codex": {
"baseUrl": "http://<olp-server-ip>:4567/v1",
"api": "openai-completions",
"apiKey": "${OLP_OPENCLAW_BOT_TOKEN}",
"models": [
{ "id": "gpt-5.5", "name": "GPT 5.5 (via OLP→codex)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
{ "id": "gpt-5.4-mini", "name": "GPT 5.4 mini (via OLP→codex)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } },
{ "id": "gpt-5.3-codex", "name": "GPT 5.3 codex (via OLP→codex)", "input": ["text"],
"contextWindow": 200000, "maxTokens": 16384, "api": "openai-completions",
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } }
]
}
}
},
"agents": {
"defaults": {
"models": {
"olp-codex/gpt-5.5": { "alias": "OLP GPT 5.5" },
"olp-codex/gpt-5.4-mini": { "alias": "OLP GPT 5.4 mini" },
"olp-codex/gpt-5.3-codex": { "alias": "OLP Codex" }
}
}
}
}
```
After restart, type `/models` in Telegram and pick `olp-codex/gpt-5.5` from the menu that appears. **`/models` is menu-driven — it does not accept inline model names**; typing `/models olp-codex/gpt-5.5` won't directly switch you. The available IDs are the ones OLP's `/v1/models` returns — typically `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`. Query your OLP server to see the live list:
```bash
curl -s -H "Authorization: Bearer olp_…" http://<olp-server-ip>:4567/v1/models | jq '.data[].id'
```
**Why not use OpenClaw's stock `openai` provider?** OpenClaw's built-in `openai-codex` provider uses the local ChatGPT account (via the `sk-proj-…` API key OpenClaw stores) and bypasses your OLP server entirely. You'd lose per-key audit + per-key quota visibility. `olp-codex` keeps everything routed through your central OLP for observability.
---
## Gotchas
### Auth must use `apiKey: "${VAR}"` env-var reference — three failure modes to avoid
Custom OpenAI-compatible providers in OpenClaw have a fragile auth path. Three patterns that **don't work** + the one that **does**:
**❌ `apiKey: "olp_<raw-token>"`** — raw string. Silently bypassed in some routing paths because OpenClaw treats `OPENAI_API_KEY` as service-managed (`OPENCLAW_SERVICE_MANAGED_ENV_KEYS=DEEPSEEK_API_KEY,OPENAI_API_KEY`), and certain model id patterns (notably `gpt-*`) fall back to that env var instead of using your explicit `apiKey`. Symptom: OLP audit shows the request as `__anonymous__` instead of your owner key. Confirmed via [openclaw#41157](https://github.com/openclaw/openclaw/issues/41157) (Gemini openai-completions Authorization not sent) and [#1669](https://github.com/openclaw/openclaw/issues/1669) (Ollama provider ignores apiKey, hardcodes Bearer). Both unresolved upstream as of OpenClaw v2026.5.
**❌ `headers: { "Authorization": "Bearer olp_<raw-token>" }`** — works for SOME provider/model combinations (e.g., model id `claude-sonnet-4-6`) but breaks for openai-shape model ids (`gpt-5.5` etc.) which take a different code path that ignores the `headers` field. Mixed behavior is worse than no behavior.
**❌ Setting `OPENAI_API_KEY=olp_…`** in the gateway env. OpenClaw service-manages that variable and overwrites your value with the user's ChatGPT key on every gateway start.
**✅ `apiKey: "${OLP_OPENCLAW_BOT_TOKEN}"`** — env-var reference with a **custom** variable name (NOT `OPENAI_API_KEY`). OpenClaw resolves the reference at request-construction time, before any service-managed-env logic runs. Both `olp-claude/*` (Claude models) and `olp-codex/*` (OpenAI models) auth correctly with this pattern. Verified end-to-end 2026-05-27: OLP audit shows requests attributed to the correct bot key for both provider blocks.
OpenClaw docs call out this as the canonical pattern: see [docs.openclaw.ai/concepts/model-providers](https://docs.openclaw.ai/concepts/model-providers) "API key or SecretRef/env reference".
### Default agent model still points at a removed provider
If you've removed a provider (e.g., torn down a co-located OCP server) but the bot's default agent model still references that provider, free-text messages will fail with "Something went wrong while processing your request." Check `agents.defaults.model.primary` and update it to a provider that exists.
### `/new` does not reset model selection — use `/reset`
OpenClaw's `/new` resets the **conversation context** but **preserves** the session's `/models` selection. If a session has been switched to a model that no longer works (revoked / removed), `/new` won't help — use `/reset` (resets both context and model selection).
### `/models` is menu-only — does not accept inline model names
The OpenClaw `/models` command in Telegram is **menu-driven**: typing `/models` pops a model-picker menu where you tap the model name. Typing `/models olp-codex/gpt-5.5` does NOT switch — it'll open the picker. The bot's own success-message after a pick may say *"Use `/model olp-codex/gpt-5.5 --runtime <runtime>` to switch harnesses."***that command form is not actually accepted by the bot**; ignore that line.
### OpenClaw v2026.5+ requires `openclaw.extensions` in `package.json`
OpenClaw versions ≥ 2026.5.22 enforce a stricter plugin-manifest validation at `openclaw plugins install` time. If `Option A` fails with `package.json missing openclaw.extensions` despite recent OLP releases, your local `olp-plugin/package.json` may predate the v0.5.x fix that adds `"extensions": ["./index.js"]` to the `openclaw` block. Pull latest OLP main (`git pull` in your OLP clone) and retry, or fall through to symlink Option B which works against any plugin shape. (Original drift event: 2026-05-27, see commit history of `olp-plugin/package.json`.)
### `openclaw gateway restart` is required after install
OpenClaw caches plugin discovery + model-provider config at gateway start. `openclaw plugins reload` does not guarantee a fresh import of the plugin module nor a fresh re-read of `models.providers.*`. Restart the gateway after every change to `~/.openclaw/openclaw.json`.
### Owner-key revocation kicks the plugin out immediately
If you revoke the bot's owner key (`npx olp-keys revoke --id=<id>`), the next `/olp status` will return `401 unauthorized`. Mint a replacement key with a new name and edit `~/.openclaw/openclaw.json`; do NOT reuse the revoked key's UUID.
### Long responses are truncated
Telegram caps messages at ~4096 characters. The plugin truncates with a `... [truncated, use SSH for full]` suffix when the rendered output would exceed ~3900 chars. Use SSH + the local `olp` CLI for full output.
---
## OLP-specific notes
The plugin honours these env vars on the OpenClaw gateway process:
- `OLP_PROXY_URL` — full URL, overrides plugin config `proxyUrl`.
- `OLP_PORT` — port only, localhost assumed; overrides `proxyUrl` when `OLP_PROXY_URL` is unset.
If you run the OpenClaw gateway under launchd or systemd with custom env vars, set `OLP_PROXY_URL` there rather than editing the plugin config — that way the same plugin install can serve multiple OLP hosts.
## Per-bot vs maintainer key
**Always create a dedicated bot key**, never the maintainer's personal owner key. The bot key:
- Has its own `id` so you can revoke it without affecting other clients.
- Has its own audit-log entries so you can attribute `/v0/management/*` traffic to the bot.
- Can be rotated routinely (every 90 days etc.) without coordinating with the maintainer's daily-driver IDE configs.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `/olp status` returns 401 | bot key revoked / wrong / missing | Mint new key on OLP host; update `plugins.entries.olp.config.apiKey`; restart gateway |
| `/olp status` returns 403 | bot key is guest-tier, not owner-tier | Generate owner-tier key (`olp-keys keygen --owner --name=...`); update config |
| `OLP error: fetch failed` | `proxyUrl` unreachable from the gateway host | `curl http://<proxyUrl>/health` from the gateway host to confirm reachability; check firewall / OLP server `OLP_BIND=0.0.0.0` for LAN access |
| Bot free-text chat returns "Something went wrong" but `/olp ...` works | Default agent model points at a broken provider (e.g., a removed OCP install) | Check `agents.defaults.model.primary` in `openclaw.json`; update to `olp-claude/claude-sonnet-4-6` or another working provider |
| Free-text returns `HTTP 401: OLP API key is invalid` despite fresh key | Raw-string `apiKey: "olp_..."` shadowed by service-managed env clobber; or `headers.Authorization` bypassed for `gpt-*` model ids | Switch `models.providers.<id>.apiKey` to env-var reference: `"${OLP_OPENCLAW_BOT_TOKEN}"` (see § Gotchas: Auth) |
| OLP audit shows `key_id=__anonymous__` for traffic that should be owner-attributed | Same root cause as 401 — raw-string apiKey or headers bypassed in some routing paths | Switch to env-var-reference `apiKey: "${VAR}"` pattern + verify `launchctl getenv OLP_OPENCLAW_BOT_TOKEN` returns the expected token |
| Bot routes to ChatGPT account directly, not through OLP | Provider config uses OpenClaw stock `openai-codex` instead of a custom OLP-pointing provider | Add `olp-codex` provider per § Using codex / OpenAI models through OLP |
| `/models olp-codex/gpt-5.5` typed inline doesn't work | OpenClaw `/models` is menu-only, doesn't accept inline names | Type `/models`, tap the model from the picker menu that appears |
## Cross-references
- [`olp-plugin/README.md`](../../olp-plugin/README.md) — full plugin docs.
- [ADR 0010 § Phase 4 D71-D73](../adr/0010-phase-4-charter-operator-and-client-ux.md) — the plugin's charter.
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor (includes mutating subcommands that OLP deliberately drops).
+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
(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
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
+655
View File
@@ -0,0 +1,655 @@
# OLP Cloud Deployment Plan — Family Testing Phase
**Status:** Draft — pending current Phase 6 completion
**Target:** Oracle Cloud VM (existing infrastructure)
**Audience:** Project maintainer deployment reference
**Scope:** Single-VM deployment for family (35 users), spawn-binary architecture, public internet exposure with hardened auth
---
## 0. Prerequisites
- OLP current phase (Phase 6) is closed and tagged
- Oracle Cloud VM accessible via SSH (existing `opc` user)
- Domain name (optional but strongly recommended for TLS)
- Provider CLI OAuth completed on at least one machine (credentials transferable)
---
## 1. Architecture Overview
```
┌────────────────────────────────────────────────────────────────────┐
│ Family Devices (anywhere on internet) │
│ │
│ Wife iPad / Kid Laptop / Maintainer MacBook / ... │
│ IDE: Cline / Continue.dev / Cursor / Aider / OpenClaw │
│ Config: OPENAI_BASE_URL=https://olp.example.com/v1 │
│ OPENAI_API_KEY=olp_<personal-key> │
└──────────────────────────┬─────────────────────────────────────────┘
│ HTTPS (TLS 1.3)
┌────────────────────────────────────────────────────────────────────┐
│ Oracle Cloud VM │
│ │
│ ┌─ iptables / OCI Security List ──────────────────────────────┐ │
│ │ ALLOW: TCP 443 (HTTPS) from 0.0.0.0/0 │ │
│ │ ALLOW: TCP 22 (SSH) from maintainer IP only │ │
│ │ DENY: everything else │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Nginx (reverse proxy + TLS termination) ───────────────────┐ │
│ │ :443 → TLS (Let's Encrypt auto-renew via certbot) │ │
│ │ proxy_pass → http://127.0.0.1:4567 │ │
│ │ Rate limit: 30 req/min per IP (burst 10) │ │
│ │ Request body limit: 1MB │ │
│ │ Connection timeout: 300s (streaming needs long timeout) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ OLP server.mjs ───────────────────────────────────────────┐ │
│ │ OLP_BIND=127.0.0.1 (loopback only — Nginx fronts it) │ │
│ │ OLP_PORT=4567 │ │
│ │ auth.allow_anonymous: false │ │
│ │ auth.advertise_anonymous_key: false │ │
│ │ Per-key audit logging to ~/.olp/logs/audit.ndjson │ │
│ │ Owner key: maintainer only │ │
│ │ Guest keys: one per family member │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Provider CLIs (installed on this VM) ──────────────────────┐ │
│ │ claude → ~/.claude/.credentials.json (OAuth) │ │
│ │ codex → ~/.codex/auth.json (OAuth) │ │
│ │ vibe → ~/.vibe/.env (API key) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ systemd service ──────────────────────────────────────────┐ │
│ │ olp.service: auto-start, auto-restart on crash │ │
│ │ Runs as dedicated `olp` user (not root, not opc) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────┘
│ Provider CLIs spawn outbound HTTPS calls
Anthropic API / OpenAI API / Mistral API
```
---
## 2. Security Design (7 Layers)
### Layer 1 — Network Perimeter (OCI Security List + iptables)
**Principle:** Minimum attack surface. Only two ports reachable from the internet.
```
OCI Security List (stateful ingress rules):
┌──────────┬────────────┬───────────────────────────────┐
│ Port │ Protocol │ Source │
├──────────┼────────────┼───────────────────────────────┤
│ 443 │ TCP │ 0.0.0.0/0 (public HTTPS) │
│ 22 │ TCP │ <maintainer-IP>/32 only │
└──────────┴────────────┴───────────────────────────────┘
NOT exposed:
- Port 4567 (OLP direct) — Nginx fronts it
- Port 80 (HTTP) — only for certbot ACME challenge, redirect to 443
```
**iptables backup** (defense in depth — OCI Security List is primary, iptables is secondary):
```bash
# Drop everything by default
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD DROP
# Allow established connections
sudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT
# Allow SSH from maintainer IP only
sudo iptables -A INPUT -p tcp --dport 22 -s <MAINTAINER_IP> -j ACCEPT
# Allow HTTPS from anywhere
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
# Allow HTTP (certbot ACME only — Nginx redirects everything else)
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
# Persist
sudo iptables-save | sudo tee /etc/iptables/rules.v4
```
### Layer 2 — TLS Termination (Nginx + Let's Encrypt)
**Principle:** All client traffic encrypted. OLP itself runs plain HTTP on loopback — simpler, no cert management in Node.
```nginx
# /etc/nginx/sites-available/olp.conf
# Redirect HTTP → HTTPS
server {
listen 80;
server_name olp.example.com;
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS — TLS 1.3 only
server {
listen 443 ssl http2;
server_name olp.example.com;
# TLS config
ssl_certificate /etc/letsencrypt/live/olp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/olp.example.com/privkey.pem;
ssl_protocols TLSv1.3; # TLS 1.3 only
ssl_prefer_server_ciphers off; # TLS 1.3 manages its own
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
# Rate limiting (per IP)
limit_req zone=olp_limit burst=10 nodelay;
# Request body size (LLM prompts can be large but cap at 1MB)
client_max_body_size 1m;
# Proxy to OLP
location / {
proxy_pass http://127.0.0.1:4567;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SSE streaming support (critical for /v1/chat/completions)
proxy_set_header Connection '';
proxy_buffering off; # Don't buffer SSE
proxy_cache off;
chunked_transfer_encoding on;
# Long timeouts for LLM inference
proxy_connect_timeout 10s;
proxy_read_timeout 300s; # 5 min — long reasoning
proxy_send_timeout 300s;
}
}
# Rate limit zone definition (in http {} block of nginx.conf)
# limit_req_zone $binary_remote_addr zone=olp_limit:10m rate=30r/m;
```
**Certbot auto-renewal:**
```bash
sudo certbot certonly --webroot -w /var/www/certbot -d olp.example.com
# Auto-renew via systemd timer (certbot installs this automatically)
```
### Layer 3 — Application Auth (OLP Multi-Key)
**Principle:** Every request must carry a valid API key. No anonymous access. Per-key audit trail.
```json
// ~/.olp/config.json on the cloud VM
{
"auth": {
"allow_anonymous": false,
"advertise_anonymous_key": false,
"owner_only_endpoints": [
"/health",
"/v0/management/dashboard-data",
"/v0/management/quota",
"/v0/management/status",
"/cache/stats",
"/dashboard"
],
"fallback_detail_header_policy": "owner_only"
}
}
```
**Key provisioning plan:**
```
┌───────────────┬──────────┬─────────────────────────────────────┐
│ Key name │ Tier │ providers_enabled │
├───────────────┼──────────┼─────────────────────────────────────┤
│ cloud-owner │ owner │ all (dashboard + management access) │
│ wife-ipad │ guest │ anthropic, openai │
│ kid-laptop │ guest │ anthropic only (cost control) │
│ maintainer-mb │ guest │ all (daily driver, not owner tier) │
└───────────────┴──────────┴─────────────────────────────────────┘
```
**Why maintainer uses a guest key for daily driving:** owner key gives access to management endpoints. Routine IDE usage should not carry owner privilege. Owner key is used only for dashboard access and administration.
**Key lifecycle:**
- Keys generated on the cloud VM via `olp-keys keygen`
- Plaintext token communicated to family member via secure channel (Signal / iMessage, not email)
- Each key logged independently in audit.ndjson (per-key `key_id` field)
- Revocation: `olp-keys revoke --id=<key-id>` — immediate, no grace period
### Layer 4 — Process Isolation (Dedicated User + systemd)
**Principle:** OLP runs as a non-root, non-login user. Crash recovery is automatic.
```bash
# Create dedicated user
sudo useradd --system --shell /usr/sbin/nologin --home-dir /opt/olp olp
# OLP code
sudo mkdir -p /opt/olp
sudo git clone https://github.com/dtzp555-max/olp.git /opt/olp/app
sudo chown -R olp:olp /opt/olp
# OLP data (keys, config, logs, cache)
sudo mkdir -p /home/olp/.olp/{keys,logs,cache}
sudo chown -R olp:olp /home/olp
```
**systemd unit:**
```ini
# /etc/systemd/system/olp.service
[Unit]
Description=OLP — Open LLM Proxy
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=olp
Group=olp
WorkingDirectory=/opt/olp/app
ExecStart=/usr/bin/node server.mjs
# Environment
Environment=OLP_BIND=127.0.0.1
Environment=OLP_PORT=4567
Environment=NODE_ENV=production
Environment=HOME=/home/olp
# Auto-restart on crash
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=false
ReadWritePaths=/home/olp/.olp
PrivateTmp=true
# Resource limits
LimitNOFILE=65536
MemoryMax=1G
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=olp
[Install]
WantedBy=multi-user.target
```
### Layer 5 — Credential Protection (Provider OAuth Tokens)
**Principle:** OAuth tokens are the crown jewels. Stolen tokens = someone else using your Claude/OpenAI subscription.
```
Credential storage on cloud VM:
~olp/
├── .claude/
│ └── .credentials.json # chmod 600, owner=olp
├── .codex/
│ └── auth.json # chmod 600, owner=olp
└── .vibe/
└── .env # chmod 600, owner=olp
Security measures:
1. chmod 600 on all credential files (olp user only)
2. Credential files NOT in the git repo (already .gitignored)
3. No credential in env vars (OLP reads from filesystem)
4. Credential transfer: scp from local machine, then delete local copy of the scp command from shell history
5. Periodic rotation: re-auth quarterly (or on any suspicion of compromise)
```
**Credential transfer procedure:**
```bash
# FROM maintainer's Mac mini (one-time):
# 1. Claude credentials
scp ~/.claude/.credentials.json opc@<cloud-ip>:/tmp/claude-cred.json
ssh opc@<cloud-ip> "sudo mv /tmp/claude-cred.json /home/olp/.claude/.credentials.json && sudo chown olp:olp /home/olp/.claude/.credentials.json && sudo chmod 600 /home/olp/.claude/.credentials.json"
# 2. Codex credentials
scp ~/.codex/auth.json opc@<cloud-ip>:/tmp/codex-cred.json
ssh opc@<cloud-ip> "sudo mv /tmp/codex-cred.json /home/olp/.codex/auth.json && sudo chown olp:olp /home/olp/.codex/auth.json && sudo chmod 600 /home/olp/.codex/auth.json"
# 3. Mistral API key
ssh opc@<cloud-ip> "sudo -u olp bash -c 'echo MISTRAL_API_KEY=sk-xxx > ~/.vibe/.env && chmod 600 ~/.vibe/.env'"
# 4. Verify
ssh opc@<cloud-ip> "sudo -u olp node /opt/olp/app/bin/olp.mjs doctor --json" | jq '.checks[] | select(.name | contains("auth"))'
```
### Layer 6 — Audit and Monitoring
**Principle:** Every request logged. Anomalies detectable. No silent failures.
**Audit (already built into OLP):**
- `~/.olp/logs/audit.ndjson` — append-only, per-request, includes `key_id`, provider, model, cache hit/miss, fallback hops
- Daily rotation: `audit-YYYY-MM-DD.ndjson` (built-in, triggers on first append after UTC midnight)
- External rotation tool: `olp-audit-rotate` (idempotent, cron-safe)
**Additional monitoring for cloud deployment:**
```bash
# Cron: daily audit rotation (belt-and-suspenders alongside in-server rotation)
0 0 * * * /usr/bin/node /opt/olp/app/bin/olp-audit-rotate.mjs
# Cron: daily health check + alert
*/5 * * * * curl -sf -H "Authorization: Bearer $OLP_OWNER_KEY" https://olp.example.com/health > /dev/null || echo "OLP health check failed at $(date)" >> /home/olp/alerts.log
# Cron: audit log size check (alert if >100MB — suggests anomalous traffic)
0 6 * * * find /home/olp/.olp/logs -name 'audit*.ndjson' -size +100M -exec echo "Large audit log: {}" \; >> /home/olp/alerts.log
# Cron: disk usage check
0 6 * * * df -h / | awk 'NR==2 && $5+0 > 80 {print "Disk usage above 80%: "$5}' >> /home/olp/alerts.log
```
**What to watch for (manually, weekly):**
1. `olp-keys list` — any unexpected keys?
2. Dashboard (`/dashboard`) — unusual request volume? Unknown providers being hit?
3. `journalctl -u olp --since "7 days ago" | grep -c ERROR` — error spike?
4. Audit log: `grep "fallback" ~/.olp/logs/audit.ndjson | wc -l` — fallback frequency (high = provider instability)
### Layer 7 — Update and Recovery
**Principle:** Rollback within 60 seconds. No data loss on failed update.
**Update procedure:**
```bash
# SSH to cloud VM as opc
# 1. Snapshot before update (Oracle Cloud console or CLI)
# OCI CLI: oci compute boot-volume-backup create ...
# 2. Pull latest code
cd /opt/olp/app
sudo -u olp git fetch origin main
sudo -u olp git log --oneline HEAD..origin/main # review what's coming
# 3. Run tests BEFORE deploying
sudo -u olp git checkout main
sudo -u olp git pull
sudo -u olp node test-features.mjs
# STOP if tests fail
# 4. Restart service
sudo systemctl restart olp
sleep 3
sudo systemctl status olp # verify running
# 5. Smoke test
curl -sf -H "Authorization: Bearer $OLP_OWNER_KEY" https://olp.example.com/health | jq .ok
# Expect: true
```
**Rollback:**
```bash
# If update breaks things:
cd /opt/olp/app
sudo -u olp git checkout <previous-tag> # e.g. v0.6.0
sudo systemctl restart olp
```
**Backup (automated):**
```bash
# Cron: daily backup of OLP state (keys + config + recent audit)
0 3 * * * tar czf /home/opc/backups/olp-state-$(date +\%Y\%m\%d).tar.gz -C /home/olp .olp/keys .olp/config.json .olp/logs/audit.ndjson 2>/dev/null; find /home/opc/backups -name 'olp-state-*' -mtime +30 -delete
```
---
## 3. Implementation Checklist
Execute in order. Each step has a verification gate — do not proceed if the gate fails.
### Phase A — VM Preparation
```
[ ] A1. SSH to Oracle Cloud VM, verify Node.js >= 18
Gate: `node --version` prints v18+
[ ] A2. Create `olp` system user
Gate: `id olp` shows the user exists
[ ] A3. Clone OLP repo to /opt/olp/app
Gate: `sudo -u olp node /opt/olp/app/test-features.mjs` — all tests pass
[ ] A4. Install provider CLIs (as olp user)
- npm install -g @anthropic-ai/claude-code
- npm install -g @openai/codex
- (mistral vibe if needed)
Gate: `which claude && which codex` both resolve
[ ] A5. Transfer OAuth credentials (Layer 5 procedure)
Gate: `sudo -u olp claude auth status` shows authenticated
```
### Phase B — Security Hardening
```
[ ] B1. Configure OCI Security List (Layer 1)
Gate: nmap from external IP shows only 22 and 443 open
[ ] B2. Configure iptables backup (Layer 1)
Gate: `sudo iptables -L -n` matches the plan
[ ] B3. Install + configure Nginx (Layer 2)
Gate: `curl -I http://olp.example.com` returns 301 → HTTPS
[ ] B4. Obtain Let's Encrypt certificate
Gate: `curl -I https://olp.example.com` returns valid cert
[ ] B5. Verify Nginx SSE passthrough
Gate: test streaming request completes without timeout
```
### Phase C — OLP Configuration
```
[ ] C1. Write ~/.olp/config.json (Layer 3 — auth config)
Gate: config validates (no startup warnings in journal)
[ ] C2. Generate owner key
Gate: `olp-keys list --owner-only` shows 1 owner key
[ ] C3. Generate family guest keys (one per person)
Gate: `olp-keys list` shows correct count
[ ] C4. Install systemd unit (Layer 4)
Gate: `systemctl status olp` shows active (running)
[ ] C5. Verify /health with owner key
Gate: `curl -H "Authorization: Bearer $OWNER_KEY" https://olp.example.com/health | jq .ok` → true
[ ] C6. Verify /health rejects unauthenticated
Gate: `curl https://olp.example.com/health` → 401
[ ] C7. Verify guest key cannot access /dashboard
Gate: `curl -H "Authorization: Bearer $GUEST_KEY" https://olp.example.com/dashboard` → 403
[ ] C8. End-to-end LLM request with guest key
Gate: streaming chat completion returns a valid response
```
### Phase D — Monitoring Setup
```
[ ] D1. Install cron jobs (Layer 6)
Gate: `crontab -l` shows all 4 jobs
[ ] D2. Verify daily backup cron
Gate: manual trigger produces valid tar.gz
[ ] D3. Test health-check alert
Gate: stop OLP, wait 5min, check alerts.log has entry
```
### Phase E — Family Onboarding
```
[ ] E1. Send each family member their API key via Signal/iMessage
(NOT via email, NOT via any cloud-stored medium)
[ ] E2. Each family member configures their IDE:
export OPENAI_BASE_URL=https://olp.example.com/v1
export OPENAI_API_KEY=olp_<their-key>
[ ] E3. Each family member runs a test prompt
Gate: audit.ndjson shows their key_id in the log
[ ] E4. Verify per-key provider scoping
Gate: kid's key cannot hit providers outside their scope
```
---
## 4. Security Threat Model
| Threat | Mitigation | Residual Risk |
|---|---|---|
| **Brute-force API key** | 32-byte entropy = 2^256 keyspace; Nginx rate limit 30r/m | Negligible |
| **TLS downgrade** | TLS 1.3 only; HSTS header | None with modern clients |
| **Credential theft (OAuth tokens on VM)** | chmod 600 + dedicated user + no root access to OLP dirs | VM root compromise (mitigated by OCI IAM) |
| **Stolen guest key** | Single-key revocation via `olp-keys revoke`; per-key audit trail for forensics | Window between theft and detection |
| **DDoS** | OCI DDoS protection (free tier) + Nginx rate limit + Nginx connection limit | Sustained volumetric attack may overwhelm free-tier VM |
| **Provider credential abuse** | OLP is the only consumer; anomalous spend visible on provider dashboard | Provider-side detection lag |
| **Supply chain (OLP code tampered)** | Git clone from known repo; `npm test` before deploy; no npm dependencies | Compromised maintainer GitHub account |
| **Log exfiltration** | audit.ndjson contains no message content (PII guard per ADR 0008); only metadata | Key IDs in logs (low sensitivity) |
---
## 5. Operational Runbooks
### Runbook: OAuth Token Expired
```
Symptom: /health shows provider auth.ok=false; fallback firing on every request
Diagnosis: sudo -u olp claude auth status → "not authenticated" or expired
Fix:
1. sudo -u olp claude setup-token
2. Complete OAuth flow (browser URL → paste code)
3. Verify: sudo -u olp claude auth status → authenticated
4. No OLP restart needed — next spawn picks up new credentials
```
### Runbook: Revoke a Compromised Key
```
Symptom: suspicious traffic in audit.ndjson from a specific key_id
grep "<suspected-key-id>" ~/.olp/logs/audit.ndjson | tail -20
Fix:
1. olp-keys revoke --id=<key-id>
2. Notify family member: "Your key was revoked. Here's a new one."
3. olp-keys keygen --name=<new-name> --providers=<same-providers>
4. Send new key via secure channel
```
### Runbook: VM Disk Full
```
Symptom: OLP stops writing audit logs; new requests may fail
Diagnosis: df -h /
Fix:
1. Purge old audit logs: find ~/.olp/logs -name 'audit-202*.ndjson' -mtime +90 -delete
2. Purge old backups: find /home/opc/backups -name 'olp-state-*' -mtime +60 -delete
3. Purge cache if needed: rm -rf ~/.olp/cache/*
4. Verify: df -h / shows >20% free
```
### Runbook: OLP Process Crash Loop
```
Symptom: systemctl status olp shows "activating (auto-restart)"
Diagnosis: journalctl -u olp --since "10 min ago" | tail -50
Common causes:
- Port conflict → check `lsof -nP -iTCP:4567`
- Corrupt config.json → validate JSON syntax
- Node.js version drift → `node --version`
Fix:
1. Fix root cause
2. sudo systemctl restart olp
3. Gate: `curl -H "Authorization: Bearer $OWNER_KEY" https://olp.example.com/health | jq .ok`
```
---
## 6. Cost Estimate (Oracle Cloud Free Tier)
| Resource | Spec | Cost |
|---|---|---|
| VM | ARM Ampere A1 (4 OCPU, 24GB RAM) | **Free** (Always Free tier) |
| Boot volume | 200GB | **Free** (up to 200GB) |
| Outbound bandwidth | 10TB/month | **Free** (first 10TB) |
| Public IP | 1 reserved | **Free** |
| Domain | olp.example.com | ~$10/year (external registrar) |
| TLS cert | Let's Encrypt | **Free** |
| **Total** | | **~$10/year** (domain only) |
Oracle Cloud's Always Free ARM VM is overprovisioned for this use case. OLP + Nginx + 3 provider CLIs will use <1GB RAM and negligible CPU (the LLM inference happens at the provider, not here).
---
## 7. Migration Path to Commercial
This family deployment is a stepping stone. When commercial service is ready:
| Aspect | Family (this plan) | Commercial (future) |
|---|---|---|
| Upstream | spawn CLI (subscription) | direct API (commercial key) |
| Auth | OLP multi-key (filesystem) | Registration + billing system |
| TLS | Let's Encrypt (single domain) | Managed cert (Cloudflare / AWS ACM) |
| Compute | Single VM (Oracle Free) | Container cluster (auto-scale) |
| Monitoring | Cron + manual | Prometheus + Grafana + PagerDuty |
| Rate limit | Nginx per-IP | Per-key token bucket in OLP |
| Data | ~/.olp/ filesystem | PostgreSQL + S3 |
The deployment experience from this plan directly informs the commercial architecture. Every operational runbook becomes a feature requirement for the commercial platform.
---
**Authors:** project maintainer (with AI drafting assistance)
**Created:** 2026-05-27
+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).
+131
View File
@@ -0,0 +1,131 @@
# 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.** As of 2026-05-27, #1 (streaming SF, D57+D58), #2 (multi-key auth, Phase 2), #4, #7, and #8 are CLOSED. Remaining v1.x scope: #3 (soft trigger reactivation), #5 (provider cacheKeyFields mask), #6 (streaming SPAWN_FAILED salvage — unbundled from #1 at #1 close). All three remaining items have explicit "trigger to start" gates that have not fired.
---
## #1 — Streaming-path singleflight + TOCTOU close — ✅ **SHIPPED (D57 + D58, 2026-05-25)**
- **Status.** Closed. Trigger (b) fired 2026-05-25 — maintainer "go" after v0.3.1. Shipped across three D-days:
- **D57** (PR #36) — cache layer: `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts) → { stream, isFirst, role }` with `_streamingInflight` Map, tee fan-out, late-joiner replay buffer, per-client backpressure (`PER_CLIENT_QUEUE_CAP=1MB`), replay cap (`ACCUMULATED_REPLAY_CAP=10MB`), AbortController propagation, synchronous Map check+insert (closes TOCTOU). Suite 27 = 12 unit tests.
- **D58** (PR #37) — server.mjs wiring: streaming branch swap; `tryAcquireSpawn`/`releaseSpawn` moved inside `sourceFactory` closure (D38 §7 coordination); `CONCURRENCY_LIMIT` fallthrough preserved; `X-OLP-Streaming-Inflight: source | attached` header; `cache_status: 'streaming_attached'` audit value + audit-query gauge reconciliation; `res.on('close') → stream.return()` for client disconnect; D16 truncated-not-cached invariant preserved via `cacheStore.delete` on stop-less exhaustion. Suite 28 = 8 HTTP integration tests.
- **D59** (this commit) — docs polish: README known-limitations entry inverted; this roadmap entry closed; issue #16 closed.
- **Design authority.** [`docs/adr/0005-cache-cross-provider.md` Amendment 8](./adr/0005-cache-cross-provider.md) — implemented per spec §§114 across D57+D58.
- **Tracking issue.** GitHub issue [#16](https://github.com/dtzp555-max/olp/issues/16) — CLOSED at D59 with refs to D57+D58 PRs.
- **Final test count delta.** 603 (v0.3.1) → 623 (v0.3.2/v0.4.0). +20 tests across the SF arc.
- **Deferred sub-items** (left here as future-work pointers, NOT blocking #1 closure):
- `X-OLP-Streaming-Inflight: solo` value not emitted on the wire (Amendment 8 §11). It's observable only post-stream via the `streaming_inflight_source_done` log event's `attached_count: 0`. Future ADR amendment may expose via HTTP trailer.
- `streaming_inflight_join` log event from `_attachClient` cache-layer path (carries no provider/model context). D58 emits the event from the server-layer wrapper instead; cache-layer emission would need a provider/model plumb (TODO marker at `lib/cache/store.mjs:~620`).
- `isFirst` field returned by `getOrComputeStreaming` is currently unused by server.mjs (`role` supersedes). Could be removed in a future cache-layer API cleanup.
## #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.
- **Status update post-#1 close (2026-05-25).** #1 was originally bundled with #6 in the design ADR (Amendment 8). The tee architecture as implemented does NOT carry salvage semantics — D57's tee writes `accumulatedChunks` to cache only on normal source completion (stop chunk seen); on SPAWN_FAILED mid-stream the cache layer rejects all clients with the error and does NOT persist partial chunks. D58 preserves D16's truncated-not-cached invariant via server-layer `cacheStore.delete` on stop-less exhaustion. #6 therefore remains independently deferrable.
- **Design ADR.** Not yet ratified. The unbundling from #1 means #6 now needs its own ADR amendment when triggered.
- **Tracking.** Not a GitHub issue. Tracked here.
- **Trigger to start.** First report of streaming-path SPAWN_FAILED mid-stream where partial-chunk salvage would have helped a downstream caller. Practically unlikely at family scale.
## #8 — Dashboard enrichment: per-provider subscription quota + reset times + 1-min refresh + manual refresh (D78 follow-up) — ✅ **CLOSED (D82, v0.5.0)**
- **Status.** Closed at D82 (Phase 5). `dashboard.html` restructured to Claude.ai-style per-provider rows rendering `quota_v2`. Closed by PR on branch `d82-dashboard-ui-claude-ai-style`; ships with v0.5.0. 60s quota auto-refresh + manual refresh button + visibilityState guard implemented. Graceful fallback to legacy `quota` field when server runs a pre-D81 build.
- **What.** Phase 3 dashboard (D51 `dashboard.html`, v0.3.0) shows: per-provider quota (currently always "n/a — no quota api"), last-24h request count + cache hit + fallback rate, 30d request-count sparkline, top fallback chains. **Maintainer request 2026-05-26 post-D78**: extend to show what each enabled provider's subscription is actually consuming, with reset times visible, refresh once per minute (current 30s is OK but maintainer specified 1min target), and a manual refresh button. Reference design: Claude.ai's own `claude.ai/settings/usage` page — current session bar with "Resets in 1hr 6min", weekly all-models bar with "Resets Sun 9:00 PM", per-model bar (Sonnet only), additional features (routine runs), usage credits + monthly spend limit + auto-reload toggle.
- **Why deferred.** v0.3.0/v0.4.x ships the dashboard frame but `provider.quotaStatus()` returns `null` in all three v0.1 plugins (anthropic / openai / mistral). The ratifying spec in ADR 0004 Amendment 2 punts `quotaStatus()` to v1.x ("soft trigger reactivation") — this dashboard ask is the **operator-facing reason** that work would land.
- **What this requires.** Per-provider plugin work + dashboard.html UI work + audit-query.mjs aggregation:
1. **`lib/providers/anthropic.mjs quotaStatus()`** — discover where the maintainer's Claude.ai subscription quota state is exposed. Candidates: (a) `claude` CLI command (e.g., `claude usage`) if Anthropic adds one — currently absent; (b) parsing the `claude-code` output for rate-limit error messages and caching state from headers; (c) hitting `api.anthropic.com/v1/.../usage` directly via the OAuth refresh token — not a documented endpoint, primary-source risk. ADR 0002 Rule 1 / Rule 5 require an authority citation before any implementation. Likely path: **wait until Anthropic publishes a documented endpoint**, OR derive from audit-side request counts only (no real quota truth, just "you sent N requests in the current 5h window").
2. **`lib/providers/openai.mjs quotaStatus()`** — codex CLI doesn't expose ChatGPT-subscription quota state. OpenAI rate-limit headers per request might be parseable but ADR 0004 Amendment 2 explicitly says no plugin parses HTTP status at v0.1.
3. **`lib/providers/mistral.mjs quotaStatus()`** — Le Chat Pro has `/v1/usage` endpoint per Mistral docs (verify).
4. **`dashboard.html` UI restructure** to a Claude.ai-style layout: rows of (label, bar, "Resets in X" / "Resets at <day-of-week> <time>", percent). Add a manual refresh button + change auto-poll from 30s → 60s. Optionally a usage-credits / per-key spend display if Phase 5 ships per-key cost weights.
5. **`lib/audit-query.mjs`** — extend `aggregateRequests` / `spendTrendDaily` to compute "in the current rolling window" (since session/week start) per provider. Today's aggregates are wall-clock windows; subscription resets are per-account-anchored. Need a way to model session windows (e.g., "Anthropic 5h-from-first-request-since-last-reset").
- **Reference (maintainer 2026-05-26).** Screenshot of `claude.ai/settings/usage` shared inline. Key panels: Plan usage limits (current session + resets-in), Weekly limits (All models / Sonnet only / per-feature breakdown, each with resets-on), Additional features (Daily included routine runs N / 15), Usage credits (toggle + spent vs monthly limit + auto-reload + buy-credits link).
- **Tracking.** Not yet a GitHub issue. Track here + cross-reference ADR 0004 Amendment 2 (soft trigger reactivation — same `quotaStatus()` data-source work) when this becomes Phase 5 scope.
- **Code anchors today.**
- `dashboard.html` — current 4 panels; needs restructure to Claude.ai-style row layout
- `lib/providers/anthropic.mjs` / `openai.mjs` / `mistral.mjs``quotaStatus()` returns null today
- `lib/audit-query.mjs` — current `aggregateRequests` is wall-clock-window; needs session-window variant
- **Trigger to start.** ANY of: (a) Anthropic publishes a documented `claude usage` CLI or `api.anthropic.com/v1/usage` endpoint, (b) maintainer hits real "I want to see quota right now" pain often enough to design without per-provider truth (audit-derived only), (c) Phase 5 multi-tenant adds per-key spend limits and the dashboard needs to surface those.
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up) — ✅ **CLOSED (D56, 2026-05-27)**
- **Status.** Closed. Test shipped at D56 (PR `f4-cli-plugin-quota-v2-plus-auth-missing-test`, 2026-05-27). Test: `test-features.mjs` line 6255 — `'engine: AUTH_MISSING terminates chain, fallbackDetail tuple records trigger_type:"auth_missing" (D56, v1.x roadmap #7)'`. Asserts: `result.fallbackDetail[0].code === 'AUTH_MISSING'`, `result.fallbackDetail[0].trigger_type === 'auth_missing'`, `result.fallbackHops === 0` (no advance). The test was already present in the file before this PR closed the roadmap entry.
- **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.
+694
View File
@@ -0,0 +1,694 @@
/**
* lib/audit-query.mjs OLP audit ndjson aggregate query layer (Phase 3 / D49)
*
* Authority: ADR 0008 § 4 (query API surface) + § 5 (rotation file naming) +
* § 3 (storage layout). Reads `~/.olp/logs/audit.ndjson` (live) +
* `audit-YYYY-MM-DD.ndjson` (rotated dailies) and returns aggregate
* summaries shaped for the Dashboard endpoints (D50).
*
* Query model (ADR 0008 Lane 2 = A): in-memory scan per request. O(N) where
* N = total lines in the date range. Family-scale acceptable; SQLite hybrid
* (ADR 0007 § 13) is the documented forward path when N+queries get slow.
*
* PII discipline (ADR 0007 § 8 + ADR 0008 § 4.3): event shape is hash + shape
* only no message content, no response content, no raw tokens. This module
* MUST NOT introduce derived fields that reveal content. Every aggregate
* function asserts the input event has the expected shape but does NOT inspect
* or relay message bodies.
*
* What is NOT in this module (intentional split):
* - Daily rotation trigger (D52, lib/audit.mjs extension)
* - Server endpoints that consume these queries (D50, server.mjs)
* - Dashboard HTML / DOM render (D51, dashboard.html)
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
// ── Constants ─────────────────────────────────────────────────────────────
const DEFAULT_OLP_HOME = join(homedir(), '.olp');
const OLP_HOME_ENV = 'OLP_HOME';
const LIVE_AUDIT_FILE = 'audit.ndjson';
const ROTATED_FILE_PATTERN = /^audit-(\d{4}-\d{2}-\d{2})\.ndjson$/;
// ── Path helpers ──────────────────────────────────────────────────────────
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;
}
function _logsDir(opts) {
return join(_resolveOlpHome(opts), 'logs');
}
/**
* Returns the UTC-date string (YYYY-MM-DD) for an ISO-8601 timestamp.
*/
function _utcDateString(isoTs) {
if (typeof isoTs !== 'string' || isoTs.length < 10) return null;
return isoTs.slice(0, 10);
}
/**
* Returns the UTC-date string for an epoch-ms.
*/
function _utcDateFromMs(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
/**
* Inclusive range of UTC date strings from startDate to endDate (both
* YYYY-MM-DD). Returns the list in ascending order. Safe for spans up to
* several years (no upper bound enforced caller's responsibility).
*/
function _dateRange(startDate, endDate) {
const dates = [];
const cur = new Date(`${startDate}T00:00:00Z`);
const end = new Date(`${endDate}T00:00:00Z`);
while (cur <= end) {
dates.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return dates;
}
// ── File enumeration ──────────────────────────────────────────────────────
/**
* Discover audit files in the logs directory. Returns a Map from
* date-string ('YYYY-MM-DD' or 'live' for the un-rotated file) to absolute
* file path. The 'live' entry is `audit.ndjson` if present; date-string
* entries are the rotated daily files matching `audit-YYYY-MM-DD.ndjson`.
*
* Returns an empty Map if the logs directory does not exist or is empty.
* Caller responsible for date filtering.
*
* @param {object} [opts] - { olpHome }
* @returns {Map<string, string>} date-string absolute file path
*/
export function discoverAuditFiles(opts = {}) {
const dir = _logsDir(opts);
const out = new Map();
if (!existsSync(dir)) return out;
let entries;
try { entries = readdirSync(dir); } catch { return out; }
for (const name of entries) {
if (name === LIVE_AUDIT_FILE) {
out.set('live', join(dir, name));
continue;
}
const m = ROTATED_FILE_PATTERN.exec(name);
if (m) {
out.set(m[1], join(dir, name));
}
}
return out;
}
// ── Line-level read + parse ───────────────────────────────────────────────
/**
* Parse a single ndjson line. Returns the event object on success, or
* null on parse error. Caller logs warn for null returns.
*/
function _parseLine(line) {
if (!line) return null;
try {
const obj = JSON.parse(line);
if (typeof obj !== 'object' || obj === null) return null;
return obj;
} catch {
return null;
}
}
/**
* Read all events from a single file, skipping malformed lines.
* Logs warn (via logEvent override or console) for each malformed line so
* a corrupted day doesn't kill the query.
*
* @param {string} path
* @param {(level: string, event: string, data?: object) => void} [logEvent]
* @returns {Array<object>} parsed events
*/
function _readFileEvents(path, logEvent) {
let raw;
try {
raw = readFileSync(path, 'utf-8');
} catch (err) {
// Re-throw read errors (EACCES, ENOENT during race) so the dashboard
// endpoint surfaces 500 with diagnostic per ADR 0008 § 4.4.
throw new Error(`audit_query_read_failed: ${path}: ${err?.message ?? err}`);
}
const lines = raw.split('\n');
const events = [];
let skipped = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const ev = _parseLine(line);
if (ev === null) {
skipped++;
continue;
}
events.push(ev);
}
if (skipped > 0 && logEvent) {
logEvent('warn', 'audit_query_skip_malformed', { path, skipped });
}
return events;
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Iterate all audit events in [startMs, endMs). Walks the rotated daily
* files whose date overlaps the range + today's live audit.ndjson. Within
* each file, includes only events whose `ts` falls in the window.
*
* Per ADR 0008 § 4.2: window semantics are half-open [start, end).
*
* @param {object} args
* @param {number} args.startMs - epoch-ms inclusive lower bound
* @param {number} args.endMs - epoch-ms exclusive upper bound
* @param {string} [args.olpHome]
* @param {(level: string, event: string, data?: object) => void} [args.logEvent]
* @yields {object} parsed audit event
*/
export function* readAuditWindow({ startMs, endMs, olpHome, logEvent } = {}) {
if (typeof startMs !== 'number' || typeof endMs !== 'number') {
throw new Error('readAuditWindow: startMs and endMs (numbers) are required');
}
if (endMs <= startMs) return; // empty window
const files = discoverAuditFiles({ olpHome });
if (files.size === 0) return;
// Walk all dates in [startMs, endMs) plus the live file (today).
const startDate = _utcDateFromMs(startMs);
const endDate = _utcDateFromMs(endMs - 1); // endMs is exclusive
const dateList = _dateRange(startDate, endDate);
for (const date of dateList) {
const path = files.get(date);
if (!path) continue;
const events = _readFileEvents(path, logEvent);
for (const ev of events) {
const tsStr = ev.ts;
if (typeof tsStr !== 'string') continue;
const tsMs = Date.parse(tsStr);
if (Number.isNaN(tsMs)) continue;
if (tsMs >= startMs && tsMs < endMs) yield ev;
}
}
// Live file (today) — always check; date may overlap window's end.
const livePath = files.get('live');
if (livePath) {
const events = _readFileEvents(livePath, logEvent);
for (const ev of events) {
const tsStr = ev.ts;
if (typeof tsStr !== 'string') continue;
const tsMs = Date.parse(tsStr);
if (Number.isNaN(tsMs)) continue;
if (tsMs >= startMs && tsMs < endMs) yield ev;
}
}
}
/**
* Aggregate request shape over a rolling window ending at "now".
*
* Returns:
* {
* window: { startMs, endMs },
* request_count, status_2xx, status_4xx, status_5xx,
* by_provider: { [providerKey]: { count, cache_hit, cache_miss, cache_bypass, cache_streaming_attached, fallback_count } },
* by_owner_tier: { owner: N, guest: N, anonymous: N },
* by_path: { '/v1/chat/completions': N, '/v1/models': N, ... },
* median_latency_ms, p95_latency_ms,
* }
*
* Per ADR 0008 § 4.1 + § 4.3 PII discipline: aggregates count + categorical
* breakdowns only, NEVER message content.
*
* @param {object} args
* @param {number} args.windowMs - duration in ms; window = [now - windowMs, now)
* @param {string} [args.olpHome]
* @param {(level, event, data?) => void} [args.logEvent]
* @param {() => number} [args._nowFn] - injectable for testing
*/
export function aggregateRequests({ windowMs, olpHome, logEvent, _nowFn } = {}) {
if (typeof windowMs !== 'number' || windowMs <= 0) {
throw new Error('aggregateRequests: windowMs (positive number) is required');
}
const now = (_nowFn ?? Date.now)();
const startMs = now - windowMs;
const endMs = now;
const result = {
window: { startMs, endMs },
request_count: 0,
status_2xx: 0,
status_4xx: 0,
status_5xx: 0,
by_provider: {},
by_owner_tier: { owner: 0, guest: 0, anonymous: 0 },
by_path: {},
median_latency_ms: 0,
p95_latency_ms: 0,
};
const latencies = [];
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
result.request_count++;
// Status code bucket
const sc = typeof ev.status_code === 'number' ? ev.status_code : 0;
if (sc >= 200 && sc < 300) result.status_2xx++;
else if (sc >= 400 && sc < 500) result.status_4xx++;
else if (sc >= 500) result.status_5xx++;
// By provider
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
const p = result.by_provider[ev.provider] ??= {
count: 0, cache_hit: 0, cache_miss: 0, cache_bypass: 0, cache_streaming_attached: 0, fallback_count: 0,
};
p.count++;
if (ev.cache_status === 'hit') p.cache_hit++;
else if (ev.cache_status === 'miss') p.cache_miss++;
else if (ev.cache_status === 'bypass') p.cache_bypass++;
// D58 — ADR 0005 Amendment 8 §11 + lib/audit.mjs cache_status enum: streaming
// singleflight joiners (attached) share the source spawn but did not hit a
// cache. Tracked separately so `count` and `cache_hit + cache_miss +
// cache_bypass + cache_streaming_attached` reconcile.
else if (ev.cache_status === 'streaming_attached') p.cache_streaming_attached++;
if (typeof ev.fallback_hops === 'number' && ev.fallback_hops > 0) p.fallback_count++;
}
// By owner tier
if (ev.owner_tier === 'owner') result.by_owner_tier.owner++;
else if (ev.owner_tier === 'guest') result.by_owner_tier.guest++;
else result.by_owner_tier.anonymous++;
// By path
if (typeof ev.path === 'string' && ev.path.length > 0) {
result.by_path[ev.path] = (result.by_path[ev.path] ?? 0) + 1;
}
// Latency
if (typeof ev.latency_ms === 'number' && ev.latency_ms >= 0) {
latencies.push(ev.latency_ms);
}
}
// Median + p95 over sorted latencies
if (latencies.length > 0) {
latencies.sort((a, b) => a - b);
const midIdx = Math.floor(latencies.length / 2);
result.median_latency_ms = latencies.length % 2 === 0
? Math.round((latencies[midIdx - 1] + latencies[midIdx]) / 2)
: latencies[midIdx];
const p95Idx = Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95));
result.p95_latency_ms = latencies[p95Idx];
}
return result;
}
/**
* Top-N fallback chains by trigger count in window. A "chain" is the
* `tried_providers` array from an event with fallback_hops > 0. Returns
* sorted array descending by count; ties broken by earliest first_seen.
*
* [{ chain: ['anthropic', 'openai'], count: 42, first_seen, last_seen }, ...]
*
* @param {object} args
* @param {number} args.windowMs
* @param {number} [args.limit=10]
* @param {string} [args.olpHome]
* @param {(level, event, data?) => void} [args.logEvent]
* @param {() => number} [args._nowFn]
*/
export function topFallbackChains({ windowMs, limit = 10, olpHome, logEvent, _nowFn } = {}) {
if (typeof windowMs !== 'number' || windowMs <= 0) {
throw new Error('topFallbackChains: windowMs (positive number) is required');
}
const now = (_nowFn ?? Date.now)();
const startMs = now - windowMs;
const endMs = now;
// Map chain-key (joined string) → aggregate
const chains = new Map();
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
if (typeof ev.fallback_hops !== 'number' || ev.fallback_hops <= 0) continue;
if (!Array.isArray(ev.tried_providers) || ev.tried_providers.length < 2) continue;
const key = ev.tried_providers.join('→');
const entry = chains.get(key);
const ts = typeof ev.ts === 'string' ? ev.ts : null;
if (entry === undefined) {
chains.set(key, {
chain: [...ev.tried_providers],
count: 1,
first_seen: ts,
last_seen: ts,
});
} else {
entry.count++;
if (ts && (!entry.first_seen || ts < entry.first_seen)) entry.first_seen = ts;
if (ts && (!entry.last_seen || ts > entry.last_seen)) entry.last_seen = ts;
}
}
// Sort desc by count, ascending by first_seen on ties
const arr = [...chains.values()];
arr.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
if (a.first_seen && b.first_seen) return a.first_seen < b.first_seen ? -1 : a.first_seen > b.first_seen ? 1 : 0;
return 0;
});
return arr.slice(0, limit);
}
/**
* Daily series of request_count + median latency_ms + by_provider over N
* UTC days ending today. Sparse-fills zero-request days. Returns ascending
* by date:
*
* [{ date: '2026-05-22', request_count, median_latency_ms, by_provider }, ...]
*
* by_provider is { [providerKey]: count } per day.
*
* @param {object} args
* @param {number} args.days
* @param {string} [args.olpHome]
* @param {(level, event, data?) => void} [args.logEvent]
* @param {() => number} [args._nowFn]
*/
export function spendTrendDaily({ days, olpHome, logEvent, _nowFn } = {}) {
if (typeof days !== 'number' || days <= 0) {
throw new Error('spendTrendDaily: days (positive number) is required');
}
const now = (_nowFn ?? Date.now)();
// Compute the N UTC dates ending today (inclusive). Semantics: "last N
// calendar dates ending today" — NOT "events within a rolling N*86400-ms
// window ago" (the latter would span N+1 distinct UTC dates and produce
// off-by-one buckets at non-midnight call times).
const dates = [];
for (let i = days - 1; i >= 0; i--) {
dates.push(_utcDateFromMs(now - i * 86400 * 1000));
}
// Window covers the start of the first date through "now" so readAuditWindow
// sees every event whose ts falls in any of the N dates' UTC days.
const startMs = Date.parse(`${dates[0]}T00:00:00Z`);
const endMs = now;
// Bucket by UTC date
const buckets = new Map();
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
const date = _utcDateString(ev.ts);
if (!date) continue;
const b = buckets.get(date) ?? { request_count: 0, latencies: [], by_provider: {} };
b.request_count++;
if (typeof ev.latency_ms === 'number') b.latencies.push(ev.latency_ms);
if (typeof ev.provider === 'string' && ev.provider.length > 0) {
b.by_provider[ev.provider] = (b.by_provider[ev.provider] ?? 0) + 1;
}
buckets.set(date, b);
}
// Sparse-fill using the precomputed dates list (preserves ascending order)
return dates.map(date => {
const b = buckets.get(date);
if (b) {
b.latencies.sort((a, b) => a - b);
const midIdx = Math.floor(b.latencies.length / 2);
const median = b.latencies.length === 0 ? 0
: b.latencies.length % 2 === 0
? Math.round((b.latencies[midIdx - 1] + b.latencies[midIdx]) / 2)
: b.latencies[midIdx];
return {
date,
request_count: b.request_count,
median_latency_ms: median,
by_provider: b.by_provider,
};
}
return { date, request_count: 0, median_latency_ms: 0, by_provider: {} };
});
}
/**
* Normalize a single quotaStatus() return value from the anthropic plugin into
* the dashboard-friendly shape (D81 / ADR 0008 Amendment).
* Provider-specific: called only for 'anthropic'. Returns null if the raw
* shape is absent or malformed.
*
* v0.5.1: handles probe_status field (F3 ADR 0013 Rule 6).
* Accepts both old shape (stale: boolean) and new shape (probe_status: string).
*
* @internal used by aggregateProviderQuota()
*/
function _normalizeAnthropicQuota(raw) {
if (!raw || typeof raw !== 'object') return null;
const f = raw.fields ?? {};
// v0.5.1: probe_status field (new) takes precedence; fall back to stale bool for compat.
const probeStatus = raw.probe_status ?? (raw.stale === true ? 'stale' : 'live');
return {
schema_version: raw.schemaVersion ?? null,
last_fresh_at: (probeStatus === 'stale')
? (raw.last_fresh_at ?? null)
: (raw.probedAt ?? null),
utilization: probeStatus === 'unreachable' ? null : {
'5h': f.utilization_5h ?? null,
'7d': f.utilization_7d ?? null,
},
reset: probeStatus === 'unreachable' ? null : {
'5h': f.reset_5h ?? null,
'7d': f.reset_7d ?? null,
overall: f.reset ?? null,
overage: f.overage_reset ?? null,
},
representative_claim: f.representative_claim ?? null,
fallback_percentage: f.fallback_percentage ?? null,
overage: probeStatus === 'unreachable' ? null : {
status: f.overage_status ?? null,
disabled_reason: f.overage_disabled_reason ?? null,
},
raw_available: (typeof raw.raw === 'object' && raw.raw !== null),
// v0.5.1 (F3 — ADR 0013 Rule 6): failure detail for operator diagnostics
failure: raw.failure ?? null,
failure_kind: raw.failure?.kind ?? null,
backoff_until: raw.failure?.backoff_until ?? null,
};
}
/**
* Aggregate per-provider quota status into a normalized dashboard-friendly
* shape. This is the D81 Phase 5 extension of lib/audit-query.mjs per
* ADR 0008 Amendment (D81).
*
* For each loaded provider, calls quotaStatus() (already cached at the plugin
* layer per ADR 0013 Rule 3) and normalizes to a consistent shape. Providers
* returning null (codex, mistral) produce a { status: 'unavailable' } row.
*
* Audit-query stays in-memory scan per ADR 0008 Lane 2 = A. This function
* does NOT scan the ndjson files; it calls the live provider plugins.
*
* Authority: ADR 0008 Amendment (D81) + ADR 0012 D81 + ADR 0013 Rule 5.
*
* @param {object} args
* @param {Map<string, object>} args.providers - Map of provider name plugin object
* @param {(name: string) => Promise<object|null>} [args.getQuotaStatus] - injectable for tests;
* defaults to calling providers.get(name).quotaStatus?.()
* @returns {Promise<Array<{
* provider: string,
* status: 'live' | 'stale' | 'unavailable' | 'disabled',
* reason?: string,
* schema_version: string | null,
* last_fresh_at: number | null,
* utilization: { '5h': number|null, '7d': number|null } | null,
* reset: { '5h': number|null, '7d': number|null, overall: number|null, overage: number|null } | null,
* representative_claim: string | null,
* fallback_percentage: number | null,
* overage: { status: string|null, disabled_reason: string|null } | null,
* raw_available: boolean,
* }>>}
*/
export async function aggregateProviderQuota({
providers,
getQuotaStatus,
} = {}) {
if (!providers) {
throw new Error('aggregateProviderQuota: providers (Map) is required');
}
// Normalize the providers argument — accept both Map and plain object.
const providerEntries = (providers instanceof Map)
? [...providers.entries()]
: Object.entries(providers);
const results = [];
for (const [name, plugin] of providerEntries) {
// Default getter: call the plugin's quotaStatus() if present.
const fetchQuota = getQuotaStatus
? () => getQuotaStatus(name)
: () => (typeof plugin?.quotaStatus === 'function' ? plugin.quotaStatus(null) : Promise.resolve(null));
let rawResult = null;
let callError = null;
try {
rawResult = await fetchQuota();
} catch (err) {
callError = err?.message ?? String(err);
}
if (callError !== null) {
// quotaStatus() threw — treat as error / unavailable.
results.push({
provider: name,
status: 'unavailable',
reason: callError,
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
});
continue;
}
if (rawResult === null || rawResult === undefined) {
// Plugin returned null: opt-in disabled (the ONLY case per v0.5.1 contract)
// or providers with no quota API at all (codex, mistral).
results.push({
provider: name,
status: 'unavailable',
reason: 'no public quota api or probe disabled',
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
failure: null,
failure_kind: null,
backoff_until: null,
});
continue;
}
// quotaStatus() returned a non-null shape — normalize.
// v0.5.1: handle probe_status field (live/stale/unreachable).
// Currently only 'anthropic' returns a structured shape; other providers
// returning structured data will work if their shape is compatible.
const probeStatus = rawResult.probe_status ?? (rawResult.stale === true ? 'stale' : 'live');
const normalized = _normalizeAnthropicQuota(rawResult);
if (normalized === null) {
// Shape was present but unrecognizable.
results.push({
provider: name,
status: 'unavailable',
reason: 'unrecognized quota shape',
schema_version: null,
last_fresh_at: null,
utilization: null,
reset: null,
representative_claim: null,
fallback_percentage: null,
overage: null,
raw_available: false,
failure: null,
failure_kind: null,
backoff_until: null,
});
continue;
}
// Map probe_status to output status:
// 'live' → 'live'
// 'stale' → 'stale'
// 'unreachable' → 'unreachable' (new in v0.5.1; dashboard renders with red border)
const outputStatus = probeStatus === 'unreachable' ? 'unreachable'
: probeStatus === 'stale' ? 'stale'
: 'live';
results.push({
provider: name,
status: outputStatus,
...normalized,
});
}
return results;
}
/**
* Audit-derived cache hit rate over the window. Differs from
* `cacheStore.stats()` in server.mjs: that is the live in-process counter;
* this is the audit-side rate scoped to the rolling window.
*
* { window: { startMs, endMs }, total, hit, miss, bypass, streaming_attached, hit_rate, by_provider }
*
* `streaming_attached` (D58, ADR 0005 Amendment 8 §11): D58 streaming
* singleflight joiners did not hit a literal cache, so they are excluded
* from both numerator AND denominator of `hit_rate`. Tracked separately
* so the count reconciles with `total = hit + miss + bypass + streaming_attached`.
*
* @param {object} args
* @param {number} args.windowMs
* @param {string} [args.olpHome]
* @param {(level, event, data?) => void} [args.logEvent]
* @param {() => number} [args._nowFn]
*/
export function cacheHitRateWindow({ windowMs, olpHome, logEvent, _nowFn } = {}) {
if (typeof windowMs !== 'number' || windowMs <= 0) {
throw new Error('cacheHitRateWindow: windowMs (positive number) is required');
}
const now = (_nowFn ?? Date.now)();
const startMs = now - windowMs;
const endMs = now;
let total = 0, hit = 0, miss = 0, bypass = 0, streaming_attached = 0;
const by_provider = {};
for (const ev of readAuditWindow({ startMs, endMs, olpHome, logEvent })) {
if (ev.cache_status === null || ev.cache_status === undefined) continue;
total++;
const p = typeof ev.provider === 'string' && ev.provider.length > 0 ? ev.provider : '__unknown__';
const pe = by_provider[p] ??= { total: 0, hit: 0, miss: 0, bypass: 0, streaming_attached: 0, hit_rate: 0 };
pe.total++;
if (ev.cache_status === 'hit') { hit++; pe.hit++; }
else if (ev.cache_status === 'miss') { miss++; pe.miss++; }
else if (ev.cache_status === 'bypass') { bypass++; pe.bypass++; }
// D58 — ADR 0005 Amendment 8 §11: streaming singleflight joiners.
// Excluded from hit_rate numerator + denominator (they did not hit a
// literal cache); tracked so `total` reconciles.
else if (ev.cache_status === 'streaming_attached') { streaming_attached++; pe.streaming_attached++; }
}
// Compute hit_rate per provider + overall (excludes bypass from denominator
// since bypass-by-cache_control is intentional non-cacheable, not a cache miss).
for (const p of Object.values(by_provider)) {
const denom = p.hit + p.miss;
p.hit_rate = denom > 0 ? p.hit / denom : 0;
}
const overallDenom = hit + miss;
const hit_rate = overallDenom > 0 ? hit / overallDenom : 0;
return {
window: { startMs, endMs },
total, hit, miss, bypass, streaming_attached, hit_rate, by_provider,
};
}
+296
View File
@@ -0,0 +1,296 @@
/**
* 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, renameSync, existsSync, statSync, readFileSync } 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
const LIVE_AUDIT_FILE = 'audit.ndjson';
const ROTATED_FILE_PREFIX = 'audit-';
const ROTATED_FILE_SUFFIX = '.ndjson';
let _dropCounter = 0;
let _rotateCounter = 0; // observability + test assertion target
let _rotateFailCounter = 0;
// Module-cached "last UTC date we saw at append time" so we don't read
// disk metadata on every append just to check for rotation.
let _lastSeenUtcDate = _utcDateNow();
/**
* 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;
}
/**
* Current UTC date as YYYY-MM-DD. Module-private; reused by rotation logic.
*/
function _utcDateNow() {
return new Date().toISOString().slice(0, 10);
}
/**
* Read the first event's `ts` from the live audit file to discover the
* date it was opened on (for stale-cache recovery when the process is
* restarted and the in-memory `_lastSeenUtcDate` doesn't match). Returns
* the YYYY-MM-DD string, or null if the file is missing / empty /
* malformed.
*/
function _firstEventDateInLiveFile(livePath) {
try {
if (!existsSync(livePath)) return null;
// Read the file + slice off the first ndjson line. For audit ndjson the
// first line is at most a few hundred bytes; this is fine for the rare
// "process restart with a stale live file" recovery path. (Family-scale
// single-file audit is bounded; multi-MB scans are not a real concern
// until Phase 4+ when Option 3 SQLite migration would kick in anyway.)
const raw = readFileSync(livePath, 'utf-8');
const nl = raw.indexOf('\n');
const firstLine = nl === -1 ? raw : raw.slice(0, nl);
if (!firstLine) return null;
const obj = JSON.parse(firstLine);
if (typeof obj?.ts !== 'string') return null;
return obj.ts.slice(0, 10);
} catch {
return null;
}
}
/**
* Rotate the live audit file (if any) to its UTC-date suffix. Idempotent:
* if a rotated file with that name already exists (e.g., cron beat us to
* it), this skips the rename.
*
* Per ADR 0008 § 5.1 + § 5.3. SYNCHRONOUS so callers (appendAuditEvent +
* external cron) can rely on it completing before the next IO operation.
* Sync rotation eliminates the race that an async wrapper would create
* between the date-change-detection and the append (where the append could
* land in the old un-rotated file).
*
* Concurrent in-process invocations: Node's single-threaded event loop
* serializes synchronous calls within a tick. Cross-tick concurrency
* uses the `_lastSeenUtcDate` cache as the gate once set, subsequent
* appends short-circuit the rotation check.
*
* @param {object} args - { olpHome, logEvent, _nowFn (test injection) }
* @returns {{ rotated: boolean, fromPath?: string, toPath?: string, dateUsed?: string }}
*/
export function _maybeRotateAudit(args = {}) {
const olpHome = _resolveOlpHome(args);
const logEvent = args.logEvent ?? ((level, ev, data) => {
const entry = { ts: new Date().toISOString(), level, event: ev, ...(data ?? {}) };
process.stderr.write(JSON.stringify(entry) + '\n');
});
const nowFn = args._nowFn ?? (() => new Date());
const logsDir = join(olpHome, 'logs');
const livePath = join(logsDir, LIVE_AUDIT_FILE);
if (!existsSync(livePath)) {
// No live file yet (first append ever); no rotation needed.
_lastSeenUtcDate = nowFn().toISOString().slice(0, 10);
return { rotated: false };
}
// Determine the "date the live file holds" — use the first event's ts.
// Fall back to file mtime if events absent (corrupt / empty file edge).
let fileDate = _firstEventDateInLiveFile(livePath);
if (fileDate === null) {
try {
fileDate = statSync(livePath).mtime.toISOString().slice(0, 10);
} catch {
return { rotated: false };
}
}
const today = nowFn().toISOString().slice(0, 10);
if (fileDate === today) {
_lastSeenUtcDate = today;
return { rotated: false };
}
// Rotate: rename live → audit-<fileDate>.ndjson.
const rotatedName = `${ROTATED_FILE_PREFIX}${fileDate}${ROTATED_FILE_SUFFIX}`;
const rotatedPath = join(logsDir, rotatedName);
if (existsSync(rotatedPath)) {
// Cron or another writer beat us; the live file holding fileDate's
// events must be merged manually. Per ADR 0008 § 5.3 we log + skip.
logEvent('warn', 'audit_rotate_target_exists', {
livePath, rotatedPath,
message: 'rotation target exists — concurrent rotator beat in-process check; manual merge required if events overlap',
});
_lastSeenUtcDate = today;
return { rotated: false };
}
try {
renameSync(livePath, rotatedPath);
_rotateCounter++;
_lastSeenUtcDate = today;
logEvent('info', 'audit_rotated', { fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate });
return { rotated: true, fromPath: livePath, toPath: rotatedPath, dateUsed: fileDate };
} catch (err) {
_rotateFailCounter++;
logEvent('warn', 'audit_rotate_failed', {
livePath, rotatedPath,
error: err?.message ?? String(err),
});
// Don't update _lastSeenUtcDate so the next append retries.
return { rotated: false };
}
}
/**
* 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.
*
* cache_status enum (free-form string; not schema-validated at append):
* 'hit' served from cache (buffered-replay or streaming
* cache_hit role from ADR 0005 Amendment 8 §1).
* 'miss' buffered or streaming source path; provider
* spawn fired for this request.
* 'bypass' D2 cache_control bypass (no cache read/write).
* 'streaming_attached' D58 / ADR 0005 Amendment 8 §11: client joined
* an in-flight streaming source spawn from
* another caller; this request did NOT spawn a
* provider but also did NOT hit the cache (the
* cache was empty at the inflight Map check).
* null pre-chain error paths (the cache layer was
* never consulted; e.g. 401, 415, 400 IR).
* @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, LIVE_AUDIT_FILE);
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');
});
// D52: cheap fast-path date check. If the module-cached date matches the
// current UTC date, skip the rotation probe entirely (no disk I/O). If
// the date has changed, synchronously rotate BEFORE the append so the
// append lands in the (post-rotation) new live file rather than the
// about-to-rotate-away old one.
// Per ADR 0008 § 5.1: rotation fires "on the first append after a UTC
// date change." Synchronous rotation ensures no append straddles the
// boundary — old-date events land in the rotated file; new-date events
// land in the fresh live file. The cache flip happens INSIDE
// _maybeRotateAudit so concurrent in-process re-triggers short-circuit.
const today = _utcDateNow();
if (today !== _lastSeenUtcDate) {
_maybeRotateAudit({ olpHome, logEvent });
}
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;
}
/**
* Per-process count of successful rotations performed. Test + future
* observability surface.
*/
export function getAuditRotateCount() {
return _rotateCounter;
}
/**
* Per-process count of failed rotation attempts (rename threw, target
* existed, etc.). Test + future observability surface.
*/
export function getAuditRotateFailCount() {
return _rotateFailCounter;
}
/**
* Test-only: reset the rotation counters + the in-process "last seen UTC
* date" cache so each test exercises a fresh code path.
*/
export function __resetAuditRotateState() {
_rotateCounter = 0;
_rotateFailCounter = 0;
_lastSeenUtcDate = _utcDateNow();
}
/**
* Test-only: force the cached "last seen UTC date" to a specific value
* so the next appendAuditEvent observes a date change and triggers
* rotation deterministically.
*/
export function __setLastSeenUtcDateForTesting(dateStr) {
_lastSeenUtcDate = dateStr;
}
+616 -2
View File
@@ -51,6 +51,75 @@
* @property {number} inflightCount
*/
// ── D57 — ADR 0005 Amendment 8 streaming-singleflight shapes ──────────────
/**
* @typedef {Object} StreamingInflightEntry
* @property {string} compositeKey - `${keyId}\0${cacheKey}`
* @property {AsyncIterator<*>|null} source - underlying source iterator (null while factory pending)
* @property {AbortController} sourceAbortController - propagates "all clients gone" to the source
* @property {Array<*>} accumulatedChunks - late-joiner replay buffer (bounded by §10)
* @property {number} accumulatedByteSize - running byte size of accumulatedChunks
* @property {boolean} accumulatedReplayCapExceeded - true once §10 cap hit; cache write will skip
* @property {Set<AttachedClient>} attachedClients - all live clients tee'ing this source
* @property {boolean} factoryPending - true while sourceFactory() is awaited
* @property {Array<{ resolve: function, reject: function }>} pendingJoiners - late joiners arriving during factoryPending
* @property {boolean} sourceDone - source iterator exhausted normally
* @property {Error|null} sourceError - non-null if source threw
* @property {boolean} sourceAborted - true if AbortController fired
* @property {number} ttlMs - TTL to use when writing the completed accumulated chunks to cache
*/
/**
* @typedef {Object} AttachedClient
* @property {string} id - request id / correlator
* @property {Array<*>} queue - per-client tee buffer
* @property {number} queueByteSize - running byte size sum
* @property {boolean} yieldedAccumulated - true after late-joiner replay drained
* @property {boolean} done - terminal sentinel hit
* @property {boolean} backpressured - true once STREAM_BACKPRESSURE terminator scheduled
* @property {Error|null} error - non-null if source threw or replay-drain over-cap
* @property {((chunk: { value: *, done: boolean }) => void)|null} resolveNext - pending pull promise resolver
* @property {((err: Error) => void)|null} rejectNext - pending pull promise rejecter
*/
// ADR 0005 Amendment 8 §14 — implementation defaults. Per-call overrides flow
// via the `opts` argument to getOrComputeStreaming (used by tests to exercise
// caps cheaply without producing megabytes of fixture data).
export const PER_CLIENT_QUEUE_CAP_DEFAULT = 1 * 1024 * 1024;
export const ACCUMULATED_REPLAY_CAP_DEFAULT = 10 * 1024 * 1024;
/**
* Returns an approximate byte size for a chunk. The tee + cap math uses
* JSON.stringify length as the serialization yardstick (matches the cache
* store's existing size accounting via Buffer.byteLength(JSON.stringify(...))).
* Non-stringifiable values (circular, etc.) fall back to 0 the tee continues
* but the size accounting under-estimates that chunk. In practice IR chunks
* are always JSON-safe.
*
* @param {*} chunk
* @returns {number}
*/
function _chunkByteSize(chunk) {
try {
return Buffer.byteLength(JSON.stringify(chunk) ?? '', 'utf8');
} catch {
return 0;
}
}
/**
* Synthesises a STREAM_BACKPRESSURE terminator stream (ADR 0005 Amendment 8 §8):
* yields `{ type: 'stop', finish_reason: 'length' }` then a `[DONE]` sentinel
* then ends. Used both for late-joiner-too-late and per-client overflow paths.
*
* @returns {AsyncGenerator<*>}
*/
async function* _backpressureTerminator() {
yield { type: 'stop', finish_reason: 'length' };
yield '[DONE]';
}
// ── CacheStore ────────────────────────────────────────────────────────────
export class CacheStore {
@@ -82,6 +151,14 @@ export class CacheStore {
/** @type {Map<string, Promise<*>>} */
this._inflight = new Map();
// D57 — ADR 0005 Amendment 8: streaming singleflight per-(keyId,cacheKey)
// inflight Map. Composite key uses `\0` separator per §2 (avoids colliding
// with keyId or cacheKey content). The check + insert against this Map is
// synchronous in getOrComputeStreaming (no `await` between read & write),
// mirroring D38 `tryAcquireSpawn` atomicity.
/** @type {Map<string, StreamingInflightEntry>} */
this._streamingInflight = new Map();
// Stats per keyId
/** @type {Map<string, { hits: number, misses: number }>} */
this._stats = new Map();
@@ -265,6 +342,11 @@ export class CacheStore {
* @param {() => Promise<*>} computeFn - async function producing the value
* @param {number} [ttlMs]
* @returns {Promise<*>}
*
* D57 ADR 0005 Amendment 8 / issue #16 resolved at the cache layer:
* the sibling `getOrComputeStreaming` method below provides tee-fan-out +
* per-client backpressure for the streaming path. Server-side wiring lands
* separately in D58.
*/
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
// 1. Cache hit — return immediately, no singleflight overhead
@@ -303,6 +385,493 @@ export class CacheStore {
return computePromise;
}
/**
* D57 ADR 0005 Amendment 8 (issue #16): streaming singleflight + tee-fan-out.
*
* Streaming-path sibling of `getOrCompute`. Coordinates concurrent identical
* streaming requests so only one source spawn occurs, all attached clients
* receive identical chunk sequences in order, late joiners are replayed from
* an accumulated buffer, slow clients are disconnected with a synthetic
* STREAM_BACKPRESSURE terminator instead of stalling the source, and the
* source is aborted when all clients disconnect.
*
* The Map check + insert against `_streamingInflight` is synchronous no
* `await` between read and write matching the D38 `tryAcquireSpawn`
* atomicity invariant and collapsing the original TOCTOU window in
* `server.mjs:782` (peek + spawn). See §1 + §6.
*
* Three outcomes (Amendment 8 §1):
* - **cache_hit**: cached entry exists and is alive returns
* `{ stream: <async iterator over cached chunks>, isFirst: false,
* role: 'cache_hit' }`. No spawn. `hits` incremented.
* - **attached**: inflight entry exists in `_streamingInflight` attaches
* a new AttachedClient. Late-joiner replay (§5) drains accumulated
* chunks synchronously; if drain would exceed `perClientQueueCap` the
* client receives a STREAM_BACKPRESSURE synthesised stream instead.
* `isFirst: false`, `role: 'attached'`. `hits` incremented (sharing a
* spawn is functionally a "cache-like" benefit; documented choice).
* - **source**: no cache hit, no inflight entry the cache layer takes
* the inflight lock synchronously (placeholder entry inserted), then
* `await sourceFactory()`. If the factory throws (e.g.
* CONCURRENCY_LIMIT) the placeholder is removed and the error
* propagates. On success the source is wired up, the tee task starts,
* `isFirst: true`, `role: 'source'`. `misses` incremented.
*
* **`role` enum** (returned at attach-time):
* - `source` first caller; entry created. Lifetime-end may upgrade to
* `solo` (no joiners ever attached) at the server (D58); the cache
* layer reports `source` at attach-time and does not flip mid-stream.
* - `attached` joined an existing inflight entry.
* - `cache_hit` served from cache; no entry created.
*
* **Amendment 8 §11 header values**: the X-OLP-Streaming-Inflight header
* (D58) uses `source | attached | solo`. The cache layer's `cache_hit` is
* the "served from cache without inflight" case; D58 chooses whether to
* emit `solo` or omit the header for that path. The cache layer reports
* `cache_hit` as a distinct role so the server can disambiguate.
*
* **§14 defaults**: `PER_CLIENT_QUEUE_CAP = 1 MB`, `ACCUMULATED_REPLAY_CAP
* = 10 MB`. Both overridable via `opts` for cheap test exercise of the cap
* paths.
*
* @param {string} keyId
* @param {string} cacheKey
* @param {() => Promise<AsyncIterator<*>>|AsyncIterator<*>} sourceFactory
* Invoked exactly once per inflight lifetime (only on first caller).
* Returns the source async iterator. May throw (e.g. CONCURRENCY_LIMIT
* from `tryAcquireSpawn`); on throw, the inflight entry is removed and
* the error propagates to the first caller. Late joiners that attached
* while the factory was pending are rejected with the same error.
* @param {object} [opts]
* @param {string} [opts.clientId] - correlator id (defaults to incrementing counter)
* @param {number} [opts.ttlMs] - TTL for completed accumulated-chunks cache write
* @param {number} [opts.perClientQueueCap] - override §14 default (1 MB)
* @param {number} [opts.accumulatedReplayCap] - override §14 default (10 MB)
* @returns {Promise<{ stream: AsyncGenerator<*>, isFirst: boolean, role: 'source'|'attached'|'cache_hit' }>}
*/
async getOrComputeStreaming(keyId, cacheKey, sourceFactory, opts = {}) {
const compositeKey = `${keyId}\0${cacheKey}`;
const perClientQueueCap = opts.perClientQueueCap ?? PER_CLIENT_QUEUE_CAP_DEFAULT;
const accumulatedReplayCap = opts.accumulatedReplayCap ?? ACCUMULATED_REPLAY_CAP_DEFAULT;
const ttlMs = opts.ttlMs;
const clientId = opts.clientId ?? `c-${this._nowFn()}-${Math.floor(Math.random() * 1e9).toString(36)}`;
// ── (A) Inflight Map check FIRST (Amendment 8 §6 — TTL race) ───────────
// Late joiners that arrive after a cache entry has expired but during an
// active source spawn must still attach via the inflight Map rather than
// re-spawn. The synchronous Map.get + (if hit) Map preservation here
// satisfies the no-`await`-between-check-and-decision invariant.
const inflightEntry = this._streamingInflight.get(compositeKey);
if (inflightEntry) {
// Hits-as-share decision (documented at method header): sharing a spawn
// is a cache-like benefit; increment hits for consistency with
// cache_hit accounting and to expose the singleflight win in stats().
this._getStats(keyId).hits++;
const stream = this._attachClient(inflightEntry, {
clientId,
perClientQueueCap,
});
return { stream, isFirst: false, role: 'attached' };
}
// ── (B) Cache hit check (no inflight) ──────────────────────────────────
// Replays cached chunks via a synthetic async iterator. No source spawn.
const ns = this._getNamespace(keyId);
const existing = ns.get(cacheKey);
if (existing && this._isAlive(existing)) {
this._getStats(keyId).hits++;
const cachedChunks = Array.isArray(existing.value) ? existing.value : [existing.value];
const stream = (async function* cacheReplay() {
for (const chunk of cachedChunks) {
yield chunk;
}
})();
return { stream, isFirst: false, role: 'cache_hit' };
}
// ── (C) Miss + no inflight: take the lock synchronously, then await ───
// The placeholder entry is inserted BEFORE invoking sourceFactory so that
// late joiners arriving while the factory is awaited see the inflight
// entry and attach (they're parked in `pendingJoiners` until the factory
// resolves or rejects). If the factory throws, the placeholder is
// removed and the error propagates to the first caller AND all parked
// joiners. This preserves the §1 invariant: the Map insert is atomic
// from later joiners' perspective.
const entry = /** @type {StreamingInflightEntry} */ ({
compositeKey,
source: null,
sourceAbortController: new AbortController(),
accumulatedChunks: [],
accumulatedByteSize: 0,
accumulatedReplayCapExceeded: false,
attachedClients: new Set(),
factoryPending: true,
pendingJoiners: [],
sourceDone: false,
sourceError: null,
sourceAborted: false,
ttlMs,
});
this._streamingInflight.set(compositeKey, entry);
this._getStats(keyId).misses++;
// Attach the first caller synchronously so any subsequent joiners during
// the factory await see the same set/topology as the first caller.
const firstStream = this._attachClient(entry, {
clientId,
perClientQueueCap,
});
let sourceIter;
try {
const factoryResult = sourceFactory();
sourceIter = factoryResult && typeof factoryResult.then === 'function'
? await factoryResult
: factoryResult;
} catch (err) {
// Factory rejected — remove placeholder and reject first caller + any
// late joiners that arrived during the await.
this._streamingInflight.delete(compositeKey);
for (const client of entry.attachedClients) {
if (client.rejectNext) {
client.rejectNext(err);
client.resolveNext = null;
client.rejectNext = null;
}
client.error = err;
client.done = true;
}
throw err;
}
entry.source = sourceIter;
entry.factoryPending = false;
// Kick off the tee task. It runs detached; lifetime is bounded by the
// source iterator's completion / error / abort.
this._teeStreamingSource(keyId, cacheKey, entry, {
accumulatedReplayCap,
});
return { stream: firstStream, isFirst: true, role: 'source' };
}
/**
* D57 ADR 0005 Amendment 8 §3 + §5: attach a new client to an inflight
* entry. Synchronously drains the accumulated replay buffer into the
* client's queue (§5). If the drain would exceed `perClientQueueCap`, the
* client receives a STREAM_BACKPRESSURE synthesised stream INSTEAD of the
* normal tee the source continues for the other clients.
*
* Returns the async iterator the caller will consume.
*
* @private
*/
_attachClient(entry, { clientId, perClientQueueCap }) {
// Late-joiner replay drain cap check (§5 + §10): a late joiner cannot
// catch up if either
// (a) the accumulated buffer alone would overflow the per-client cap
// (burst > PER_CLIENT_QUEUE_CAP), or
// (b) the replay buffer is already truncated (§10 cap was hit and
// further source chunks were not appended to accumulatedChunks),
// so even a successful drain would give the joiner a partial view
// that disagrees with later live chunks.
// Either condition → STREAM_BACKPRESSURE synthetic terminator. The
// source / other clients are unaffected.
if (
entry.accumulatedByteSize > perClientQueueCap
|| entry.accumulatedReplayCapExceeded
) {
this._warnFn('stream_backpressure_disconnect', {
client_id: clientId,
queue_byte_size: entry.accumulatedByteSize,
per_client_cap: perClientQueueCap,
composite_key: entry.compositeKey,
reason: entry.accumulatedReplayCapExceeded
? 'replay_cap_truncated'
: 'replay_drain_over_cap',
});
return _backpressureTerminator();
}
/** @type {AttachedClient} */
const client = {
id: clientId,
queue: [],
queueByteSize: 0,
yieldedAccumulated: false,
done: false,
backpressured: false,
error: null,
resolveNext: null,
rejectNext: null,
// Per-client cap is captured here so the tee task can apply it
// without re-plumbing opts; documented as an internal field.
__perClientQueueCap__: perClientQueueCap,
};
// Synchronous replay drain — push every accumulated chunk into the
// client's queue at attach-time. From this point on the tee task pushes
// live chunks.
for (const chunk of entry.accumulatedChunks) {
client.queue.push(chunk);
client.queueByteSize += _chunkByteSize(chunk);
}
client.yieldedAccumulated = true;
entry.attachedClients.add(client);
// TODO(D58 — ADR 0005 Amendment 8 §11): emit `streaming_inflight_join`
// event from the server-layer wrapper, which has provider/model context.
// Cache layer alone does not have provider/model identity (sourceFactory
// is a closure), so the join event lives at the consumer of `role:
// 'attached'` in server.mjs. D57 reviewer P2-3 follow-up.
// If source already completed before this attach (last-second join)
// mark the client as terminal-after-drain so the iterator returns
// cleanly once the replay queue is drained.
if (entry.sourceDone) {
client.done = true;
} else if (entry.sourceError) {
client.error = entry.sourceError;
client.done = true;
}
// Per-client AbortController for client-side cancellation (HTTP close).
// The async iterator's return() removes the client from attachedClients;
// if the entry's attachedClients size hits zero, the tee task aborts the
// source. The teardown logic lives in the iterator below.
const store = this;
const iterator = (async function* clientStream() {
try {
while (true) {
// Drain queue chunks first.
if (client.queue.length > 0) {
const next = client.queue.shift();
client.queueByteSize -= _chunkByteSize(next);
yield next;
continue;
}
// Backpressure-terminated client: yield the synthetic terminator.
if (client.backpressured) {
yield { type: 'stop', finish_reason: 'length' };
yield '[DONE]';
client.done = true;
return;
}
// Source already errored.
if (client.error) {
throw client.error;
}
// Source already completed and no more queued chunks.
if (client.done) {
return;
}
// Block until the tee task pushes the next chunk (or signals
// source-done / source-error / backpressure).
await new Promise((resolve, reject) => {
client.resolveNext = resolve;
client.rejectNext = reject;
});
client.resolveNext = null;
client.rejectNext = null;
}
} finally {
// Iterator return() fired (HTTP close, break, or normal return) —
// remove client and possibly trigger source abort.
if (entry.attachedClients.has(client)) {
entry.attachedClients.delete(client);
}
// If we're the last client AND the source is still running, fire
// the AbortController so the source iterator's return() / cleanup
// can reap any underlying resources.
if (
entry.attachedClients.size === 0
&& !entry.sourceDone
&& !entry.sourceError
&& !entry.sourceAborted
&& !entry.factoryPending
) {
entry.sourceAborted = true;
try {
entry.sourceAbortController.abort();
} catch {
// best-effort
}
// Tee task observes attachedClients.size === 0 + sourceAborted on
// its next loop iteration and exits without a cache write.
store._streamingInflight.delete(entry.compositeKey);
store._warnFn('streaming_inflight_abort', {
composite_key: entry.compositeKey,
accumulated_chunk_count: entry.accumulatedChunks.length,
});
}
}
})();
return iterator;
}
/**
* D57 ADR 0005 Amendment 8 §4: tee fan-out task. One reader pulls from
* `entry.source`; on each chunk, pushes to `accumulatedChunks` (bounded by
* §10) and to every attached client's queue (per-client cap from §8).
*
* On source completion: writes accumulated chunks to cache if (a) cap not
* exceeded and (b) `set()`'s own `maxEntryBytes` cap admits it. Resolves
* all clients to drain-out state. Removes entry.
*
* On source error: rejects all clients via their `rejectNext`. No cache
* write. Removes entry.
*
* Source-abort short-circuit: if `attachedClients.size === 0` after a push,
* fires `sourceAbortController.abort()`, exits without cache write.
*
* @private
*/
_teeStreamingSource(keyId, cacheKey, entry, { accumulatedReplayCap }) {
const store = this;
(async () => {
try {
for (;;) {
// Pre-check: if all clients have already gone away before we even
// pull the next chunk, abort the source and bail out. (The
// per-client iterator's finally-block sets sourceAborted=true and
// removes the entry; we just need to stop pulling.)
if (entry.attachedClients.size === 0 && !entry.factoryPending) {
if (!entry.sourceAborted) {
entry.sourceAborted = true;
try { entry.sourceAbortController.abort(); } catch { /* best-effort */ }
}
// Try to call return() on the source so the underlying generator
// cleans up. Best-effort; not all iterators implement it.
try {
if (entry.source && typeof entry.source.return === 'function') {
await entry.source.return();
}
} catch { /* best-effort */ }
return;
}
const result = await entry.source.next();
if (result.done) break;
const chunk = result.value;
const size = _chunkByteSize(chunk);
// §10 — replay buffer cap. Past the cap we stop accumulating (so
// future late joiners can still see the chunks they need to
// catch up to live), but we mark the entry not-cacheable so the
// §4 completion path skips the cache write.
if (!entry.accumulatedReplayCapExceeded) {
if (entry.accumulatedByteSize + size > accumulatedReplayCap) {
entry.accumulatedReplayCapExceeded = true;
store._warnFn('streaming_inflight_replay_cap_exceeded', {
composite_key: entry.compositeKey,
accumulated_byte_size: entry.accumulatedByteSize,
chunk_size: size,
accumulated_replay_cap: accumulatedReplayCap,
});
// Continue accumulating up to this chunk so existing late
// joiners' drain decision was based on the size they saw at
// attach-time. We do NOT push this chunk to accumulatedChunks
// (it would corrupt the "<= cap at attach-time" invariant for
// future joiners). Future joiners arriving past this point
// see accumulatedByteSize already > cap and get the
// backpressure terminator at attach-time per §5.
} else {
entry.accumulatedChunks.push(chunk);
entry.accumulatedByteSize += size;
}
}
// Fan out to each client synchronously (no await inside the for-
// each-client loop). Disconnecting a client is mutation-during-
// iteration; we snapshot the set first.
const clientsSnapshot = [...entry.attachedClients];
for (const client of clientsSnapshot) {
// Per-client backpressure (§8). If the push would exceed the
// per-client cap, disconnect this client only.
if (client.queueByteSize + size > client.__perClientQueueCap__) {
client.backpressured = true;
store._warnFn('stream_backpressure_disconnect', {
client_id: client.id,
queue_byte_size: client.queueByteSize,
per_client_cap: client.__perClientQueueCap__,
composite_key: entry.compositeKey,
reason: 'queue_overflow',
});
// Remove from set so future fan-out skips this client.
entry.attachedClients.delete(client);
// Wake the client's pull-promise so it can yield the synthetic
// STREAM_BACKPRESSURE terminator.
if (client.resolveNext) {
const r = client.resolveNext;
client.resolveNext = null;
client.rejectNext = null;
r({ value: undefined, done: false });
}
continue;
}
client.queue.push(chunk);
client.queueByteSize += size;
if (client.resolveNext) {
const r = client.resolveNext;
client.resolveNext = null;
client.rejectNext = null;
r({ value: undefined, done: false });
}
}
// If the fan-out emptied the attached set (all over-cap), the
// top-of-loop pre-check will fire on the next iteration and abort.
}
// Source iterator returned normally.
entry.sourceDone = true;
const cacheWritten =
!entry.accumulatedReplayCapExceeded
&& entry.accumulatedChunks.length > 0;
if (cacheWritten) {
// ADR 0005 Amendment 8 §4: write accumulated chunks to cache via
// the standard set() path (which itself applies the D23
// maxEntryBytes cap — separate from the §10 replay cap).
await store.set(keyId, cacheKey, entry.accumulatedChunks, entry.ttlMs);
}
store._warnFn('streaming_inflight_source_done', {
composite_key: entry.compositeKey,
attached_count: entry.attachedClients.size,
accumulated_chunk_count: entry.accumulatedChunks.length,
cache_written: cacheWritten,
});
// Wake every remaining attached client so they drain their queue and
// observe `done = true`.
for (const client of [...entry.attachedClients]) {
client.done = true;
if (client.resolveNext) {
const r = client.resolveNext;
client.resolveNext = null;
client.rejectNext = null;
r({ value: undefined, done: true });
}
}
store._streamingInflight.delete(entry.compositeKey);
} catch (err) {
// Source threw mid-stream. Reject every attached client.
entry.sourceError = err;
for (const client of [...entry.attachedClients]) {
client.error = err;
client.done = true;
if (client.rejectNext) {
const rej = client.rejectNext;
client.resolveNext = null;
client.rejectNext = null;
rej(err);
}
}
store._streamingInflight.delete(entry.compositeKey);
}
})();
}
/**
* Returns stats for a specific keyId, or aggregate stats across all keyIds.
*
@@ -314,11 +883,14 @@ export class CacheStore {
const s = this._stats.get(keyId) ?? { hits: 0, misses: 0 };
const ns = this._store.get(keyId);
const size = ns ? ns.size : 0;
// D57 — ADR 0005 Amendment 8 §1: inflightCount aggregates both the
// buffered-path singleflight Map and the streaming-path inflight Map
// so stats() reflects all active dedup-coordination entries.
return {
hits: s.hits,
misses: s.misses,
size,
inflightCount: this._inflight.size,
inflightCount: this._inflight.size + this._streamingInflight.size,
};
}
@@ -337,10 +909,44 @@ export class CacheStore {
hits: totalHits,
misses: totalMisses,
size: totalSize,
inflightCount: this._inflight.size,
// D57 — see per-keyId branch above; streaming entries counted alongside buffered.
inflightCount: this._inflight.size + this._streamingInflight.size,
};
}
/**
* 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.
*
@@ -356,10 +962,18 @@ export class CacheStore {
this._inflight.delete(k);
}
}
// D57 — also clear streaming inflight entries scoped to this keyId.
// Composite key uses `\0` separator (see _streamingInflight init).
for (const k of this._streamingInflight.keys()) {
if (k.startsWith(`${keyId}\0`)) {
this._streamingInflight.delete(k);
}
}
} else {
this._store.clear();
this._stats.clear();
this._inflight.clear();
this._streamingInflight.clear();
}
}
}
+607
View File
@@ -0,0 +1,607 @@
/**
* lib/doctor.mjs OLP doctor framework (Phase 4 / D65)
*
* Authority: ADR 0010 § Phase 4 D64-D67 + ADR 0002 Amendment 7 (D67)
* per-provider `doctorChecks()` contract method that this framework consumes.
*
* `olp doctor` runs a set of `Check` objects. Each check has:
* - id: string (unique, e.g. 'server.running', 'anthropic.cli_available')
* - category: 'server'|'auth'|'config'|'provider'|'system'
* - async run(): { status: 'ok'|'fail'|'warn', message, evidence? }
*
* Built-in checks (categories server / auth / config / system) are defined in
* `buildBuiltinChecks()` below; per-provider checks are sourced from each loaded
* plugin's optional `doctorChecks()` method (ADR 0002 Amendment 7).
*
* Output shape (machine-readable consumed by `bin/olp.mjs --json`):
* {
* schema_version: 1,
* generated_at: '2026-05-26T...',
* checks: [{ id, category, status, message, evidence? }],
* fail_count: number,
* warn_count: number,
* kind: 'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install',
* next_action: { ai_executable: string[], human_required: string[], verify: string },
* summary: string,
* }
*
* `kind` precedence (highest first the most upstream blocker wins):
* 1. fresh_install config.exists FAIL (~/.olp/config.json missing/malformed)
* 2. fix_server server.running FAIL
* 3. fix_oauth auth.owner_key_exists FAIL
* 4. fix_provider any provider-category FAIL
* 5. fix_config any other config-category FAIL
* 6. noop all OK (or WARN-only)
*
* `next_action.ai_executable[]` aggregates `evidence.fix_commands[]` from every
* FAIL check; `next_action.human_required[]` aggregates `evidence.human_steps[]`.
* `verify` is always `olp doctor` (re-run after applying the fix).
*
* Design notes:
* - Pure functions + dependency injection: callers pass `{ checks }` (which can
* be overridden for tests) plus a `{ now }` clock for deterministic timestamps.
* - No filesystem writes. No process.exit. No console.log. Callers handle I/O.
* - All checks run in parallel via Promise.all individual check failures are
* captured (not propagated) so one broken check does not hide others.
*/
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { request as httpRequest } from 'node:http';
import { loadProviders } from './providers/index.mjs';
import { loadFallbackConfigSync } from './fallback/engine.mjs';
import { listKeys } from './keys.mjs';
// ── Schema version ────────────────────────────────────────────────────────
export const DOCTOR_SCHEMA_VERSION = 1;
// ── Built-in check builders ───────────────────────────────────────────────
/**
* Resolve the OLP base URL the CLI / doctor will probe.
* Precedence:
* 1. opts.proxyUrl (explicit caller override)
* 2. OLP_PROXY_URL env (full URL like http://host:port)
* 3. http://127.0.0.1:${OLP_PORT || 4567}
*/
export function resolveProxyUrl(opts = {}) {
if (opts.proxyUrl) return String(opts.proxyUrl).replace(/\/+$/, '');
if (process.env.OLP_PROXY_URL) return String(process.env.OLP_PROXY_URL).replace(/\/+$/, '');
const port = process.env.OLP_PORT ?? '4567';
return `http://127.0.0.1:${port}`;
}
/**
* Resolve the OLP_HOME directory (mirrors lib/keys.mjs precedence).
* 1. opts.olpHome
* 2. OLP_HOME env
* 3. ~/.olp
*/
export function resolveOlpHome(opts = {}) {
if (opts.olpHome) return opts.olpHome;
if (process.env.OLP_HOME) return process.env.OLP_HOME;
return join(homedir(), '.olp');
}
/**
* Helper issue a GET to the proxy with a tight timeout. Returns
* `{ ok: true, status, body }` or `{ ok: false, error }`. Never throws.
*/
async function httpGet(url, { timeoutMs = 3000, headers = {} } = {}) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let req;
try {
req = httpRequest(url, { method: 'GET', headers, timeout: timeoutMs }, res => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
});
} catch (e) {
finish({ ok: false, error: String(e?.message ?? e) });
return;
}
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e) }));
req.on('timeout', () => {
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
});
req.end();
});
}
/**
* Build the default check set. Test-mode overrides:
* - opts.injectChecks: [...Check] REPLACES the built-in set entirely
* - opts.providersOverride: Map<name, plugin> REPLACES loaded providers for the per-provider sweep
* - opts.skipNetwork: true omit server.running / server.version (offline mode)
* - opts.olpHome: override ~/.olp lookup
* - opts.proxyUrl: override resolveProxyUrl()
*/
/**
* D64-D67 reviewer P2-1: shell-quote a path before interpolation into
* `ai_executable[]` strings. Single-quote-wrap + escape any embedded single
* quote per POSIX shell rules: `foo'bar` `'foo'\''bar'`. Defends against
* a malicious `OLP_HOME` env value injecting shell metacharacters into the
* suggested-fix command an AI agent (or human) might paste back.
*
* Risk surface is narrow at family scale (operator local env, single-user
* proxy), but the hardening cost is one helper.
*
* @param {string} s
* @returns {string} single-quoted shell-safe string
*/
function _shellQuote(s) {
return `'${String(s).replace(/'/g, "'\\''")}'`;
}
export function buildBuiltinChecks(opts = {}) {
if (opts.injectChecks) return opts.injectChecks;
const olpHome = resolveOlpHome(opts);
const configPath = join(olpHome, 'config.json');
const proxyUrl = resolveProxyUrl(opts);
// D74 P1-1 fix: server.running and server.version probe /health, which
// under default production posture (auth.allow_anonymous: false) requires
// an Authorization: Bearer header. Without it, the probe gets 401 and
// doctor falsely reports the server down. Caller passes the resolved
// bearer token via opts.authHeaders (a `{Authorization: 'Bearer ...'}`
// object). Empty headers means "no token configured" — the probe still
// fires but a 401 response is treated as "auth misconfigured" rather
// than "server down" (see server.running check below).
const authHeaders = opts.authHeaders ?? {};
const checks = [];
// ── system.* ─────────────────────────────────────────────────────────
checks.push({
id: 'system.node_version',
category: 'system',
async run() {
// package.json engines.node = >=18; check process.versions.node major >= 18
const major = parseInt(String(process.versions.node).split('.')[0], 10);
if (Number.isFinite(major) && major >= 18) {
return { status: 'ok', message: `Node ${process.versions.node} (>=18)` };
}
return {
status: 'fail',
message: `Node ${process.versions.node} is below the required >=18 (per package.json engines.node)`,
evidence: {
human_steps: [
'Install a current Node.js LTS (>=18) — see https://nodejs.org/en/download',
],
},
};
},
});
// ── config.* ─────────────────────────────────────────────────────────
checks.push({
id: 'config.exists',
category: 'config',
async run() {
if (!existsSync(configPath)) {
return {
status: 'fail',
message: `${configPath} not found`,
evidence: {
// D64-D67 reviewer P2-1: shell-quote paths via _shellQuote so a
// malicious OLP_HOME env can't inject shell metacharacters into
// the suggested-fix command pasted into an AI agent.
fix_commands: [
`mkdir -p ${_shellQuote(olpHome)}`,
`printf '%s\\n' '{"auth":{"allow_anonymous":false,"owner_only_endpoints":["/health"],"fallback_detail_header_policy":"owner_only"},"providers":{"enabled":{}},"routing":{"chains":{},"soft_triggers":{}},"streaming":{"heartbeat_interval_ms":0}}' > ${_shellQuote(configPath)}`,
],
reference: 'docs/adr/0007-multi-key-auth.md § 3, docs/adr/0004-fallback-engine.md',
},
};
}
try {
const parsed = JSON.parse(readFileSync(configPath, 'utf8'));
if (parsed && typeof parsed === 'object') {
return { status: 'ok', message: `${configPath} parses` };
}
return { status: 'fail', message: `${configPath} parses but is not a JSON object` };
} catch (e) {
return {
status: 'fail',
message: `${configPath} unreadable / malformed: ${e?.message ?? e}`,
evidence: {
human_steps: [
`Inspect ${configPath} — fix the JSON syntax error (or delete the file to fall back to the empty default and re-run olp doctor)`,
],
},
};
}
},
});
checks.push({
id: 'config.providers_enabled',
category: 'config',
async run() {
try {
const cfg = loadFallbackConfigSync(configPath);
const enabled = cfg.providersEnabled ?? {};
const enabledNames = Object.keys(enabled).filter(k => enabled[k] === true);
if (enabledNames.length > 0) {
return { status: 'ok', message: `${enabledNames.length} provider(s) enabled: ${enabledNames.join(', ')}` };
}
return {
status: 'warn',
message: 'No providers enabled in config.json (all /v1/chat/completions requests will 503)',
evidence: {
human_steps: [
`Edit ${configPath} → set providers.enabled.<name> = true for at least one provider (anthropic / openai / mistral)`,
],
reference: 'docs/adr/0002-plugin-architecture.md § Disable model',
},
};
} catch (e) {
return { status: 'fail', message: `Could not read providers.enabled from ${configPath}: ${e?.message ?? e}` };
}
},
});
checks.push({
id: 'config.chains_configured',
category: 'config',
async run() {
try {
const cfg = loadFallbackConfigSync(configPath);
const chains = cfg.chains ?? {};
const chainNames = Object.keys(chains);
if (chainNames.length > 0) {
return { status: 'ok', message: `${chainNames.length} chain(s) configured: ${chainNames.join(', ')}` };
}
return {
status: 'warn',
message: 'No routing chains configured (single-hop mode; cross-provider fallback inactive)',
evidence: {
reference: 'docs/adr/0004-fallback-engine.md § Chain configuration',
},
};
} catch (e) {
return { status: 'fail', message: `Could not read routing.chains from ${configPath}: ${e?.message ?? e}` };
}
},
});
// ── auth.* ───────────────────────────────────────────────────────────
checks.push({
id: 'auth.owner_key_exists',
category: 'auth',
async run() {
// Per ADR 0007 § 9.4: process.env.OLP_OWNER_TOKEN also satisfies "owner identity".
if (process.env.OLP_OWNER_TOKEN) {
return { status: 'ok', message: 'OLP_OWNER_TOKEN env var present (synthetic env-owner per ADR 0007 § 9.4)' };
}
try {
const keys = listKeys({ olpHome });
const activeOwner = keys.find(k => k.owner_tier === 'owner' && k.revoked_at === null);
if (activeOwner) {
return { status: 'ok', message: `active owner key found: id=${activeOwner.id} name="${activeOwner.name}"` };
}
return {
status: 'fail',
message: 'No active owner-tier key found in ~/.olp/keys/ and OLP_OWNER_TOKEN env unset',
evidence: {
fix_commands: [
'npx olp-keys keygen --owner',
],
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 (bootstrap & recovery)',
},
};
} catch (e) {
return { status: 'fail', message: `listKeys failed: ${e?.message ?? e}` };
}
},
});
// ── server.* ─────────────────────────────────────────────────────────
if (!opts.skipNetwork) {
checks.push({
id: 'server.running',
category: 'server',
async run() {
// D74 P1-1: pass authHeaders so the probe works under the default
// production posture (auth.allow_anonymous: false).
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
if (!r.ok) {
return {
status: 'fail',
message: `${proxyUrl}/health unreachable: ${r.error}`,
evidence: {
fix_commands: [
'npx olp restart',
],
reference: 'README.md § Running OLP',
},
};
}
// 401: server is up but the caller has no/wrong bearer token. NOT a
// "server down" condition — distinguish so the kind discriminator
// doesn't route to fix_server when the user just needs OLP_API_KEY.
if (r.status === 401 || r.status === 403) {
return {
status: 'fail',
message: `${proxyUrl}/health returned ${r.status} — server is up but the bearer token is missing or invalid. Set OLP_API_KEY env to an owner-tier token (npx olp-keys list).`,
evidence: {
fix_commands: [
'echo "set OLP_API_KEY=<your owner token> or OLP_OWNER_TOKEN=<...> then rerun olp doctor"',
],
human_required: [
'Locate an owner-tier OLP API key plaintext (or run `npx olp-keys keygen --owner` to mint a new one — printed ONCE).',
'Export it: `export OLP_API_KEY=olp_...`',
],
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 + README § Environment Variables',
},
};
}
if (r.status !== 200) {
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
}
return { status: 'ok', message: `${proxyUrl}/health → 200` };
},
});
checks.push({
id: 'server.version',
category: 'server',
async run() {
// Read local package.json version
let localVersion = null;
try {
// Resolve relative to this file — lib/doctor.mjs → ../package.json
// import.meta.url gives a file:// URL; convert and join.
const here = new URL('../package.json', import.meta.url);
const pkg = JSON.parse(readFileSync(here, 'utf8'));
localVersion = pkg.version ?? null;
} catch {
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
}
// D74 P1-1: same auth-headers fix as server.running.
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
if (!r.ok || r.status !== 200) {
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
}
let serverVersion = null;
try {
serverVersion = JSON.parse(r.body)?.version ?? null;
} catch {
return { status: 'warn', message: '/health returned non-JSON; cannot compare version' };
}
if (!serverVersion) {
return { status: 'warn', message: '/health did not include version; cannot compare' };
}
if (serverVersion === localVersion) {
return { status: 'ok', message: `local v${localVersion} matches running v${serverVersion}` };
}
return {
status: 'warn',
message: `local v${localVersion} differs from running v${serverVersion} — restart to pick up the new code`,
evidence: {
fix_commands: [
'npx olp restart',
],
},
};
},
});
}
return checks;
}
/**
* Sweep loaded providers for doctorChecks() (ADR 0002 Amendment 7).
* Plugins without doctorChecks() contribute nothing (default back-compat).
*
* @param {object} opts
* @param {Map} [opts.providersOverride] Map<name, plugin> for tests
* @param {object} [opts.providersEnabled] Record<string, boolean>; default = all from config.json
* @returns {Check[]}
*/
export function collectProviderChecks(opts = {}) {
let providers;
if (opts.providersOverride) {
providers = opts.providersOverride;
} else {
const olpHome = resolveOlpHome(opts);
const configPath = join(olpHome, 'config.json');
let enabled = opts.providersEnabled;
if (!enabled) {
try {
enabled = loadFallbackConfigSync(configPath).providersEnabled ?? {};
} catch {
enabled = {};
}
}
providers = loadProviders({ enabled });
}
const checks = [];
for (const [_name, plugin] of providers) {
if (typeof plugin?.doctorChecks !== 'function') continue;
let pluginChecks;
try {
pluginChecks = plugin.doctorChecks();
} catch (e) {
// Misbehaving plugin — surface as a synthesized fail check, do not crash.
checks.push({
id: `${plugin.name}.doctor_checks_threw`,
category: 'provider',
async run() {
return { status: 'fail', message: `doctorChecks() threw: ${e?.message ?? e}` };
},
});
continue;
}
if (!Array.isArray(pluginChecks)) continue;
for (const c of pluginChecks) {
if (c && typeof c.id === 'string' && typeof c.run === 'function') {
checks.push({
id: c.id,
category: c.category ?? 'provider',
run: c.run,
});
}
}
}
return checks;
}
// ── Discriminator (kind precedence) ───────────────────────────────────────
/**
* Given a flat results array, determine the next-action discriminator.
* Per ADR 0010 § D65 framework:
* fresh_install > fix_server > fix_oauth > fix_provider > fix_config > noop
*/
export function deriveKind(results) {
const failed = results.filter(r => r.status === 'fail');
if (failed.length === 0) return 'noop';
if (failed.some(r => r.id === 'config.exists')) return 'fresh_install';
if (failed.some(r => r.category === 'server')) return 'fix_server';
if (failed.some(r => r.category === 'auth')) return 'fix_oauth';
if (failed.some(r => r.category === 'provider')) return 'fix_provider';
if (failed.some(r => r.category === 'config')) return 'fix_config';
return 'fix_config';
}
/**
* Compose the next_action block from FAIL results' evidence.
*/
export function deriveNextAction(results, kind) {
const ai_executable = [];
const human_required = [];
for (const r of results) {
if (r.status !== 'fail') continue;
const ev = r.evidence ?? {};
if (Array.isArray(ev.fix_commands)) ai_executable.push(...ev.fix_commands);
if (Array.isArray(ev.human_steps)) human_required.push(...ev.human_steps);
}
return {
ai_executable,
human_required,
verify: kind === 'noop' ? 'already healthy' : 'olp doctor',
};
}
// ── runDoctor (main entry) ────────────────────────────────────────────────
/**
* Execute every check in parallel; aggregate; derive kind + next_action.
*
* @param {object} [opts]
* @param {Check[]} [opts.injectChecks] REPLACE the built-in + provider checks entirely
* @param {Check[]} [opts.extraChecks] APPEND extra checks (after defaults)
* @param {string} [opts.checkFilter] restrict to checks whose id OR category matches
* @param {Map} [opts.providersOverride] for the per-provider sweep
* @param {object} [opts.providersEnabled] Record<string, boolean>
* @param {string} [opts.olpHome] override ~/.olp
* @param {string} [opts.proxyUrl] override the proxy URL
* @param {boolean} [opts.skipNetwork] omit server.* checks
* @param {() => Date} [opts.now] clock injection
* @returns {Promise<DoctorResult>}
*/
export async function runDoctor(opts = {}) {
const now = opts.now ?? (() => new Date());
let checks;
if (opts.injectChecks) {
checks = [...opts.injectChecks];
} else {
checks = [
...buildBuiltinChecks(opts),
...collectProviderChecks(opts),
];
}
if (opts.extraChecks) checks.push(...opts.extraChecks);
// --check <filter>: restrict to checks whose id OR category startsWith / equals the filter.
// Match rule: exact id match, exact category match, OR id startsWith `<filter>.`
// (so --check anthropic matches both anthropic.cli_available and anthropic.oauth_token_present).
if (opts.checkFilter) {
const f = String(opts.checkFilter);
checks = checks.filter(c =>
c.id === f
|| c.category === f
|| c.id.startsWith(`${f}.`)
);
}
// Run all checks in parallel. Capture per-check failures (do not let one throw
// hide the rest of the diagnostic).
const results = await Promise.all(checks.map(async c => {
try {
const r = await c.run();
return {
id: c.id,
category: c.category,
status: r?.status ?? 'fail',
message: r?.message ?? '(check returned no message)',
...(r?.evidence !== undefined ? { evidence: r.evidence } : {}),
};
} catch (e) {
return {
id: c.id,
category: c.category,
status: 'fail',
message: `check threw: ${e?.message ?? e}`,
};
}
}));
const fail_count = results.filter(r => r.status === 'fail').length;
const warn_count = results.filter(r => r.status === 'warn').length;
const ok_count = results.filter(r => r.status === 'ok').length;
const kind = deriveKind(results);
const next_action = deriveNextAction(results, kind);
let summary;
if (fail_count === 0 && warn_count === 0) {
summary = `all ${ok_count} checks ok`;
} else if (fail_count === 0) {
summary = `${ok_count} ok, ${warn_count} warn — no FAIL; kind=${kind}`;
} else {
const firstFail = results.find(r => r.status === 'fail');
summary = `${fail_count} of ${results.length} checks failed — ${firstFail?.id ?? '?'} (${firstFail?.message?.slice(0, 80) ?? ''})`;
}
return {
schema_version: DOCTOR_SCHEMA_VERSION,
generated_at: now().toISOString(),
checks: results,
fail_count,
warn_count,
ok_count,
kind,
next_action,
summary,
};
}
/**
* @typedef {Object} Check
* @property {string} id
* @property {'server'|'auth'|'config'|'provider'|'system'} category
* @property {() => Promise<{ status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }>} run
*/
/**
* @typedef {Object} DoctorResult
* @property {number} schema_version
* @property {string} generated_at (ISO timestamp)
* @property {Array<{ id: string, category: string, status: 'ok'|'fail'|'warn', message: string, evidence?: object }>} checks
* @property {number} fail_count
* @property {number} warn_count
* @property {number} ok_count
* @property {'noop'|'fix_server'|'fix_oauth'|'fix_config'|'fix_provider'|'fresh_install'} kind
* @property {{ ai_executable: string[], human_required: string[], verify: string }} next_action
* @property {string} summary
*/
+126 -8
View File
@@ -27,11 +27,16 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
/**
* 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)
* - CLI_NOT_FOUND hard trigger (binary missing)
* - AUTH_MISSING NOT a hard trigger (user-config failure; user must fix)
* - 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):
* no v0.1 plugin parses underlying-API HTTP status codes, so these codes
@@ -46,6 +51,7 @@ const HARD_TRIGGER_CODES = {
CLI_NOT_FOUND: true,
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
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
*/
/**
* @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
* @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 {Error|null} originalError first-hop error if exhausted; null on success
* @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.
*
@@ -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 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++) {
const hop = chain[i];
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
// this; documented here so future readers do not try to add SOFT_TRIGGER
// to the PROVIDER_ERROR_CODES closed enum.
if (!firstErrorRecorded) {
originalError = Object.assign(
const softErr = Object.assign(
new Error(`Soft trigger fired for provider ${provider}: quota threshold exceeded`),
{ code: 'SOFT_TRIGGER', provider },
);
if (!firstErrorRecorded) {
originalError = softErr;
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;
}
@@ -342,6 +427,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i,
originalError: null,
triedProviders,
fallbackDetail, // D40: failing hops that came BEFORE this success
};
} catch (err) {
// Record FIRST hop error as the canonical signal
@@ -351,6 +437,12 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
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', {
chain_id: chainId,
hop: i,
@@ -358,7 +450,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
model,
error: err.message,
code: err.code ?? null,
trigger_type: classifyTrigger(err),
trigger_type: errTriggerType,
ir_request_hash: irRequestHash,
next_provider: chain[i + 1]?.provider ?? null,
});
@@ -383,6 +475,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i,
originalError: err,
triedProviders,
fallbackDetail, // D40
};
}
@@ -406,6 +499,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i,
originalError: err,
triedProviders,
fallbackDetail, // D40
};
}
@@ -422,7 +516,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
error: err.message,
code: err.code ?? null,
advance_to_hop: i + 1,
trigger_type: classifyTrigger(err),
trigger_type: errTriggerType,
ir_request_hash: irRequestHash,
next_provider: chain[i + 1]?.provider ?? null,
});
@@ -437,7 +531,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
provider,
error: err.message,
code: err.code ?? null,
trigger_type: classifyTrigger(err),
trigger_type: errTriggerType,
ir_request_hash: irRequestHash,
next_provider: null,
});
@@ -448,6 +542,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: i,
originalError: err,
triedProviders,
fallbackDetail, // D40
};
}
}
@@ -464,6 +559,16 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
next_provider: null,
});
// D41 (issue #8): providerUsed on chain-exhausted reflects **chain origin**
// (the configured primary), not necessarily the first hop where spawn() was
// actually called. At v0.1 the two are equivalent because soft triggers are
// deferred (ADR 0004 Amendment 2) — every hop in the chain is attempted in
// order. When soft triggers are reactivated in v1.x, the semantic ambiguity
// surfaces: a soft-skipped hop 0 followed by hard-failed hops 1+N would
// report providerUsed=chain[0] even though hop 0 was never spawned. The v0.1
// contract is chain-origin (option b); v1.x may switch to first-attempted-
// hop (option a) as part of the soft-trigger reactivation work. See ADR 0004
// Amendment 6 for the documented semantics.
return {
chunks: null,
providerUsed: chain[0].provider,
@@ -471,6 +576,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
fallbackHops: chain.length,
originalError,
triedProviders,
fallbackDetail, // D40 (issue #7): per-hop failure tuples; every attempted hop on exhaustion
};
}
@@ -561,24 +667,36 @@ function defaultConfigPath() {
* Returns empty config (no chains, no soft triggers, no enabled providers) if the
* file is absent, unreadable, or malformed.
*
* D61 (ADR 0010 § Phase 4 D61-D63): adds `streaming` block. Currently
* exposes `heartbeat_interval_ms` (default 0 = heartbeat disabled). When
* heartbeat_interval_ms > 0, the streaming branch emits `: keepalive\n\n`
* SSE comment frames during silent windows of length >= the interval. Default
* 0 preserves backwards compat (no behavioural change).
*
* @param {string} [configPath] override path (for testing do NOT write to ~/.olp/config.json in tests)
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean> }}
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean>, streaming: { heartbeat_interval_ms: number } }}
*/
export function loadFallbackConfigSync(configPath) {
const DEFAULT_STREAMING = { heartbeat_interval_ms: 0 };
try {
const path = configPath ?? defaultConfigPath();
const raw = readFileSync(path, 'utf8');
const parsed = JSON.parse(raw);
const routing = parsed?.routing ?? {};
const providers = parsed?.providers ?? {};
const streaming = parsed?.streaming ?? {};
const hb = Number(streaming.heartbeat_interval_ms);
return {
chains: routing.chains ?? {},
soft_triggers: routing.soft_triggers ?? {},
providersEnabled: providers.enabled ?? {},
streaming: {
heartbeat_interval_ms: Number.isFinite(hb) && hb >= 0 ? hb : 0,
},
};
} catch {
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
return { chains: {}, soft_triggers: {}, providersEnabled: {} };
return { chains: {}, soft_triggers: {}, providersEnabled: {}, streaming: { ...DEFAULT_STREAMING } };
}
}
+17 -2
View File
@@ -27,13 +27,28 @@ export class BadRequestError extends Error {
// ── Role normalization ────────────────────────────────────────────────────
/**
* OpenAI deprecated role='function' in favour of role='tool'.
* Per ADR 0003, IR supports system/user/assistant/tool.
* Normalize entry-surface role names IR canonical set (system/user/assistant/tool).
*
* Per ADR 0003, IR supports exactly four roles. OpenAI's chat-completions
* spec has evolved beyond that, and we keep the IR minimal by normalizing
* at the entry boundary instead of bloating IR + every provider plugin.
*
* Current normalizations:
* - `function` `tool` deprecated in OpenAI chat API, replaced by tool.
* - `developer` `system` OpenAI o1/o3+ reasoning models accept a new
* "developer" role with similar semantics to "system" (high-priority
* instructions from the developer to the model). Providers like Hermes
* Agent and Cline default to `developer` for openai-completions calls.
* OLP-side anthropic + codex providers don't differentiate developer
* from system, so the IR canonicalizes to `system` and downstream
* translations remain unchanged.
*
* @param {string} role
* @returns {string}
*/
function normalizeRole(role) {
if (role === 'function') return 'tool';
if (role === 'developer') return 'system';
return role;
}
+10 -1
View File
@@ -40,7 +40,7 @@ export const VALID_ROLES = ['system', 'user', 'assistant', 'tool'];
/**
* @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 {string} model
* @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}}
if (obj.tool_choice !== undefined) {
if (typeof obj.tool_choice === 'string') {
+593
View File
@@ -0,0 +1,593 @@
/**
* 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, plaintext_advertise = false } = 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');
}
// D69 plaintext_advertise (ADR 0011): only valid on guest tier — see ADR
// 0011 § "Trusted-LAN invariant + tier restriction". Owner-tier advertisement
// is rejected because exposing the owner identity unauthenticated would
// grant unauthenticated callers /health full payload, /v0/management/* access,
// and X-OLP-Fallback-Detail visibility — the inverse of the advertise key's
// intent (a low-privilege zero-config tier).
if (plaintext_advertise && owner_tier !== 'guest') {
throw new Error('createKey: plaintext_advertise requires owner_tier="guest" (ADR 0011)');
}
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,
};
// D69 (ADR 0011): when the operator explicitly opts in via --advertise on
// keygen, the plaintext token is co-located with the hash so the server can
// surface it via /health.anonymousKey for zero-config family-LAN setup.
// This is the ONLY place plaintext ever lands on disk; see ADR 0011 for
// the trusted-LAN-only invariant + threat model.
if (plaintext_advertise) {
manifest.plaintext_advertise = plaintext_token;
}
writeManifestAtomic(id, manifest, { olpHome });
return { id, plaintext_token, manifest };
}
// ── D69 advertise-key discovery (ADR 0011) ───────────────────────────────
/**
* Find the active key marked for /health advertisement. Returns the manifest
* (including `plaintext_advertise`) or null when no such key exists.
*
* Scans every manifest under ~/.olp/keys/; selects the FIRST active
* (revoked_at === null) manifest that carries a non-empty `plaintext_advertise`
* string. Deterministic ordering is unstable across filesystems operators
* are expected to keep at most one advertised key on disk at a time.
*
* Returns null if:
* - the keys directory doesn't exist
* - no manifest carries plaintext_advertise
* - the only matching manifest is revoked
*
* Used by server.mjs handleHealth (D69) + olp-keys CLI 'list' subcommand
* (advertise badge).
*
* @param {object} [opts]
* @param {string} [opts.olpHome] - test override; defaults to ~/.olp
* @returns {object|null} manifest object (NOT redacted; carries plaintext_advertise)
*/
export function findAdvertisedKey(opts = {}) {
const dir = _keysDir(opts);
if (!existsSync(dir)) return null;
let entries;
try { entries = readdirSync(dir); } catch { return null; }
for (const id of entries) {
if (id.startsWith('.')) continue;
let m;
try { m = readManifest(id, opts); } catch { continue; }
if (m === null) continue;
if (m.revoked_at !== null) continue;
if (typeof m.plaintext_advertise === 'string' && m.plaintext_advertise.length > 0) {
return m;
}
}
return null;
}
/**
* 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;
// D69 reviewer P2-1 (footgun-removal): strip BOTH `token_hash` AND
// `plaintext_advertise` from list output. Callers wanting the
// advertised plaintext for the /health publication path must go
// through `findAdvertisedKey()` instead, which is the only sanctioned
// read site. Future callers of `listKeys()` that emit results into
// logs / HTTP responses / dashboards therefore can't accidentally
// leak the advertised plaintext.
const { token_hash, plaintext_advertise, ...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)
*
* D69 / ADR 0011:
* - advertise_anonymous_key: false (default off; opt-in surfaces
* findAdvertisedKey() plaintext via
* /health.anonymousKey)
*
* 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', advertise_anonymous_key: boolean }}
*/
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',
advertise_anonymous_key: false,
};
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,
advertise_anonymous_key: typeof auth.advertise_anonymous_key === 'boolean' ? auth.advertise_anonymous_key : DEFAULTS.advertise_anonymous_key,
};
} 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;
}
File diff suppressed because it is too large Load Diff
+34 -2
View File
@@ -30,6 +30,13 @@
* collectAllChunks directly. ADR 0002 Amendment 3 (D23).
*/
/**
* @typedef {Object} DoctorCheck
* @property {string} id - unique per check, e.g. 'anthropic.cli_available'
* @property {'provider'} category - fixed for plugin-contributed checks (ADR 0002 Amendment 7)
* @property {function} run - async () => { status: 'ok'|'fail'|'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }
*/
/**
* @typedef {Object} ProviderContractV1
* @property {string} name - unique lowercase key
@@ -41,6 +48,7 @@
* @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null
* @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null
* @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string}
* @property {function} [doctorChecks] - OPTIONAL () => DoctorCheck[] (ADR 0002 Amendment 7, D67)
* @property {ProviderHints} hints
*/
@@ -113,6 +121,13 @@ export function validateProvider(p) {
errors.push('healthCheck must be a function');
}
// ADR 0002 Amendment 7 (D67): doctorChecks() is optional. When present it must be
// a function; absence is allowed (plugin contributes no provider-tier checks to
// `olp doctor` — built-in server/system/auth checks still run).
if (p.doctorChecks !== undefined && typeof p.doctorChecks !== 'function') {
errors.push('doctorChecks must be a function or omitted');
}
if (!p.hints || typeof p.hints !== 'object') {
errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional { maxSpawnTimeMs, cacheable }');
} else {
@@ -141,8 +156,15 @@ export function validateProvider(p) {
/**
* Error codes surfaced by provider plugins.
*
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7):
* SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7 and Amendment 4, D38):
* 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
* underlying-API HTTP status codes at v0.1, so these codes are never emitted.
@@ -154,6 +176,16 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
'CLI_NOT_FOUND',
'SPAWN_FAILED',
'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)
/* ADR 0005 Amendment 8 §8 (D57): per-client streaming queue overflow.
* NOT a hard trigger the source spawned successfully and other attached
* clients continue to receive chunks; only this client's queue exceeded
* PER_CLIENT_QUEUE_CAP. The affected client receives a synthetic
* { type: 'stop', finish_reason: 'length' } + [DONE] terminator.
* D58 wires server-side header/log surface; HARD_TRIGGER_CODES in
* lib/fallback/engine.mjs is a whitelist so absence here gives the
* correct default (no fallback advancement). */
'STREAM_BACKPRESSURE',
]);
export class ProviderError extends Error {
+163 -3
View File
@@ -157,6 +157,36 @@ function resolveCodexBin() {
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
//
// ── D75 (v0.4.2) F1 — codex CLI v0.133.0 schema pin ─────────────────────────
//
// Real codex CLI v0.133.0 auth.json (verified empirically on PI231 / Mac mini,
// 2026-05-26 E2E session):
//
// {
// "auth_mode": "chatgpt",
// "OPENAI_API_KEY": null | "<key>",
// "tokens": {
// "id_token": "<JWT>",
// "access_token": "<opaque-or-JWT>", <-- THIS is the access token
// "refresh_token": "<opaque>",
// "account_id": "<uuid>"
// },
// "last_refresh": "<ISO8601>"
// }
//
// D6 assumption A3 originally tried `creds.access_token` at TOP level. Under
// codex v0.133.0 this field does not exist at the top level → readAuthArtifact()
// returned null even when the user had fully completed `codex login`. OLP then
// reported "auth artifact missing" via /health and `olp doctor`, and refused
// to spawn codex — false negative blocking the entire openai provider.
//
// Fix: prepend `creds?.tokens?.access_token` to the precedence chain. Keep all
// existing fallbacks unchanged so older codex CLI versions (pre-v0.133) and any
// future shape variants still resolve.
//
// Authority pin: codex CLI v0.133.0 source + on-disk auth.json captured during
// PI231 E2E. See D75 commit body for the verification transcript.
//
// Returns { accessToken: string } or null (never throws).
export function readAuthArtifact() {
// 1. Explicit test override — always takes precedence.
@@ -165,7 +195,12 @@ export function readAuthArtifact() {
try {
const raw = readFileSync(authPathOverride, 'utf8');
const creds = JSON.parse(raw);
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
// Preserve top-level fallbacks for backward / forward compat.
const token = creds?.tokens?.access_token
?? creds?.access_token
?? creds?.token
?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token };
} catch { /* fall through */ }
return null; // explicit path set but file missing / malformed
@@ -178,8 +213,12 @@ export function readAuthArtifact() {
try {
const raw = readFileSync(authPath, 'utf8');
const creds = JSON.parse(raw);
// D6 assumption A3: try common OAuth field names in precedence order.
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
// Try the nested location FIRST, then fall back to legacy top-level fields.
const token = creds?.tokens?.access_token
?? creds?.access_token
?? creds?.token
?? creds?.accessToken;
if (token && typeof token === 'string') return { accessToken: token };
} catch { /* file missing or malformed */ }
@@ -242,9 +281,30 @@ export function irToCodex(irRequest) {
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
// from stdin."
//
// ── D75 (v0.4.2) F2 — codex CLI v0.133.0 trusted-directory sandbox ─────────
// codex CLI v0.133.0 added a trusted-directory sandbox: invocations outside
// a git repo (or outside any directory explicitly trusted via
// `codex config trusted-directories`) refuse with:
// "Not inside a trusted directory and --skip-git-repo-check was not specified."
// and exit non-zero with zero NDJSON output → OLP surfaces SPAWN_FAILED with
// no usable chunks → fallback engine advances to next hop unnecessarily.
//
// The CWD that OLP spawns from is typically the server install dir (`~/olp/`
// on Pi231) which is a git repo on maintainer workstations but is NOT a git
// repo on most operator hosts. We bypass the sandbox unconditionally because
// OLP is the trusted caller (it is the operator's own server invoking its own
// configured Codex subscription via the documented `codex exec` automation
// entry point). The trusted-directory sandbox is a foot-gun safeguard for
// interactive users; OLP's spawn is non-interactive and pre-authorized.
//
// Authority: codex CLI v0.133.0 release notes / `codex exec --help` output
// documenting `--skip-git-repo-check`. Verified empirically on PI231 E2E
// 2026-05-26.
const args = [
'exec',
'--json',
'--skip-git-repo-check',
'--model', irRequest.model,
];
@@ -291,6 +351,51 @@ export function codexChunkToIR(rawNDJSONLine) {
if (!event || typeof event !== 'object') return null;
// ── D75 (v0.4.2) F3 — codex CLI v0.133.0 event shape pin ─────────────────
// Real codex CLI v0.133.0 NDJSON event stream (verified empirically on PI231
// / Mac mini, 2026-05-26 E2E session):
// {"type":"thread.started","thread_id":"019e..."}
// {"type":"turn.started"}
// {"type":"item.started","item":{"id":"item_0","type":"reasoning","text":""}}
// {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}
// {"type":"turn.completed","usage":{"input_tokens":..,"output_tokens":..}}
//
// The D6 defensive parser recognized `content`/`delta`/`text` fields at the
// top level and `type === 'stop'`/`done === true`. None of these match
// v0.133.0's actual shape → every chunk was silently dropped → response body
// had `content: null`. F3 adds three NEW recognizers (item.completed →
// agent_message; turn.completed → stop; turn.failed → error) BEFORE the
// legacy fallback chain. Legacy recognizers preserved for forward/backward
// compat (older codex versions; future shape variants).
// F3-a: agent_message item completion.
// codex v0.133.0 emits assistant text as a single item.completed event whose
// item.type is 'agent_message' and item.text carries the full text. There
// are no incremental deltas — the entire response arrives in one chunk.
if (event.type === 'item.completed'
&& event.item?.type === 'agent_message'
&& typeof event.item?.text === 'string') {
return { type: 'delta', content: event.item.text };
}
// F3-b: turn completion → stop chunk.
// codex v0.133.0 emits turn.completed with a usage block when the model
// finishes. We map this to IR stop with finish_reason 'stop'.
if (event.type === 'turn.completed') {
return { type: 'stop', finish_reason: 'stop' };
}
// F3-c: turn failure → error chunk.
// codex v0.133.0 emits turn.failed with an embedded error object when the
// turn cannot complete. Extract a human-readable message for the IR error.
if (event.type === 'turn.failed') {
const errMsg = (typeof event.error === 'string')
? event.error
: (event.error?.message ?? 'codex turn.failed');
return { type: 'error', error: errMsg };
}
// ── Legacy/fallback recognizers (kept for backward + forward compat) ────
// Error event: type === 'error' or error field present
// A4: defensive — error shape unconfirmed; D7 will pin actual field names
if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
@@ -637,6 +742,59 @@ function _defaultBinaryExists() {
}
}
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
//
// Probes:
// openai.cli_available — `codex --version` resolves on PATH (or via OLP_CODEX_BIN)
// openai.auth_present — `readAuthArtifact()` returns a non-empty accessToken
// (Codex CLI reference § Authentication: credentials in $CODEX_HOME, default ~/.codex/auth.json)
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact;
return [
{
id: 'openai.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`codex` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`codex` binary not found on PATH (and OLP_CODEX_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @openai/codex',
],
reference: 'https://developers.openai.com/codex/cli/reference',
},
};
},
},
{
id: 'openai.auth_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.accessToken) {
return { status: 'ok', message: 'Codex auth artifact present ($CODEX_HOME/auth.json)' };
}
return {
status: 'fail',
message: 'Codex auth artifact missing — $CODEX_HOME/auth.json does not contain access_token (default $CODEX_HOME=~/.codex)',
evidence: {
human_steps: [
'run: codex (the first interactive launch prompts for OAuth login per Codex CLI reference § Authentication)',
],
reference: 'https://developers.openai.com/codex/cli/reference',
},
};
},
},
];
}
// ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
@@ -662,6 +820,8 @@ const codex = {
estimateCost,
quotaStatus,
healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: {
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
concurrentSpawnSafe: true, // each invocation is independent
+130
View File
@@ -209,3 +209,133 @@ export function getProviderByName(loadedProviders, name) {
export function listAllProviderNames() {
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();
}
+71 -10
View File
@@ -142,17 +142,22 @@
* D-later E2E will capture real `vibe --output json` stdout and pin the
* 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
* Status: UNPINNED-D-later-verifies
* Basis: DOCS-3 mentions model selection via "/config" inside the interactive
* Vibe UI. The quickstart (DOCS-1) does not show a `--model` CLI flag for
* programmatic mode. OLP does NOT pass `--model` in the spawn args at D8
* because no CLI reference confirms this flag exists on the `vibe` command
* (per ALIGNMENT.md Rule 2: "if the underlying authority does not perform
* the operation, the PR must state this explicitly").
* D-later E2E: run `vibe --help` to enumerate all flags; if --model exists
* and the flag name is confirmed, add it to spawn args with the model ID.
* Status: CONFIRMED-NOT-APPLICABLE
* Basis: DeepWiki (DOCS-4) full CLI command flag enumeration confirms that
* `vibe` has no `--model` flag in programmatic mode. Model selection happens
* exclusively via `~/.vibe/config.toml` (set interactively via the `/config`
* command inside Vibe per DOCS-3) there is no CLI-flag surface OLP can use
* to pass `model` per-request. ALIGNMENT.md Rule 2: the underlying authority
* does not perform the operation, so OLP must not invent one. The IR's
* `model` field is used by OLP for routing only; the Vibe CLI will use
* 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):
* Name: model_ids
@@ -759,6 +764,60 @@ function _defaultBinaryExists() {
}
}
// ── doctorChecks (ADR 0002 Amendment 7, D67) ──────────────────────────────
// See lib/providers/anthropic.mjs doctorChecks header for the contract.
//
// Probes:
// mistral.cli_available — `vibe --version` resolves on PATH (or via OLP_VIBE_BIN)
// mistral.api_key_present — readAuthArtifact() returns apiKey (MISTRAL_API_KEY env or ~/.vibe/.env)
// (DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration —
// auth from MISTRAL_API_KEY env / ~/.vibe/.env)
export function doctorChecks({ _binaryExistsFn, _authReadFn } = {}) {
const binaryExists = _binaryExistsFn ?? _defaultBinaryExists;
const authRead = _authReadFn ?? readAuthArtifact;
return [
{
id: 'mistral.cli_available',
category: 'provider',
async run() {
if (binaryExists()) {
return { status: 'ok', message: '`vibe` binary resolved on PATH' };
}
return {
status: 'fail',
message: '`vibe` binary not found on PATH (and OLP_VIBE_BIN unset/invalid)',
evidence: {
fix_commands: [
'npm install -g @mistralai/vibe',
],
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/quickstart',
},
};
},
},
{
id: 'mistral.api_key_present',
category: 'provider',
async run() {
const auth = authRead();
if (auth?.apiKey) {
return { status: 'ok', message: 'Mistral API key present (env MISTRAL_API_KEY or ~/.vibe/.env)' };
}
return {
status: 'fail',
message: 'Mistral API key missing — neither MISTRAL_API_KEY env nor ~/.vibe/.env (or $VIBE_HOME/.env) supplied a key',
evidence: {
human_steps: [
'export MISTRAL_API_KEY=<your-key> # or write MISTRAL_API_KEY=... into ~/.vibe/.env',
],
reference: 'https://docs.mistral.ai/mistral-vibe/terminal/configuration',
},
};
},
},
];
}
// ── Provider export ───────────────────────────────────────────────────────
// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion.
@@ -790,6 +849,8 @@ const mistral = {
estimateCost,
quotaStatus,
healthCheck,
// ADR 0002 Amendment 7 (D67): OPTIONAL doctorChecks() — consumed by `olp doctor`.
doctorChecks: () => doctorChecks(),
hints: {
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
concurrentSpawnSafe: true, // each invocation is independent
+290
View File
@@ -0,0 +1,290 @@
/**
* lib/sandbox/doctor.mjs Sandbox availability preflight module (Phase 7 PR-A)
*
* Authority:
* @anthropic-ai/sandbox-runtime v0.0.52
* https://github.com/anthropic-experimental/sandbox-runtime
*
* 2026-05-28 PoC spike on PI231 (arm64 Debian Bookworm): dep install clean,
* isSupportedPlatform()=true, blocked on apt deps (bwrap + socat), three PoC
* scripts parked at /tmp/sandbox-spike/ on PI231.
*
* OLP ADR 0014 Sandbox-Runtime Integration for Multi-Tenant Provider Spawning
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
* docs/plans/cloud-deployment-family.md § 5
*
* Design:
* Pure module no state, no side effects beyond child_process.execFileSync for
* `which` probes. Does NOT call SandboxManager.initialize(). Does NOT create
* or interact with any real sandbox. Safe to call from /health on every request
* (results are memoized process-wide by the caller in server.mjs see
* _sandboxStatusCache there).
*
* Exports:
* checkSandboxAvailability() returns { available, missing, details }
* describeSandboxStatus() returns { ok, message } human-readable summary
*/
import { execFileSync } from 'node:child_process';
import { platform as osPlatform } from 'node:os';
// ── which probe helper ────────────────────────────────────────────────────
/**
* Check if a binary is in PATH by running `which <binary>`.
* Returns true if found, false if not found or if `which` is unavailable.
* Never throws.
* @param {string} binary
* @returns {boolean}
*/
function isInPath(binary) {
try {
execFileSync('which', [binary], { stdio: 'pipe', timeout: 2000 });
return true;
} catch {
return false;
}
}
// ── Platform helper ───────────────────────────────────────────────────────
/**
* Map Node's process.platform to the sandbox-runtime platform string.
* @returns {'linux'|'macos'|'other'}
*/
function getPlatformName() {
const p = osPlatform();
if (p === 'linux') return 'linux';
if (p === 'darwin') return 'macos';
return 'other';
}
// ── Library introspection ─────────────────────────────────────────────────
/**
* Attempt to import @anthropic-ai/sandbox-runtime and call its exported
* isSupportedPlatform + checkDependencies. Returns structured findings.
* Never throws all errors become { libError: <message> }.
*
* @returns {Promise<{
* libLoaded: boolean,
* libError: string|null,
* isSupportedPlatform: boolean,
* libDependencyErrors: string[],
* libDependencyWarnings: string[],
* }>}
*/
async function probeLibrary() {
try {
const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime');
let supportedPlatform = false;
try {
supportedPlatform = SandboxManager.isSupportedPlatform();
} catch (e) {
return {
libLoaded: true,
libError: `isSupportedPlatform() threw: ${e?.message ?? e}`,
isSupportedPlatform: false,
libDependencyErrors: [],
libDependencyWarnings: [],
};
}
// checkDependencies() requires initialize() to have been called first to
// set ripgrep/bwrap/socat config. Since PR-A never calls initialize(), we
// call checkDependencies() with an undefined argument — the library falls
// back to { command: 'rg' } for ripgrep and PATH lookup for bwrap/socat,
// which is exactly what we want for the doctor preflight.
let libDependencyErrors = [];
let libDependencyWarnings = [];
if (supportedPlatform) {
try {
const depCheck = SandboxManager.checkDependencies(undefined);
libDependencyErrors = depCheck?.errors ?? [];
libDependencyWarnings = depCheck?.warnings ?? [];
} catch (e) {
// checkDependencies() can throw before initialize() — not fatal
libDependencyErrors = [`checkDependencies() threw: ${e?.message ?? e}`];
}
}
return {
libLoaded: true,
libError: null,
isSupportedPlatform: supportedPlatform,
libDependencyErrors,
libDependencyWarnings,
};
} catch (e) {
return {
libLoaded: false,
libError: `@anthropic-ai/sandbox-runtime import failed: ${e?.message ?? e}`,
isSupportedPlatform: false,
libDependencyErrors: [],
libDependencyWarnings: [],
};
}
}
// ── Public API ────────────────────────────────────────────────────────────
/**
* Check sandbox availability (OS deps + library platform support).
*
* Returns:
* {
* available: boolean, // true only when all hard deps pass on a supported platform
* missing: string[], // friendly names of missing hard deps (e.g. 'bubblewrap', 'socat')
* details: {
* platform: string, // 'linux'|'macos'|'other'
* bwrap: boolean, // which bwrap → found
* socat: boolean, // which socat → found
* ripgrep: boolean, // which rg → found
* isSupportedPlatform: boolean,
* libLoaded: boolean,
* libError: string|null,
* libDependencyErrors: string[],
* libDependencyWarnings: string[],
* }
* }
*
* The `missing` array uses human-readable package names ('bubblewrap', 'socat',
* 'ripgrep') so that install hints are directly actionable.
*
* Does NOT call SandboxManager.initialize() pure inspection only.
* Does NOT cache the caller (server.mjs) memoizes the result.
*/
export async function checkSandboxAvailability() {
const platform = getPlatformName();
// Probe OS-level deps independently of the library (the `which` calls are
// cheap and always correct; library's checkDependencies may be less precise
// when initialize() hasn't been called).
const bwrap = isInPath('bwrap');
const socat = isInPath('socat');
const ripgrep = isInPath('rg');
// Library introspection (import + isSupportedPlatform + checkDependencies)
const lib = await probeLibrary();
// Determine what's missing for the doctor report.
// Only report OS deps as missing on Linux (where bwrap/socat/rg are required);
// macOS uses sandbox-exec which is built-in, so these are not hard requirements.
const missing = [];
if (platform === 'linux') {
if (!bwrap) missing.push('bubblewrap');
if (!socat) missing.push('socat');
if (!ripgrep) missing.push('ripgrep');
}
// If the library itself failed to load, that's also a blocker
if (!lib.libLoaded) {
missing.push('@anthropic-ai/sandbox-runtime (import failed)');
}
// Library-reported hard dep errors (may overlap with our `which` probes;
// deduplicate by treating them as additional evidence rather than re-adding)
for (const errMsg of lib.libDependencyErrors) {
// Only add if it doesn't overlap with what we already reported
const isAlreadyCovered =
(errMsg.includes('bwrap') && !bwrap) ||
(errMsg.includes('socat') && !socat) ||
(errMsg.includes('ripgrep') && !ripgrep) ||
(errMsg.includes('Unsupported platform'));
if (!isAlreadyCovered && !missing.includes(errMsg)) {
missing.push(errMsg);
}
}
const available =
lib.libLoaded &&
lib.isSupportedPlatform &&
missing.length === 0;
return {
available,
missing,
details: {
platform,
bwrap,
socat,
ripgrep,
isSupportedPlatform: lib.isSupportedPlatform,
libLoaded: lib.libLoaded,
libError: lib.libError,
libDependencyErrors: lib.libDependencyErrors,
libDependencyWarnings: lib.libDependencyWarnings,
},
};
}
/**
* Human-readable sandbox status summary for /health and CLI consumers.
*
* Returns:
* {
* ok: boolean, // same as checkSandboxAvailability().available
* message: string, // multi-line, includes install hint when deps are missing
* }
*
* Does NOT call SandboxManager.initialize() pure inspection only.
*/
export async function describeSandboxStatus() {
const result = await checkSandboxAvailability();
const { available, missing, details } = result;
if (available) {
return {
ok: true,
message:
`Sandbox available on ${details.platform}` +
(details.libDependencyWarnings.length > 0
? `. Warnings: ${details.libDependencyWarnings.join('; ')}`
: '.'),
};
}
// Build a friendly explanation
const lines = [];
if (!details.libLoaded) {
lines.push(`Sandbox library not available: ${details.libError ?? 'import failed'}`);
} else if (!details.isSupportedPlatform) {
lines.push(
`Sandbox dependencies not available: platform '${details.platform}' is not supported by @anthropic-ai/sandbox-runtime v0.0.52.`,
);
} else {
// Platform is supported but OS deps are missing
const pkgNames = missing.filter(m => !m.includes('import failed'));
if (pkgNames.length > 0) {
lines.push(`Sandbox dependencies not available: ${pkgNames.map(m => `${m} not installed`).join(', ')}.`);
}
}
// Install hint (only for Linux; macOS sandbox uses sandbox-exec which is built-in)
if (details.platform === 'linux' && (missing.includes('bubblewrap') || missing.includes('socat') || missing.includes('ripgrep'))) {
const aptPkgs = [];
if (missing.includes('bubblewrap')) aptPkgs.push('bubblewrap');
if (missing.includes('socat')) aptPkgs.push('socat');
if (missing.includes('ripgrep')) aptPkgs.push('ripgrep');
lines.push(
`Install on Debian/Ubuntu/Raspbian: sudo apt-get install -y ${aptPkgs.join(' ')}`,
);
}
// macOS note (PR-A does not wire macOS sandbox-exec; PR-B will)
if (details.platform === 'macos') {
lines.push(
'macOS: sandbox-exec is built-in, but anthropic provider wrapping lands in PR-B. ' +
'macOS sandbox integration is not yet wired in this PR (PR-A). ',
);
}
if (details.libDependencyWarnings.length > 0) {
lines.push(`Warnings: ${details.libDependencyWarnings.join('; ')}`);
}
return {
ok: false,
message: lines.join('\n'),
};
}
+409
View File
@@ -0,0 +1,409 @@
/**
* lib/sandbox/manager.mjs Sandbox manager bootstrap + spawn-wrap (Phase 7 PR-B)
*
* Authority:
* @anthropic-ai/sandbox-runtime v0.0.52
* https://github.com/anthropic-experimental/sandbox-runtime
* dist/sandbox/sandbox-manager.js SandboxManager.initialize(), wrapWithSandbox()
* dist/sandbox/sandbox-utils.js getDefaultWritePaths() (used internally)
*
* 2026-05-28 PR-A spike report on PI231 (arm64 Debian Bookworm):
* /tmp/sandbox-spike/spike-anthropic.mjs wrapWithSandbox call signature,
* CLAUDE_CODE_OAUTH_TOKEN env passthrough, shell-mode spawn pattern.
* OLP ADR 0014 § Decision (singleton at boot) + § PR-B specific scope
* OLP ADR 0009 Amendment 1 § Caveats #3 (sandbox is cloud prerequisite)
* cc-mem incident 2026-05-27 § 3 (multi-tenant security gap motivation)
* ALIGNMENT.md Rule 1 provider plugin authority citation
*
* Design:
* One-shot bootstrap at server startup (idempotent). If sandbox not available
* (doctor.available=false or SandboxManager.initialize throws), bootstrap is a
* no-op and isSandboxActive() returns false provider falls back to direct spawn
* (transparent pass-through).
*
* Singleton pattern: SandboxManager is a process-wide singleton per library
* design (reset() clears ALL state). PR-B initializes once at boot with union
* config (Anthropic domains only; codex config follows in PR-C). Per-request
* wrapSpawn() calls SandboxManager.wrapWithSandbox() which reads from the
* already-initialized config state no per-request initialize().
*
* ADR 0014 § Pitfalls #4: SandboxManager.reset() in test teardown must happen
* in finally blocks; concurrent in-flight spawns may break if reset fires while
* a wrapWithSandbox call is in-flight. OLP's current single-server model (one
* process) makes this safe: tests call __resetSandboxManagerForTests() which
* also calls SandboxManager.reset() only safe in test context where no real
* spawns are in-flight.
*
* Exports:
* bootstrapSandbox(opts?) one-shot bootstrap; returns { active, reason?, summary? }
* isSandboxActive() synchronous query
* wrapSpawn({ bin, args, env, cwd, allowedDomains })
* wraps spawn args; transparent pass-through when inactive
* __resetSandboxManagerForTests() test seam: reset internal state + SandboxManager
*/
import { createHash } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { checkSandboxAvailability } from './doctor.mjs';
// ── Internal state ────────────────────────────────────────────────────────
/**
* Whether bootstrapSandbox() has been called (initialized = true means we
* ran through bootstrap, not necessarily that sandbox is active).
* @type {boolean}
*/
let _initialized = false;
/**
* Whether the SandboxManager was successfully initialized and is ready to wrap.
* @type {boolean}
*/
let _active = false;
/**
* The config-at-boot snapshot passed to SandboxManager.initialize().
* Null if never initialized or bootstrap failed.
* @type {object|null}
*/
let _initConfig = null;
// ── Ephemeral workspace root ─────────────────────────────────────────────
// Per-request cwd: /tmp/olp-spawn/<uuid>/ — unique per request to prevent
// cross-request contamination. Caller (provider) owns cleanup (or trusts tmpfs
// lifetime). Created by mkdirSync(recursive:true) inside wrapSpawn().
const SPAWN_BASE_DIR = '/tmp/olp-spawn';
// ── Custom error types ───────────────────────────────────────────────────
export class SandboxBootstrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxBootstrapError';
}
}
export class SandboxWrapError extends Error {
constructor(message) {
super(message);
this.name = 'SandboxWrapError';
}
}
// ── bootstrapSandbox ──────────────────────────────────────────────────────
/**
* One-shot bootstrap of the sandbox. Idempotent safe to call multiple times.
* If already bootstrapped, returns cached result immediately.
*
* Steps:
* 1. Call checkSandboxAvailability() from doctor module.
* 2. If !available set _active=false, return { active:false, reason }.
* 3. If available build config-at-boot, call SandboxManager.initialize(config).
* 4. On init success _active=true, return { active:true, summary }.
* 5. On init failure log + _active=false + return error (server still starts).
*
* The network allowedDomains covers the Anthropic provider only (PR-B scope).
* Codex domains will be added in PR-C alongside the enableWeakerNestedSandbox flag.
*
* ADR 0014 § PR-B: denyRead covers ~/.olp, ~/.claude, ~/.ssh, ~/.config, ~/.codex
* using absolute literal Linux paths (no globs see ADR 0014 § Pitfalls #2).
* ~/.olp contains keys.json (OLP API keys). ~/.claude contains OAuth credentials.
* ~/.ssh and ~/.config contain identity material. ~/.codex contains codex config.
*
* @param {object} [opts]
* @param {boolean} [opts.force=false] if true, re-run bootstrap even if already initialized
* @returns {Promise<{ active: boolean, reason?: string, summary?: string }>}
*/
export async function bootstrapSandbox(opts = {}) {
// Return cached result if already initialized (unless forced)
if (_initialized && !opts.force) {
return _active
? { active: true, summary: _buildSummary() }
: { active: false, reason: _initConfig?.failReason ?? 'sandbox not available' };
}
// OLP_SANDBOX_DISABLED env-var gate (2026-05-28 PR-B emergency disable):
// Live PI231 evidence showed that even with the exit-null guard, HTTP-path
// anthropic spawns produced no claude stdout when wrapped (manual exec of
// the SAME wrap script in the same process did produce output — root cause
// not yet isolated; likely interaction between SandboxManager in-process
// proxy sockets and OLP's request-handler event loop). Until the root cause
// is debugged + Suite 44-equivalent E2E tests cover the HTTP path, the
// sandbox bootstrap is opt-out via OLP_SANDBOX_DISABLED=1 in the server env.
//
// Default is sandbox-enabled (no env var = try-and-bootstrap). Sandbox is
// skipped only when the operator explicitly disables.
//
// Future PR-B follow-up: investigate the in-process proxy lifecycle
// interaction with OLP's HTTP server event loop; capture diagnostic
// transcript; ship Suite 44-equivalent that exercises the full HTTP
// request → sandbox spawn → response pipeline.
if (process.env.OLP_SANDBOX_DISABLED === '1') {
_initialized = true;
_active = false;
_initConfig = { failReason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator' };
return {
active: false,
reason: 'OLP_SANDBOX_DISABLED=1 — sandbox bootstrap skipped by operator',
};
}
// Reset state for re-bootstrap
_initialized = false;
_active = false;
_initConfig = null;
// Step 1: Check OS + library availability
let availability;
try {
availability = await checkSandboxAvailability();
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `doctor check threw: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
if (!availability.available) {
_initialized = true;
_active = false;
const reason = availability.missing.length > 0
? `sandbox deps missing: ${availability.missing.join(', ')}`
: `sandbox not available on platform: ${availability.details?.platform}`;
_initConfig = { failReason: reason };
return { active: false, reason };
}
// Step 2: Build config-at-boot
// Network allowedDomains: Anthropic provider API domains (PR-B scope).
// - api.anthropic.com: primary Anthropic API endpoint
// - statsig.anthropic.com: claude CLI telemetry (verified empirically in spike;
// required by claude CLI OAuth token refresh path — removing it causes auth failure)
// TODO(PR-C): union in codex/openai provider domains when codex wrap lands.
const allowedDomains = [
'api.anthropic.com',
'statsig.anthropic.com',
];
const home = homedir();
// denyRead: Absolute literal Linux paths per ADR 0014 § Pitfalls #2.
// No ~ or glob — ripgrep glob expansion is not used here to stay safe on
// both Linux (bwrap) and macOS (sandbox-exec profile).
//
// 2026-05-28 PR-B fold-in: ~/.claude is NOT in denyRead. It contains the
// spawn's own OAuth credentials — claude CLI must read its own auth file
// to function. Denying read here causes "Not logged in" failures even
// though the operator has valid credentials present.
//
// The cross-tenant risk for ~/.claude is mitigated by Phase 6c's
// --system-prompt flag (ADR 0009 Amendment 1): the system prompt is
// fully replaced, suppressing the default tool descriptions that would
// otherwise tell the model it has Read/Bash. Without tool descriptions,
// the model is highly unlikely to emit tool_use even under prompt
// injection. Sandbox's contribution here is protecting OTHER auth
// material (other clients' OLP keys, SSH identity, other providers'
// tokens) — files claude CLI does NOT legitimately need.
//
// If we ever switch to a CLI that requires reading credentials.json
// AND also legitimately offers tool execution that surfaces those files
// (no known case today), this trade-off needs revisiting.
const denyRead = [
join(home, '.olp'), // OLP API keys + config — cross-tenant
join(home, '.ssh'), // SSH identity material — lateral movement
join(home, '.config'), // Generic config dir (may contain tokens)
join(home, '.codex'), // Codex config — other-provider auth (PR-C will wrap codex)
// NOT denied: ~/.claude — this spawn's own auth, breaks claude CLI if denied
];
// allowWrite: ephemeral spawn workspace only. mkdirSync at bootstrap.
// getDefaultWritePaths() adds /dev/stdout, /dev/null etc. internally.
try {
mkdirSync(SPAWN_BASE_DIR, { recursive: true });
} catch (e) {
// Non-fatal: if this dir can't be created, wrapSpawn will fail per-request.
console.warn(`[sandbox/manager] Warning: could not create ${SPAWN_BASE_DIR}: ${e?.message}`);
}
const config = {
network: {
allowedDomains,
deniedDomains: [],
},
filesystem: {
denyRead,
allowWrite: [SPAWN_BASE_DIR, '/tmp'],
denyWrite: [],
},
};
// Step 3: Initialize SandboxManager
let SandboxManager;
try {
const mod = await import('@anthropic-ai/sandbox-runtime');
SandboxManager = mod.SandboxManager;
} catch (e) {
_initialized = true;
_active = false;
_initConfig = { failReason: `sandbox-runtime import failed: ${e?.message ?? e}` };
return { active: false, reason: _initConfig.failReason };
}
try {
// ADR 0014 § Pitfalls #5: initialize() generates MITM CA cert (~100-500ms).
// Must happen at boot, not per-request.
await SandboxManager.initialize(config);
_initialized = true;
_active = true;
_initConfig = { config, SandboxManager };
return { active: true, summary: _buildSummary() };
} catch (e) {
_initialized = true;
_active = false;
const reason = `SandboxManager.initialize failed: ${e?.message ?? e}`;
_initConfig = { failReason: reason };
// Log but DO NOT throw — server still starts in unsandboxed mode.
// PR-D will add hard-fail mode via config flag.
console.warn(`[sandbox/manager] WARNING: ${reason} — provider spawns will run UNSANDBOXED`);
return { active: false, reason };
}
}
/** @internal — returns summary string for logging */
function _buildSummary() {
const cfg = _initConfig?.config;
if (!cfg) return 'active (no config)';
const domains = (cfg.network?.allowedDomains ?? []).join(', ');
return `network allowlist=[${domains}], denyRead=[${(cfg.filesystem?.denyRead ?? []).length} paths], allowWrite=[${SPAWN_BASE_DIR}, /tmp]`;
}
// ── isSandboxActive ───────────────────────────────────────────────────────
/**
* Synchronous query of bootstrap state.
* Returns true only if bootstrapSandbox() completed successfully.
* Used by provider plugins to decide spawn path.
*
* @returns {boolean}
*/
export function isSandboxActive() {
return _active;
}
// ── wrapSpawn ─────────────────────────────────────────────────────────────
/**
* Wrap a spawn command + args for sandbox execution.
*
* Returns { bin, args, env, cwd, sandboxed: boolean }.
* - If sandbox inactive: returns inputs unchanged with sandboxed:false.
* - If sandbox active: returns the wrapped shell string as
* { bin: '/bin/sh', args: ['-c', wrappedShellString], env, cwd, sandboxed:true }.
*
* The wrapped command is a shell string from SandboxManager.wrapWithSandbox().
* It must be spawned with shell:true OR by invoking /bin/sh -c <string> directly
* (the latter is what we do here avoids relying on the shell that Node picks).
*
* Per-spawn ephemeral cwd uses a UUID to prevent cross-request contamination.
* The caller is responsible for cleanup (or trusts tmpfs lifetime).
*
* ADR 0014 § PR-B: env vars passed through unchanged so CLAUDE_CODE_OAUTH_TOKEN
* (if operator set at OLP boot time) still works inside the sandbox.
*
* @param {object} params
* @param {string} params.bin original binary (e.g. 'claude')
* @param {string[]} params.args original args
* @param {object} params.env spawn environment (from buildSpawnEnv())
* @param {string} [params.cwd] original cwd (ignored; replaced by ephemeral dir)
* @param {string[]} [params.allowedDomains] per-spawn domain override (passed as customConfig)
* @returns {Promise<{ bin: string, args: string[], env: object, cwd: string, sandboxed: boolean }>}
*/
export async function wrapSpawn({ bin, args, env, cwd: _cwd, allowedDomains }) {
// Transparent pass-through when sandbox inactive
if (!_active || !_initConfig?.SandboxManager) {
return {
bin,
args: args ?? [],
env: env ?? {},
cwd: _cwd,
sandboxed: false,
};
}
const SandboxManager = _initConfig.SandboxManager;
// Build the shell command string from bin + args.
// Each arg is shell-quoted to handle spaces and special characters.
// Authority: spike-anthropic.mjs line 29-31 — same quoting pattern.
const quotedArgs = (args ?? []).map(a =>
/[\s"'`$\\;&|<>()\[\]{}!#~*?]/.test(a)
? `"${a.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`')}"`
: a
);
const commandString = [bin, ...quotedArgs].join(' ');
// Per-spawn ephemeral cwd (UUID) — prevents cross-request contamination.
// ADR 0014 § PR-B: unique per request.
const reqId = createHash('sha256').update(`${Date.now()}-${Math.random()}`).digest('hex').slice(0, 16);
const spawnCwd = join(SPAWN_BASE_DIR, reqId);
try {
mkdirSync(spawnCwd, { recursive: true });
} catch (e) {
throw new SandboxWrapError(`Failed to create ephemeral spawn dir ${spawnCwd}: ${e?.message ?? e}`);
}
// Per-spawn customConfig: allow caller to override domains (e.g. different provider).
// Default: use the config-at-boot allowedDomains.
let customConfig;
if (allowedDomains && allowedDomains.length > 0) {
customConfig = {
network: {
allowedDomains,
deniedDomains: [],
},
};
}
let wrappedCommand;
try {
wrappedCommand = await SandboxManager.wrapWithSandbox(commandString, undefined, customConfig);
} catch (e) {
throw new SandboxWrapError(`SandboxManager.wrapWithSandbox failed: ${e?.message ?? e}`);
}
// Invoke via /bin/sh -c to avoid spawning a second shell layer.
// The wrapped command is already a complete shell invocation (bwrap args or
// sandbox-exec profile + the original command inside).
return {
bin: '/bin/sh',
args: ['-c', wrappedCommand],
env: env ?? {},
cwd: spawnCwd,
sandboxed: true,
};
}
// ── Test seam ─────────────────────────────────────────────────────────────
/**
* Reset internal state so test suite can simulate fresh process.
* Also calls SandboxManager.reset() if it was initialized (to clear singleton).
*
* ADR 0014 § Pitfalls #4: must only be called when no in-flight wrapSpawn calls
* are active. Safe in sequential test contexts.
*
* @returns {Promise<void>}
*/
export async function __resetSandboxManagerForTests() {
if (_active && _initConfig?.SandboxManager) {
try {
await _initConfig.SandboxManager.reset();
} catch { /* ignore — test teardown, best-effort */ }
}
_initialized = false;
_active = false;
_initConfig = null;
}
+35
View File
@@ -1,6 +1,41 @@
{
"version": "0.1.0-bootstrap",
"comment": "OLP models registry — SPOT for (provider, model) → metadata per CLAUDE.md release_kit overlay. v0.1 founding shipped zero Enabled Providers per ALIGNMENT.md § Provider Inventory. D4 populates providers.anthropic as Candidate; D5 transitions to Enabled pending E2E audit. Schema validated by .github/workflows/alignment.yml; provider keys must match ALIGNMENT.md inventory.",
"quota_probe": {
"schema_version": "2026-05-26",
"comment": "D81 — ADR 0013 Rule 5 mandate: schema_version pinned in registry so downstream consumers can detect schema drift. fields_pinned is load-bearing: if Anthropic adds/renames a header, dashboard consumers comparing field-presence against this list can flag 'schema drift detected'. Last verified: 2026-05-26 via live probe against api.anthropic.com (Path B per ADR 0013 Rule 5).",
"anthropic": {
"status": "live",
"source": "anthropic-ratelimit-unified-headers",
"endpoint": "https://api.anthropic.com/v1/messages",
"fields_pinned": [
"status",
"representative_claim",
"reset",
"fallback_percentage",
"status_5h",
"utilization_5h",
"reset_5h",
"status_7d",
"utilization_7d",
"reset_7d",
"overage_status",
"overage_disabled_reason",
"overage_reset"
]
},
"openai": {
"status": "unavailable",
"reason": "no public quota endpoint exposed by the openai/codex CLI; audit-derived spend tracking only at v0.5.0",
"re_entry_point": "lib/providers/openai.mjs DL-N (when OpenAI publishes a documented quota endpoint)"
},
"mistral": {
"status": "unavailable",
"reason": "no public quota endpoint accessible to Vibe / Le Chat member / La Plateforme API keys per D84 spike 2026-05-26 (https://docs.mistral.ai/api). Mistral Admin API exposes billing/usage but requires org-admin scope (out of scope for OLP family-tier deployment).",
"re_entry_point": "lib/providers/mistral.mjs DL-7 (when Mistral publishes a member-key-accessible usage endpoint, or when OLP scope expands to admin-key deployment)",
"admin_api_reference": "https://docs.mistral.ai/admin/security-access/admin-api"
}
},
"bootstrapCreated": 1778630400,
"bootstrapCreatedComment": "Fallback Unix timestamp for models whose precise release date is unknown. Value = 2026-05-13 (the day before the Anthropic billing-split announcement that triggered OLP). Used by handleModels() in server.mjs when a model entry does not have a model-level 'created' field. Per F12 round-5 cold-audit: OpenAI spec treats 'created' as a stable per-model attribute; synthesizing Date.now() on each request causes spurious updates for clients caching models by 'created'.",
"providers": {
+153
View File
@@ -0,0 +1,153 @@
# olp-plugin
OpenClaw gateway plugin that exposes a `/olp` slash command on Telegram and
Discord, with subcommand parity to the local `olp` CLI (`bin/olp.mjs`) minus
mutating operations.
**Authority:** [ADR 0010 § Phase 4 D71-D73](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md).
## Status
✅ Shipped at v0.4.0 (read-only subset of `olp` CLI).
## What you can do from chat
| Slash command | Maps to | Tier |
|---|---|---|
| `/olp status` | GET `/v0/management/status` | owner |
| `/olp health` | GET `/health` | public |
| `/olp usage` | GET `/v0/management/dashboard-data` | owner |
| `/olp models` | GET `/v1/models` | public |
| `/olp cache` | GET `/cache/stats` | owner |
| `/olp providers` | local registry view | public |
| `/olp chain show [model]` | local chain view (empty unless wired) | public |
| `/olp doctor` | informational only (HTTP doctor endpoint not yet shipped) | — |
| `/olp help` | usage text | — |
## What you can NOT do from chat (by design)
The following `olp` CLI subcommands are **deliberately not** ported to the
chat surface, because Telegram + Discord are shared / persistent message
streams and key material or raw audit logs should not be flowing across
them:
- `olp keys keygen` — key material would land in chat history
- `olp keys revoke` — accidental misclick could lock out clients
- `olp restart` — a misclick should not cycle the proxy
- `olp logs` — audit content may carry PII
Use SSH to the host running OLP and the local `olp` CLI for those.
## Install
The plugin is shipped inside the OLP repo at `olp-plugin/`. Two install paths:
### Option A — OpenClaw CLI
```bash
openclaw plugins install /path/to/olp/olp-plugin/
```
### Option B — symlink
```bash
mkdir -p ~/.openclaw/extensions/
ln -s /path/to/olp/olp-plugin/ ~/.openclaw/extensions/olp
```
Either path makes the plugin discoverable; restart the gateway to pick it up:
```bash
openclaw gateway restart
```
## Configure
Edit `~/.openclaw/openclaw.json` and add a config block for the `olp` plugin:
```json
{
"plugins": {
"olp": {
"proxyUrl": "http://127.0.0.1:4567",
"apiKey": "olp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
}
}
```
- `proxyUrl` — full URL of the OLP proxy. Default `http://127.0.0.1:4567`
(OLP's default since v0.4.0 / D60). Overridable via `OLP_PROXY_URL` or
`OLP_PORT` env if you run the gateway under launchd / systemd with custom
env.
- `apiKey`**owner-tier** OLP API key. Required for the subcommands marked
`owner` in the table above. Create one with:
```bash
# On the OLP host, NOT in chat:
npx olp-keys keygen --owner --name=openclaw-bot
# Capture the plaintext token from the output — it is printed exactly once.
```
Use a dedicated bot key (the `--name=openclaw-bot` example above) so you
can `npx olp-keys revoke --id=<id>` later without affecting the
maintainer's personal key.
## Use
In Telegram or Discord, after the gateway picks up the plugin:
```
/olp status
/olp usage
/olp models
/olp help
```
Output is wrapped in a monospace code block. Long responses are truncated
to fit Telegram's ~4096-char per-message limit; a `... [truncated, use SSH
for full]` suffix marks where the cut happened.
## Authorization model
The plugin sends `Authorization: Bearer <apiKey>` on every request. OLP's
server enforces:
- **public-tier endpoints** (`/health`, `/v1/models`) accept any non-revoked
key (or no key at all if `auth.allow_anonymous: true`).
- **owner-tier endpoints** (`/v0/management/*`, `/cache/stats`) reject any
non-owner key with 403.
If you see `401 unauthorized` or `403 forbidden` in chat:
- Verify the configured `apiKey` is a non-revoked **owner**-tier key.
- Verify the key was created on the same host running the OLP server (keys
are stored under `~/.olp/keys/` and validated by hash on the server side).
- Check the OLP server's `/health` directly with `curl` to confirm
reachability.
## Port resolution priority
1. `OLP_PROXY_URL` env (full URL) — useful when the gateway runs on a
different host than OLP and you proxy in via Tailscale.
2. `OLP_PORT` env (port only; localhost assumed).
3. Plugin config `proxyUrl`.
4. Fallback `http://127.0.0.1:4567`.
## Why no Telegram/Discord SDK dependency
OpenClaw provides the transport (Telegram bot + Discord bot are gateway
features). This plugin only registers a slash command — it does not open
its own websocket / long-poll connection. That means:
- No new npm dependency.
- No bot tokens stored in plugin config.
- Plugin works for any OpenClaw-supported chat surface (currently Telegram +
Discord; future surfaces inherit automatically).
## Cross-references
- [Local `olp` CLI](../bin/olp.mjs) — the full mutating-capable surface.
- [OCP `/ocp` plugin](https://github.com/dtzp555-max/ocp/tree/main/ocp-plugin) — the OCP predecessor this is ported from.
- [ADR 0010](../docs/adr/0010-phase-4-charter-operator-and-client-ux.md) — Phase 4 charter.
- [ADR 0007](../docs/adr/0007-multi-key-auth.md) — multi-key auth model that gates owner-tier subcommands.
+532
View File
@@ -0,0 +1,532 @@
/**
* OLP Plugin registers /olp as a native slash command in the OpenClaw gateway.
* Calls the local OLP proxy and formats the response for Telegram/Discord.
*
* Authority: ADR 0010 § Phase 4 D71-D73 (operator + client UX bundle). Ports
* OCP's ocp-plugin/index.js (https://github.com/dtzp555-max/ocp /ocp/ocp-plugin)
* to the OLP namespace with two structural differences:
*
* 1. **Read-only by design.** All mutating subcommands (`keygen`, `revoke`,
* `restart`, `logs`) are deliberately NOT ported. Telegram + Discord are
* shared / persistent surfaces; rotating an owner key or pulling raw audit
* logs from a chat client is a security regression. Use SSH + the local
* `olp` CLI for those operations.
*
* 2. **Bearer auth required.** OLP enforces multi-key auth at every /v1/* and
* /v0/management/* endpoint (ADR 0007 § 7). Owner-only subcommands need an
* OLP API key with owner_tier="owner". The plugin config carries that key;
* operators are advised to mint a dedicated bot key (NOT the maintainer's
* personal owner key) so revocation is scoped.
*
* Port resolution (in priority order):
* 1. OLP_PROXY_URL env (full URL, e.g. http://10.0.0.5:4567)
* 2. OLP_PORT env (port only; localhost assumed)
* 3. Plugin config `proxyUrl`
* 4. Fallback: http://127.0.0.1:4567 (OLP default port since v0.4.0 / D60)
*
* Subcommand parity with the local `olp` CLI (bin/olp.mjs at D64-D67) MINUS
* mutating operations. Mapping table is in ./README.md.
*/
// ── Output helpers (Telegram/Discord-friendly) ─────────────────────────────
/** Wrap output in a monospace code block (Telegram + Discord render this fine). */
export function mono(text) {
return "```\n" + text + "\n```";
}
/** ASCII progress bar — `pct` ∈ [0, 1] clamped. width=16 → 16 cells. */
export function bar(pct, width = 16) {
const p = Number.isFinite(pct) ? Math.max(0, Math.min(1, pct)) : 0;
const filled = Math.round(p * width);
return "█".repeat(filled) + "░".repeat(width - filled);
}
/** Status icon — used in `/olp status` summary lines. */
export function statusIcon(status) {
if (status === "ok" || status === true) return "🟢";
if (status === "degraded" || status === "warn") return "🟡";
return "🔴";
}
/** Truncate to fit Telegram's 4096-char message limit (with mono wrapper). */
export function truncateForChat(text, maxChars = 3900) {
if (text.length <= maxChars) return text;
const SUFFIX = "\n... [truncated, use SSH for full]";
// Reserve room for the suffix so the final string is <= maxChars.
const room = Math.max(0, maxChars - SUFFIX.length);
return text.slice(0, room) + SUFFIX;
}
// ── Proxy URL resolution ───────────────────────────────────────────────────
/**
* Resolve the proxy base URL. Order: OLP_PROXY_URL env OLP_PORT env
* plugin config `proxyUrl` default http://127.0.0.1:4567.
*
* Exported for test injection. Pass `env` to override `process.env` and
* `config` to override the plugin config block.
*/
export function resolveProxyUrl({ env = process.env, config = {} } = {}) {
if (env.OLP_PROXY_URL) return env.OLP_PROXY_URL;
if (env.OLP_PORT) return `http://127.0.0.1:${env.OLP_PORT}`;
if (config.proxyUrl) return config.proxyUrl;
return "http://127.0.0.1:4567";
}
// ── HTTP helper ────────────────────────────────────────────────────────────
/**
* Fetch a JSON endpoint. Sends Authorization: Bearer <apiKey> if provided.
*
* Exported so unit tests can inject a fetch mock via the `fetchFn` arg.
*/
export async function fetchJSON(url, { apiKey, fetchFn = fetch, timeoutMs = 15000 } = {}) {
const headers = {};
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const resp = await fetchFn(url, {
headers,
signal: AbortSignal.timeout(timeoutMs),
});
if (resp.status === 401) {
throw new Error(`401 unauthorized — set plugin "apiKey" config (owner-tier required for ${new URL(url).pathname})`);
}
if (resp.status === 403) {
throw new Error(`403 forbidden — the configured key is not owner-tier (${new URL(url).pathname} is owner-only)`);
}
if (!resp.ok) {
throw new Error(`proxy ${resp.status}: ${resp.statusText}`);
}
return resp.json();
}
// ── Subcommand formatters ──────────────────────────────────────────────────
//
// Each cmdXxx() is pure: takes the JSON body the server returned, returns a
// string. The dispatcher fetches + delegates. This split makes the formatters
// unit-testable without an HTTP mock.
export function fmtStatus(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} OLP v${body.version ?? "?"} | up ${body.uptime_human ?? "?"}\n`;
out += `Providers: ${body.providers?.enabled ?? "?"} enabled / ${body.providers?.available ?? "?"} available\n`;
if (body.providers?.status && typeof body.providers.status === "object") {
for (const [name, s] of Object.entries(body.providers.status)) {
const i = statusIcon(s?.ok ? "ok" : "fail");
out += ` ${i} ${name.padEnd(10)} ${s?.error ? `(${String(s.error).slice(0, 40)})` : "ok"}\n`;
}
}
out += `Requests: ${body.stats?.total_requests ?? 0} total | ${body.stats?.active_requests ?? 0} active\n`;
const c = body.stats?.cache;
if (c) {
out += `Cache: ${c.hits ?? 0} hit / ${c.misses ?? 0} miss / ${c.size ?? "?"} entries\n`;
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
out += `\nRecent errors (${body.recent_errors.length}):\n`;
for (const e of body.recent_errors.slice(0, 3)) {
const ts = (e.time || "").slice(11, 19);
const msg = String(e.message ?? "").slice(0, 60);
out += ` ${ts} ${e.provider ?? "?"} ${msg}\n`;
}
}
return out;
}
export function fmtHealth(body) {
const icon = statusIcon(body.ok ? "ok" : "fail");
let out = `${icon} Status: ${body.ok ? "ok" : "fail"} | v${body.version ?? "?"}\n`;
if (body.uptime_human || body.uptimeHuman) {
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
}
// D74 P2-4 fix: server.mjs /health full payload is
// body.providers = { enabled: N, available: N, status: { <name>: {...} } }
// The plugin previously iterated Object.entries(body.providers), which
// surfaced `enabled`, `available`, and `status` as pseudo-providers
// (typeof status === 'object' → loop body fired with name='status').
// Walk providers.status when present; fall back to providers.* for the
// older OCP shape that lacks the .status wrapper.
if (body.providers && typeof body.providers === "object") {
const enabled = body.providers.enabled;
const available = body.providers.available;
if (typeof enabled === "number" || typeof available === "number") {
out += `Providers: ${enabled ?? "?"} enabled / ${available ?? "?"} available\n`;
}
const statusMap = body.providers.status && typeof body.providers.status === "object"
? body.providers.status
: body.providers;
const entries = Object.entries(statusMap).filter(
([name, s]) => typeof s === "object" && s !== null && name !== "enabled" && name !== "available" && name !== "status"
);
if (entries.length > 0) {
out += `\nProviders:\n`;
for (const [name, s] of entries) {
const i = statusIcon(s?.ok ? "ok" : "fail");
const spawn = typeof s?.activeSpawns === "number" ? ` spawns=${s.activeSpawns}` : "";
out += ` ${i} ${name}${spawn}\n`;
}
}
}
return out;
}
/**
* formatResetCountdown(epochSeconds) human-readable reset countdown.
*
* Mirrors bin/olp.mjs + dashboard.html versions. Five ranges:
* past / < 1h / < 24h / < 7d / 7d
*
* Authority: ADR 0008 Amendment 2 (quota_v2 shape), ported from dashboard.html (D82).
* No external deps. Duplicated here intentionally (olp-plugin ships separately).
*/
export function pluginFormatResetCountdown(epochSeconds) {
if (epochSeconds == null) return "—";
const nowMs = Date.now();
const targetMs = epochSeconds * 1000;
const diffMs = targetMs - nowMs;
if (diffMs <= 0) return "resetting now";
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 60) return `resets in ${diffMin}m`;
if (diffHr < 24) {
const remMin = diffMin - diffHr * 60;
if (remMin === 0) return `resets in ${diffHr}h`;
return `resets in ${diffHr}h ${remMin}m`;
}
const target = new Date(targetMs);
const timeStr = target.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
if (diffDay < 7) {
const dayStr = target.toLocaleString("en-US", { weekday: "short" });
return `resets ${dayStr} ${timeStr}`;
}
const dateStr = target.toLocaleString("en-US", { month: "short", day: "numeric" });
return `resets ${dateStr} ${timeStr}`;
}
export function fmtUsage(body) {
let out = "OLP usage (24h)\n";
out += "─────────────────────────────\n";
const w = body.window_24h ?? body.usage_24h ?? {};
if (w.request_count !== undefined) {
out += `Requests: ${w.request_count}\n`;
const c = body.cache_hit_24h ?? {};
if (typeof c.hit_rate === "number") {
out += `Cache hit: ${(c.hit_rate * 100).toFixed(1)}%\n`;
}
} else if (w.requests !== undefined) {
out += `Requests: ${w.requests}\n`;
out += `Cache hit: ${w.cache_hit_rate != null ? `${(w.cache_hit_rate * 100).toFixed(1)}%` : "?"}\n`;
out += `Fallbacks: ${w.fallbacks ?? "?"}\n`;
} else if (typeof body.cache_hit_24h === "number") {
// Legacy: cache_hit_24h as a bare number
out += `Cache hit (24h): ${(body.cache_hit_24h * 100).toFixed(1)}%\n`;
}
// F4: prefer quota_v2 when present (server v0.5.0+), fall back to legacy quota.
// Authority: ADR 0008 Amendment 2 (quota_v2 shape).
if (Array.isArray(body.quota_v2) && body.quota_v2.length > 0) {
out += `\nPer-provider quota (live):\n`;
for (const p of body.quota_v2) {
const name = String(p.provider ?? "?").toUpperCase().padEnd(10);
const status = p.status ?? "unavailable";
if (status === "unavailable") {
out += ` ${name} unavailable ${p.reason ?? "no public quota api"}\n`;
} else if (status === "unreachable") {
const fk = p.failure?.kind ?? "unknown";
out += ` ${name} no cached data — failure: ${fk}\n`;
} else {
// live or stale
const util = p.utilization ?? {};
const reset = p.reset ?? {};
const parts = [];
for (const window of ["5h", "7d"]) {
const frac = util[window];
const resetEpoch = reset[window];
if (frac != null) {
const pct = `${Math.round(frac * 100)}%`;
const rst = pluginFormatResetCountdown(resetEpoch);
parts.push(`${window}: ${pct} (${rst})`);
}
}
const staleNote = status === "stale"
? ` ⚠ stale (${p.failure?.kind ?? "unknown"})`
: "";
out += ` ${name} ${status.padEnd(6)} ${parts.join(" ")}${staleNote}\n`;
}
}
} else if (Array.isArray(body.quota) && body.quota.length > 0) {
// Legacy fallback for pre-v0.5.0 servers
out += `\nPer-provider quota:\n`;
for (const q of body.quota) {
const pct = typeof q.percent_used === "number" ? q.percent_used : null;
const bar0 = pct != null ? ` ${bar(pct / 100, 12)} ${pct.toFixed(0)}%` : " no quota api";
out += ` ${String(q.provider ?? q.name ?? "?").padEnd(10)}${bar0}\n`;
}
}
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
out += `\nTop fallback chains (24h):\n`;
for (const f of body.top_fallback_chains_24h.slice(0, 5)) {
out += ` ${String(f.count ?? "?").padStart(5)} ${(f.chain ?? []).join(" → ")}\n`;
}
}
return out;
}
export function fmtModels(body) {
const data = body.data ?? [];
if (data.length === 0) return "No models.";
let out = `Models (${data.length})\n`;
out += "─────────────────────────────\n";
for (const m of data) {
out += ` ${m.id}${m.owned_by ? ` (${m.owned_by})` : ""}\n`;
}
return out;
}
export function fmtCache(body) {
let out = "OLP cache\n";
out += "─────────────────────────────\n";
out += `Entries: ${body.size ?? body.entries ?? "?"}\n`;
out += `Hits: ${body.hits ?? 0}\n`;
out += `Misses: ${body.misses ?? 0}\n`;
out += `Inflight: ${body.inflightCount ?? 0}\n`;
if (typeof body.evictions === "number") {
out += `Evictions: ${body.evictions}\n`;
}
return out;
}
export function fmtProviders(registry, configEnabled) {
const providers = registry?.providers ?? {};
const names = Object.keys(providers);
let out = `OLP providers (${names.length} in registry)\n`;
out += "─────────────────────────────\n";
for (const name of names) {
const p = providers[name];
const enabled = configEnabled?.[name] === true ? "enabled " : "disabled";
const tier = p?.tier ?? "?";
const modelCount = (p?.models ?? []).length;
const candidate = p?.candidate === true ? " (candidate)" : "";
out += ` ${name.padEnd(10)} ${enabled} tier ${tier} models ${String(modelCount).padStart(2)}${candidate}\n`;
}
return out;
}
export function fmtChainShow(chains, target) {
if (!chains || Object.keys(chains).length === 0) {
return "No chains configured.";
}
if (target) {
const chain = chains[target];
if (!chain) {
return `Model "${target}" not in routing.chains.\nConfigured: ${Object.keys(chains).join(", ")}`;
}
let out = `${target}:\n`;
for (const hop of chain) {
out += `${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
return out;
}
let out = "OLP routing.chains\n";
out += "─────────────────────────────\n";
for (const [model, chain] of Object.entries(chains)) {
out += `${model}:\n`;
for (const hop of chain) {
out += `${typeof hop === "string" ? hop : JSON.stringify(hop)}\n`;
}
}
return out;
}
export function fmtDoctor(body) {
// body shape: { checks, fail_count, warn_count, ok_count, kind, summary, next_action }
let out = `OLP doctor — ${body.summary ?? "?"}\n`;
out += "─────────────────────────────\n";
for (const c of (body.checks ?? []).slice(0, 20)) {
const icon = c.status === "ok" ? "🟢" : c.status === "warn" ? "🟡" : "🔴";
out += ` ${icon} ${String(c.id ?? "?").padEnd(34)} ${String(c.message ?? "").slice(0, 60)}\n`;
}
if ((body.checks ?? []).length > 20) {
out += ` ... (${body.checks.length - 20} more — use SSH 'olp doctor' for full output)\n`;
}
out += `\nfail=${body.fail_count ?? 0} warn=${body.warn_count ?? 0} ok=${body.ok_count ?? 0} kind=${body.kind ?? "?"}\n`;
if (body.next_action?.ai_executable?.length > 0) {
out += `\nNext (AI-executable):\n`;
for (const cmd of body.next_action.ai_executable.slice(0, 5)) {
out += ` $ ${cmd}\n`;
}
}
if (body.next_action?.human_required?.length > 0) {
out += `\nNext (human-required):\n`;
for (const step of body.next_action.human_required.slice(0, 5)) {
out += `${step}\n`;
}
}
return out;
}
// ── Help text ──────────────────────────────────────────────────────────────
export function cmdHelp() {
return `OLP Commands (read-only)
/olp status Process + provider + cache snapshot
/olp health /health endpoint (public-ok)
/olp usage 24h request stats + per-provider quota
/olp models Available models
/olp cache Cache stats
/olp providers Provider registry + enabled flags
/olp chain show [model] Routing chain(s) from server config
/olp doctor Diagnostic checks + suggested next action
/olp help This message
Mutating commands (keygen / revoke / restart / logs) are NOT
available from chat by design use SSH + the local 'olp' CLI.`;
}
// ── Dispatcher ─────────────────────────────────────────────────────────────
/**
* Pure subcommand dispatcher. Returns `{ text }` always caller wraps in
* mono() for the chat surface.
*
* Exported for unit tests. Injects:
* - fetchFn (default global fetch)
* - proxyUrl (resolved upstream so tests can pin)
* - apiKey (from plugin config)
* - registry (models-registry.json caller provides since this module
* ships in `olp-plugin/` and the file is a sibling concept living at
* the repo root)
* - chainsLocal (local routing.chains override usually empty; the
* server-side /v0/management/status already exposes provider+chain
* state, but chain-show is the one local-config touch that mirrors
* `olp chain show`)
*/
export async function dispatch(rawArgs, opts) {
const {
proxyUrl,
apiKey,
registry,
chainsLocal = {},
fetchFn = fetch,
} = opts;
const raw = (rawArgs || "").trim();
const spaceIdx = raw.indexOf(" ");
const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx);
const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim();
try {
switch (subcmd) {
case "status": {
const body = await fetchJSON(`${proxyUrl}/v0/management/status`, { apiKey, fetchFn });
return { text: fmtStatus(body) };
}
case "health": {
const body = await fetchJSON(`${proxyUrl}/health`, { apiKey, fetchFn });
return { text: fmtHealth(body) };
}
case "usage": {
const body = await fetchJSON(`${proxyUrl}/v0/management/dashboard-data`, { apiKey, fetchFn });
return { text: fmtUsage(body) };
}
case "models": {
const body = await fetchJSON(`${proxyUrl}/v1/models`, { apiKey, fetchFn });
return { text: fmtModels(body) };
}
case "cache": {
const body = await fetchJSON(`${proxyUrl}/cache/stats`, { apiKey, fetchFn });
return { text: fmtCache(body) };
}
case "providers": {
// models-registry.json + (optionally) the server's idea of which are
// enabled. /v0/management/status carries that and is owner-gated, but
// /v1/models lists what's exposed publicly. For the chat surface we
// use the public registry shape — config.enabled is a local-config
// concept and the plugin doesn't have filesystem access to
// ~/.olp/config.json by design.
return { text: fmtProviders(registry, {}) };
}
case "chain": {
// /olp chain show [model]
const inner = subargs.trim();
const parts = inner.split(/\s+/).filter(Boolean);
if (parts[0] !== "show") {
return { text: `Usage: /olp chain show [model]` };
}
const target = parts[1] ?? null;
return { text: fmtChainShow(chainsLocal, target) };
}
case "doctor": {
// /v0/management/doctor doesn't exist yet — D67 added doctor as a CLI
// surface only. The plugin reports that explicitly so families know
// to use SSH + `olp doctor` rather than waiting for a chat response.
return {
text: `/olp doctor is not yet wired through HTTP (planned for Phase 5+).\n` +
`Run \`olp doctor\` over SSH on the host running the OLP server\n` +
`for the full diagnostic output.`,
};
}
case "help":
case "--help":
case "-h":
case "":
return { text: cmdHelp() };
default:
return { text: `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}` };
}
} catch (err) {
return { text: `OLP error: ${err.message ?? String(err)}` };
}
}
// ── Plugin entry point (consumed by OpenClaw gateway) ──────────────────────
/**
* OpenClaw plugin entry. The gateway calls this with its `api` registration
* object; we register the `/olp` slash command and a handler that resolves
* the proxy URL + API key from plugin config + env, then delegates to
* `dispatch()`.
*
* The `registry` (models-registry.json) is read lazily inside the handler
* so that a stale plugin install doesn't bind to an old snapshot and so
* the plugin module stays import-time-pure for tests.
*/
export default function (api) {
api.registerCommand({
name: "olp",
description: "OLP — usage, health, status, doctor, etc. (read-only)",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const cfg = ctx.config ?? {};
const apiKey = cfg.apiKey ?? process.env.OLP_API_KEY ?? null;
const proxyUrl = resolveProxyUrl({ env: process.env, config: cfg });
// Lazy load to avoid binding the import to the OpenClaw gateway's
// ESM cache (which may pre-resolve at plugin-discovery time).
let registry;
try {
// Convert file path to URL for ESM `import(...)`.
const { fileURLToPath, pathToFileURL } = await import("node:url");
const { dirname, resolve: pathResolve } = await import("node:path");
const here = dirname(fileURLToPath(import.meta.url));
const registryUrl = pathToFileURL(pathResolve(here, "..", "models-registry.json")).href;
registry = (await import(registryUrl, { with: { type: "json" } })).default;
} catch (e) {
registry = { providers: {} };
}
// Local chains config is not currently surfaced through HTTP. For
// chat-side chain-show we fall back to an empty map; operators
// wanting the live config view should use `olp chain show` over SSH.
const chainsLocal = {};
const { text } = await dispatch(ctx.args ?? "", {
proxyUrl,
apiKey,
registry,
chainsLocal,
});
return { text: mono(truncateForChat(text)) };
},
});
}
+22
View File
@@ -0,0 +1,22 @@
{
"id": "olp",
"name": "OLP Commands",
"description": "Slash commands for OLP — /olp status, /olp usage, /olp health, etc. (read-only by design; mutations require SSH).",
"version": "0.4.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"proxyUrl": {
"type": "string",
"default": "http://127.0.0.1:4567",
"description": "Full URL of the OLP proxy. Default matches D60 OLP_PORT=4567. Overridable via OLP_PROXY_URL or OLP_PORT env."
},
"apiKey": {
"type": "string",
"description": "Owner-tier OLP API key (olp_xxx). Required for owner-only subcommands (status / usage / cache). Use a dedicated bot key — DO NOT share the maintainer's personal owner key."
}
},
"required": ["apiKey"]
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "olp-plugin",
"version": "0.4.0",
"description": "OpenClaw gateway plugin — /olp slash commands for the OLP proxy (read-only)",
"main": "index.js",
"type": "module",
"keywords": ["openclaw", "plugin", "olp", "proxy"],
"license": "MIT",
"private": true,
"openclaw": {
"type": "plugin",
"id": "olp",
"pluginManifest": "openclaw.plugin.json",
"extensions": ["./index.js"]
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"name": "olp",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "olp",
"version": "0.5.1",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.52"
},
"bin": {
"olp": "bin/olp.mjs",
"olp-audit-rotate": "bin/olp-audit-rotate.mjs",
"olp-connect": "bin/olp-connect",
"olp-keys": "bin/olp-keys.mjs"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@anthropic-ai/sandbox-runtime": {
"version": "0.0.52",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.52.tgz",
"integrity": "sha512-vYaM7OslFmOAzNgfy5gxvt3NoWFeCbr7C0AKyuduQq7Gdxbg2NnYmE7deBf8Nxj3ZNECTcC5RhAfz0lZwvbtBA==",
"license": "Apache-2.0",
"dependencies": {
"@pondwader/socks5-server": "^1.0.10",
"commander": "^12.1.0",
"node-forge": "^1.4.0",
"shell-quote": "^1.8.3",
"zod": "^3.24.1"
},
"bin": {
"srt": "dist/cli.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@pondwader/socks5-server": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
"license": "MIT"
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/node-forge": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+29 -3
View File
@@ -1,13 +1,36 @@
{
"name": "olp",
"version": "0.1.0",
"version": "0.5.1",
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
"type": "module",
"main": "server.mjs",
"bin": {
"olp": "./bin/olp.mjs",
"olp-keys": "./bin/olp-keys.mjs",
"olp-audit-rotate": "./bin/olp-audit-rotate.mjs",
"olp-connect": "./bin/olp-connect"
},
"scripts": {
"start": "node server.mjs",
"test": "node test-features.mjs"
"test": "node test-features.mjs",
"olp": "node bin/olp.mjs",
"olp-keys": "node bin/olp-keys.mjs",
"olp-audit-rotate": "node bin/olp-audit-rotate.mjs",
"olp-connect": "bash bin/olp-connect"
},
"files": [
"server.mjs",
"bin/",
"lib/",
"olp-plugin/",
"models-registry.json",
"dashboard.html",
"README.md",
"ALIGNMENT.md",
"CHANGELOG.md",
"LICENSE",
"docs/"
],
"engines": {
"node": ">=18"
},
@@ -26,5 +49,8 @@
"mistral",
"fallback",
"cache"
]
],
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.52"
}
}
+1554 -80
View File
File diff suppressed because it is too large Load Diff
+10688 -26
View File
File diff suppressed because it is too large Load Diff