12 Commits
Author SHA1 Message Date
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
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
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
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 d85a2dcf71 docs: D20 — honest forward-reference annotations across README/ADRs (Finding 9)
cold-audit catch from 2026-05-23

Cold-audit Finding 9 (P3 drift): multiple docs referenced files / paths
/ directories that don't exist at the current implementation state. A
reader following AGENTS.md § "Key files to know" to read lib/keys.mjs
would find no such file; following README architecture to dashboard.html
same; ADR 0005 § D1 described a `~/.olp/cache/<key>/<prefix>/<hash>.json`
layout that doesn't exist (current impl is in-memory Map per
lib/cache/store.mjs). D20 doesn't gut the designs — it adds honest
status callouts so the gap is obvious within seconds.

Files changed (8, docs only, +46 / -14):

1. README.md (+32 / -3):
   - New H2 section "Implementation status (as of 2026-05-24)" with a
     10-row table distinguishing  Shipped vs 📋 Planned, including phase
     numbers (Phase 2 / 6 / 7) matching the existing §"Phase plan"
   - Inline status annotation on the multi-key auth bullet
     (lib/keys.mjs marked planned for Phase 2)
   - Inline status on the cache layer bullet (file-backed storage marked
     Phase 2 — current is in-memory Map)
   - Migration section's Phase 7 placeholder annotated explicitly

2. AGENTS.md (+8 / -3):
   - Inline `📋 Planned (Phase N) — not yet authored` markers on
     lib/keys.mjs and dashboard.html in the "Key files" bullet list
   - Inline marker on setup.mjs reference in the
     "Project-specific constraints" section
   - New "Implementation status note" paragraph at the end of the Key
     files block pointing to README's status table for the full picture

3. ALIGNMENT.md (+6 / -3):
   - docs/openai-spec-pin.md references (Authority 2 + audits section)
     tightened from "deferred" to "deferred, not yet authored; must be
     created before first annual audit (target: v1.0)"
   - docs/alignment-audits/ directory annotated as "directory does not
     exist yet; it is created when the first audit is conducted"

4. CLAUDE.md (+2):
   - release_kit.bootstrap_quirk_policy YAML retains the
     scripts/migrate-from-ocp.mjs reference (forward-looking spec
     compliance) and adds an inline YAML comment explicitly noting
     "is planned (Phase 7), not yet authored. The scripts/ directory
     does not currently exist. References here are forward-looking;
     do not attempt to run this script."

5-8. ADR amendments (status notes only — no Amendment blocks, since
     these are clarifications about implementation state, NOT decision
     changes per se):
   - ADR 0001 (Consequences/Negative): scripts/migrate-from-ocp.mjs
     annotated as Phase 7 planned
   - ADR 0003 § Decision (lossy-translation paragraph): inline note that
     docs/provider-caveats.md is planned; until it exists, lossy edges
     are recorded only in plugin headers. Plus a status mention in
     Consequences/Positive
   - ADR 0004 Mitigations: docs/provider-caveats.md annotated as planned
   - ADR 0005 § Decision (D1 paragraph): the file-backed layout block
     reframed from "Cache directory structure:" to "Designed file-backed
     layout (target for Phase 2 storage adapter):". Added a status
     blockquote explicitly noting "v0.1 implementation in lib/cache/store.mjs
     uses an in-memory Map; no files written to ~/.olp/cache/. The
     file-backed layout described above is the designed shape; it
     transitions in via a Phase 2 storage adapter. Per-key isolation and
     singleflight (D4) are live; file persistence is not."

Note on ADR Decision-section edits (corrected from initial writeup):
two ADR amendments DO touch Decision-section text (ADR 0003 lossy-
translation paragraph, ADR 0005 D1 cache layout). Both edits are
conservative — status caveats that preserve the original prose verbatim
(0003) or reframe section headings without changing the path-shape spec
(0005). No design content is gutted. No Amendment block was added
because the decisions themselves aren't changing; these are clarifications
about what's live today vs designed. The discipline boundary here:
inline-status-note ≠ decision-amendment.

Tests: 328/328 unchanged (sanity check; docs-only changes).

7/7 Finding 9 forward references annotated (confirmed by reviewer
running independent `ls` on each path):
- lib/keys.mjs  doesn't exist → annotated
- dashboard.html  doesn't exist → annotated
- docs/provider-caveats.md  → annotated (2 ADR sites + Consequences)
- docs/openai-spec-pin.md  → annotated (ALIGNMENT.md Authority 2)
- docs/alignment-audits/  → annotated
- scripts/migrate-from-ocp.mjs  → annotated (README + ADR 0001 + CLAUDE.md release_kit)
- setup.mjs  → annotated (AGENTS.md)

Authority:
- README/AGENTS.md/ADR/ALIGNMENT.md/CLAUDE.md self — the doc set is
  its own authority for what it documents; D20 brings each statement
  into honest agreement with the current implementation
- CC 开发铁律 v1.6 § 10.x — Cold Audit Finding 9

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Verified each Finding 9 path doesn't
exist via independent `ls`; cross-checked phase numbers match the
existing §"Phase plan" listing; ran `npm test` to confirm 328/328
unchanged. Two non-blocking minors:

1. Implementer's initial writeup overstated "ADR decision text NOT
   edited" — reality is two Decision-section paragraphs got inline
   status caveats. Commit message above is corrected.

2. Reviewer found an additional drift D20 didn't address: ADR 0002 §
   Decision filesystem layout lists `vibe.mjs` for the Mistral plugin,
   but the shipped file is `mistral.mjs` (the binary is `vibe`, the
   plugin file is `mistral`). Different drift class from Finding 9
   (file exists, just named differently in the ADR). Filed as
   follow-up issue.

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

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

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

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

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

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

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