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>
35 KiB
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.cacheableopt-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 whenlib/keys.mjslands"; 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>>atlib/cache/store.mjs:77-79); this ADR fills thekeyIdsource that is hardcoded to'__anonymous__'inserver.mjsat v0.1.1
- OLP v0.1 spec § 4.5 (Auth & multi-key) — the planning authority for the
- 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 incc-rulesd9da966) 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 thekeyIdsource 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/healthis 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 whenlib/keys.mjslands."
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, 8–32 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)
{
"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—1at v0.2.0. Increment on any non-additive change to this schema. Implementation readsschema_versionfirst; rejects unrecognized versions with a clear error.id— matches the parent directory name. If they disagree, validation fails (manifest_id_mismatch).name— human label forolp keys listoutput. 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 (matchingmodels-registry.jsonprovider entries) OR literal"*"for all providers. Empty array[]means the key can authenticate but cannot dispatch any provider call (returns 403 withkey_no_provider_access).quota—nullat Phase 2 (no enforcement). Reserved for Phase 3+ quota work. Implementations MUST readnullas "no quota enforcement"; non-null shapes are deferred to a Phase 3 ADR amendment.created_at— set at key creation; never modified.revoked_at—nullwhile active; set to current timestamp on revocation. Revoked keys fail validation with401 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'socp_); 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 formanifest.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):
- Compute target path:
~/.olp/keys/<key-id>/manifest.json. - Write to tmpfile in same directory:
~/.olp/keys/<key-id>/manifest.json.tmp.<pid>.<counter>. fsync()the tmpfile fd.rename()tmpfile → final path (same-filesystem atomic).- 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 (thetouchLastUsedpath 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:
- Serialize event (§ 8 schema) with trailing
\n. Serialization fires AFTERstatus_codeis determined andlatency_msis measured (i.e., after the request handler emits the response, aroundres.end()finalization). This pinning is what makes acceptance criterion #2 testable — the 401-on-anonymous case recordsstatus_code: 401+latency_msin the same audit event. fs.appendFile(path, line, { mode: 0o600 })(Node default opens with append flag).- 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). - On second-failure: log warn
audit_append_droppedwith 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:
- Re-read the latest manifest from disk inside the per-key write-lock (§6.4) — not reuse the snapshot the validating request held.
- If
revoked_atis 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. - Otherwise, merge the new
last_used_atvalue into the freshly-read manifest, preserving ALL other fields includingrevoked_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:
{
"auth": {
"allow_anonymous": false,
"owner_only_endpoints": ["/health", "/v0/management/quota"],
"fallback_detail_header_policy": "owner_only"
}
}
allow_anonymous— defaultfalse. Whentrue, 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./healthis the canonical example.fallback_detail_header_policy—"owner_only"(default) emitsX-OLP-Fallback-Detailonly 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).
{
"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 theX-OLP-Fallback-Detailtuple.
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:
- Generates an opaque OLP token (§ 5 format).
- Computes its SHA-256 hash.
- Writes a
keys/<key-id>/manifest.jsonper § 4 withowner_tier: "owner"(first key) or as specified by flag. - Prints the plaintext token to stdout exactly once. The token is otherwise never logged.
- 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:
- Generate a fresh owner key (new
<key-id>, new plaintext). - Mark all existing
owner_tier: "owner"keys'revoked_atto current timestamp. (Existing guest keys are not affected.) - 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 stablekey_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
Authorizationheader only. - Cache namespacing uses
__env_owner__as thekeyId, isolating env-owner traffic from filesystem-owner traffic. - Audit attribution uses
key_id: "__env_owner__"andowner_tier: "owner". - Server startup logs warn
non_persistent_owner_tokenwith 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:
- Per-key cache isolation — Two keys A and B with identical request payloads do NOT share cache.
cache_statusismissfor both first calls andhitfor the second call from the SAME key only. - Anonymous prod-default off — With
auth.allow_anonymous: false(no override), a request without a key receives401 auth_required; the audit event is recorded withkey_id: "__anonymous__"andstatus_code: 401. - Anonymous dev-mode on — With
auth.allow_anonymous: true, the same request succeeds withkeyId="__anonymous__". - Owner-vs-guest /health gating (with default
auth.owner_only_endpointsconfig) — Owner key sees the full per-providerprovidersmap in/health; guest key + anonymous see only{ status, version }. Test rephrases if the operator'sowner_only_endpointsconfig does not include/health(test must assert the same gating predicate the config produces, not a hardcoded trimmed payload shape). - Owner-vs-guest X-OLP-Fallback-Detail gating — Same response payload for both owner and guest; header present for owner only.
- Key revocation — After
revoke, subsequent requests with that token return401 key_revokedwithin the next request (no caching of validation). - Manifest atomicity + revoke-dominates-touch (§ 6.3, § 6.4) — Concurrent
revoke+touchLastUsedwrites do not corrupt the manifest AND revoke always survives. Test: spawn two writers racing on the same key (revoke vstouchLastUsed) 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_atis non-null and equals the revoke writer's timestamp; (c)last_used_atmay have either writer's value. The test FAILS if any interleaving producesrevoked_at: nullafter the revoke writer completed. This pins the §6.3 read-before-write discipline. - Audit ndjson round-trip — Every line in
audit.ndjsonparses as valid JSON; every required field present; PII fields (message content, response content) absent. - 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.
OLP_OWNER_TOKENenv override — With the env var set, a request bearing the env token validates askeyId="__env_owner__"withowner_tier="owner"; the raw token does NOT appear in any log line, audit event, or stack trace.providers_enabledscope enforcement — A guest key withproviders_enabled: ["anthropic"]requestingmodelthat routes toopenaireceives403 key_no_provider_accessand 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.jsondeclaresengines.node">=18"(file line 11). - CI test matrix in
.github/workflows/test.ymlruns Node 20 and 24 (file line 13). node:sqlitewas added in Node v22.5.0; v22.12 still required the--experimental-sqliteruntime 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:
- Raise
engines.nodeto 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 forengines.nodeis therefore>=22.13.0(or>=23.4.0on the non-LTS path). A stable (post-RC) baseline is TBD pending future Node releases beyond v25.x. - 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).
- 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.quotanon-null shapes) — manifest schema reserves the field; semantics + enforcement land in a Phase 3 ADR amendment. - Audit query layer / rotation —
audit.ndjsonis 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:
- A separate prior PR raises
engines.nodeand updates the CI matrix per § 11. This PR ships independently of any storage change. - An ADR amendment to this file documents the migration trigger (which of the criteria above fired) and the schema mapping from manifest → SQLite rows.
- 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-Detailre-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 inserver.mjsand 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.mdon the maintainer's workstations. - OCP
keys.mjs(prior-art reference for opaque-key + per-key isolation model) — at~/ocp/keys.mjson 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.mdcommitted incc-rulesd9da966. - Node
node:sqlitedocumentation (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 realkeyId).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:/healthhandler entry and the twoX-OLP-Fallback-Detailheader-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_modecurrent_pre_release_identifier="0.2.0-phase2"— confirms this ADR lands in the Phase 2 sprint.