Files
olp/docs/adr/0007-multi-key-auth.md
T
68e50da68a fix+test+docs: D53 — tried_providers schema semantic fix (D45 P2 deferral closed) (#30)
Sixth Phase 3 D-day. Small focused fix for the D45 fresh-context opus
reviewer P2 finding that was deferred: auditCtx.tried_providers on the
key_no_provider_access 403 path was being stamped with the ORIGINAL
chain (which was filtered out, never dispatched), distorting downstream
audit queries like "which providers did key X actually call".

CHANGES:

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

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

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

  - CHANGELOG.md: D53 entry under Unreleased.

NOT IN D53:

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

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

AUTHORITY:

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

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

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:32:59 +10:00

35 KiB
Raw Permalink Blame History

ADR 0007 — Multi-Key Auth (lib/keys.mjs)

  • Date: 2026-05-25
  • Status: Accepted (D43-B, design-only — implementation D-days D44+ follow)
  • Authors: project maintainer (with AI drafting assistance)
  • Related:
    • OLP v0.1 spec § 4.5 (Auth & multi-key) — the planning authority for the ~/.olp/ layout used in § 3 below
    • ADR 0001 (project founding) — single-tenant family-scale framing; this ADR keeps that framing while enabling multi-identity isolation within a single deployment
    • ADR 0002 (plugin architecture) — hints.cacheable opt-out demonstrates the per-plugin gating pattern this ADR extends to per-key gating
    • ADR 0004 Amendment 5 (D40 X-OLP-Fallback-Detail) — explicitly defers owner-only header gating to "Phase 2 when lib/keys.mjs lands"; this ADR is that landing event
    • ADR 0005 (cache cross-provider) — D1 per-key isolation: the cache layer is already keyed by keyId (Map<keyId, Map<cacheKey, CacheEntry>> at lib/cache/store.mjs:77-79); this ADR fills the keyId source that is hardcoded to '__anonymous__' in server.mjs at v0.1.1
  • Prior-art authority: OCP keys.mjs (at the maintainer workstation ~/ocp/keys.mjs, OCP v3.13.0 production reference) — model adapted; storage strategy diverges per § 11 below
  • Phase 2 provenance: ~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md (committed in cc-rules d9da966) captures the catch-up brief, lane separation, and the four amendments the maintainer pinned during D43-B drafting

1. Context

OLP v0.1.1 ships with the cache namespace hardcoded to '__anonymous__' (server.mjs ~L502 buffered handler, ~L531 streaming handler). The cache data model in lib/cache/store.mjs is already segmented by keyId (per-key Map + per-key stats + per-key singleflight key composition), but the identity layer that produces a real keyId does not exist yet.

Phase 2 of OLP introduces multi-key authentication so a single OLP deployment can serve multiple human users (e.g., maintainer + family members + a CI client) with:

  • Per-key cache namespace isolation — already wired in store.mjs; only the keyId source needs to land.
  • Per-key audit trail — which key issued which request, what provider/model served it, what fallback / cache outcome resulted.
  • Per-key provider access scoping — each key declares which providers it may invoke (providers_enabled).
  • Owner-vs-guest gating for debug/observability surfaces that should not leak to non-owner identities, specifically:
    • /health — currently returns full per-provider details to any caller. README has long claimed /health is owner-only (README.md § API Endpoints), but no auth gate has shipped. Phase 2 closes that gap.
    • X-OLP-Fallback-Detail — D40 / ADR 0004 Amendment 5 explicitly shipped the header ungated at v0.1 with the note "Phase 2 will re-introduce owner-vs-non-owner gating when lib/keys.mjs lands."

OCP solved an adjacent problem with keys.mjs (~417 LOC, SQLite-backed, single-tenant LAN mode). OLP cannot port that code verbatim — see § 11 (Node runtime baseline) — but the model (opaque key + per-key namespace + per-key audit + per-key quota) is the prior art this ADR adapts.

Phase 2 is not a v1.x cross-phase deliverable. docs/v1x-roadmap.md lists seven deferred items; only #2 (multi-key auth) is the Phase 2 mainline. The others (#1 streaming SF, #3 soft triggers, #4 /health activeSpawns, etc.) are triggered on demand and stay on the v1.x tracker.


2. Decision

OLP Phase 2 ships filesystem-only multi-key auth with opaque tokens, structured for migratability to a SQLite-indexed model when Phase 3+ Dashboard / SQL-aggregate quota work justifies that change.

Three load-bearing choices:

Axis Choice Rationale
Storage Filesystem manifest per key (~/.olp/keys/<key-id>/manifest.json) + append-only audit ndjson (~/.olp/logs/audit.ndjson) Matches v0.1 spec § 4.5 layout; zero-dep within current Node baseline (§ 11); trivially backed-up / human-inspectable / git-crypt-encryptable; per-key isolation natural via filesystem hierarchy
Token format Opaque olp_<32-byte base64url>; manifest stores SHA-256 hash, never plaintext Mirrors OCP's ocp_<24-byte> opaque pattern; revocation is single-record (no JWT revocation-list problem); validation is single manifest read; family-scale has no stateless-validation pressure
Migration lane Manifest remains the declarative SPOT in all future revisions; SQLite (if added in Phase 3+) becomes a query-side index synced on every manifest/audit write Forward path documented in § 13 — never a single-direction door; manifest schema is always source of truth

The decision rejects three plausible alternatives:

  • Option 1 (direct SQLite port from OCP) — rejected at v0.2.0 because of a runtime baseline mismatch documented in § 11, not because of any flaw in SQLite or in OCP's design.
  • JWT tokens — rejected because OLP has no stateless-validation pressure (the deployment is a single Node process; one manifest read per request is cheaper than the JWT-issuance / rotation / revocation-list infrastructure).
  • Auto-detected "dev mode" anonymous fallback — rejected because behavioural divergence based on heuristics (NODE_ENV, hostname, port, etc.) creates security-incident-prone surprises. Anonymous access is an explicit configuration toggle (§ 7) or it does not happen.

3. Storage layout (~/.olp/)

The layout below is normative for v0.2.0. Each path is binary in spec — present-and-honored or absent-and-defaulted. No path may be silently created with a different name.

~/.olp/
  config.json                       — top-level config (existing); gains `auth` block per § 7
  keys/                             — chmod 0700; per-key SPOT
    <key-id>/
      manifest.json                 — chmod 0600; JSON; schema in § 4
  logs/                             — chmod 0700
    audit.ndjson                    — chmod 0600; one JSON event per line; schema in § 8
  providers/                        — existing; per-provider auth artifact root
    anthropic/credentials.json
    openai/codex_token.json
    mistral/api_key.env
  cache/                            — file-backed cache (📋 v1.x); chmod 0700 when introduced

<key-id> format. Lowercase alphanumeric + hyphen + underscore, 832 chars, generated by the keygen command. NOT derived from the secret token — <key-id> is the public namespace identifier (cache key prefix, audit key_id field, log correlator); the secret token is separate.

Why a directory per key (vs one keys.json index file). Future per-key augmentation (per-key cache index, per-key provider-specific auth override, per-key rate-limit state) can land as additional files in keys/<key-id>/ without re-writing a shared index. The directory is the namespace.


4. Manifest schema (keys/<key-id>/manifest.json)

{
  "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_version1 at v0.2.0. Increment on any non-additive change to this schema. Implementation reads schema_version first; rejects unrecognized versions with a clear error.
  • id — matches the parent directory name. If they disagree, validation fails (manifest_id_mismatch).
  • name — human label for olp keys list output. Required, non-empty.
  • token_hash — SHA-256 of the plaintext token (lowercase hex). The plaintext token is NEVER stored.
  • token_hash_algo"sha256" at v0.2.0. Schema-versioned forward-compat for future algorithm rotation.
  • owner_tier"owner" grants full /health + X-OLP-Fallback-Detail visibility; "guest" does not (§ 7).
  • providers_enabled — array of provider keys (matching models-registry.json provider entries) OR literal "*" for all providers. Empty array [] means the key can authenticate but cannot dispatch any provider call (returns 403 with key_no_provider_access).
  • quotanull at Phase 2 (no enforcement). Reserved for Phase 3+ quota work. Implementations MUST read null as "no quota enforcement"; non-null shapes are deferred to a Phase 3 ADR amendment.
  • created_at — set at key creation; never modified.
  • revoked_atnull while active; set to current timestamp on revocation. Revoked keys fail validation with 401 key_revoked; their manifest stays on disk for audit attribution.
  • last_used_at — updated on successful validation. Best-effort (lazy write OK; failure to update does NOT fail the request — § 6).
  • notes — optional; useful for "spouse's laptop", "Pi staging", etc.

Schema rigidity. Unrecognized fields cause a warn but not a reject (forward-compat). Missing required fields cause a reject (manifest_invalid).


5. Token format

olp_<32 bytes from crypto.randomBytes, base64url-encoded, no padding>
  • Prefix olp_ is fixed (mirrors OCP's ocp_); enables grep / regex detection in logs / secret-scanners.
  • 32 bytes = 256 bits of entropy. base64url-encoded = 43 characters; total token length = 47 characters including prefix.
  • Hash with crypto.createHash('sha256') over the full token string (prefix included). Hex-lowercase the digest for manifest.token_hash.

Why SHA-256, not argon2/bcrypt. Argon2-class slow hashes are for low-entropy secrets (passwords). A 256-bit random token has no brute-force exposure in the relevant attack-cost model; SHA-256 is sufficient and ~6 orders of magnitude faster, which matters because validation runs on every request.

No plaintext storage, ever. The plaintext token leaves the keygen command (printed to stdout once) and the authenticated request (HTTP header). It is never logged, never written to manifest, never written to audit. The only persistent representation is the hash in manifest.token_hash.


6. Atomic write & audit append

Two distinct write surfaces with different semantics:

6.1 Manifest writes (lifecycle events only)

Manifest writes fire ONLY on key lifecycle events: createKey, revokeKey, updateKey (e.g., setting providers_enabled), and touchLastUsed (the lazy last_used_at update). Manifest is not written per request — per-request state goes to audit.

Atomic write pattern (POSIX):

  1. Compute target path: ~/.olp/keys/<key-id>/manifest.json.
  2. Write to tmpfile in same directory: ~/.olp/keys/<key-id>/manifest.json.tmp.<pid>.<counter>.
  3. fsync() the tmpfile fd.
  4. rename() tmpfile → final path (same-filesystem atomic).
  5. Directory mode 0700, file mode 0600 enforced on every write.

POSIX-strict atomic-replace also requires fsync() on the containing directory after the rename to guarantee survival of an OS crash mid-flush. Phase 2 deliberately omits the directory fsync: the single-process family-scale deployment model accepts a tiny window where a rename can be lost under abrupt host crash. The trade-off is documented here so a future POSIX-strict deployment knows where to add the step.

Failure semantics:

  • Step 2/3/4 failure → throw; caller handles. Lifecycle commands (olp keygen / olp keys revoke) report failure to the operator and exit non-zero. Server requests do not trigger lifecycle writes (the touchLastUsed path is best-effort — see 6.3).

6.2 Audit ndjson appends (per-request)

Per-request audit events append a single newline-terminated JSON object to ~/.olp/logs/audit.ndjson.

Append pattern:

  1. Serialize event (§ 8 schema) with trailing \n. Serialization fires AFTER status_code is determined and latency_ms is measured (i.e., after the request handler emits the response, around res.end() finalization). This pinning is what makes acceptance criterion #2 testable — the 401-on-anonymous case records status_code: 401 + latency_ms in the same audit event.
  2. fs.appendFile(path, line, { mode: 0o600 }) (Node default opens with append flag).
  3. On EAGAIN / EBUSY / ENOSPC: log warn audit_append_failed_once + retry once (synchronous, no backoff at Phase 2 — family-scale write rate makes contention rare).
  4. On second-failure: log warn audit_append_dropped with the failure reason + a per-process drop counter; do not block the request; do not buffer (memory buffer is a forward path in § 13, deliberately not in Phase 2 scope).

Failure semantics:

  • Audit append failure NEVER fails the request. Auditing is observability, not authorization.
  • Dropped audit events surface via the warn log and the dropped-count metric (exposed in /health owner-tier view per § 7).

6.3 last_used_at lazy update (revoke-dominates-touch)

The touchLastUsed write goes through the same atomic-write pattern as 6.1, but is fired async after request response is dispatched. Failure logs warn last_used_update_failed and does NOT fail the request.

Read-modify-write with revoke preservation. touchLastUsed MUST:

  1. Re-read the latest manifest from disk inside the per-key write-lock (§6.4) — not reuse the snapshot the validating request held.
  2. If revoked_at is non-null in the freshly-read manifest, NO-OP (do not write). A revocation occurred between request validation and this lazy touch; the request was the last legitimate use of the now-revoked key.
  3. Otherwise, merge the new last_used_at value into the freshly-read manifest, preserving ALL other fields including revoked_at, and write via the atomic-rename pattern.

This protects against the failure mode where a stale manifest snapshot held by the touch path overwrites a fresh revoke and silently clears revoked_at back to null. The safety property is revoke dominates touch: any ordering of CLI revoke and server-side touchLastUsed (revoke-then-touch, touch-then-revoke, or interleaved) leaves a revoked manifest. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest under concurrent CLI revoke + in-flight server request.

6.3.5 No in-process validation cache (Phase 2)

Token validation MUST hit the manifest on every authenticated request at Phase 2 — implementations MUST NOT introduce an in-process LRU / TTL cache of validation results. Rationale: revocation must take effect on the next request without an invalidation hop; the family-scale request rate makes per-request manifest read O(1) on the OS file-system cache. This is the contract that makes acceptance criterion #6 (post-revoke 401 within the next request) honest. A validation cache is a forward-path consideration if Phase 3+ load profile demands it; a separate ADR amendment ratifies the cache shape + invalidation contract before any cache code lands.

6.4 Locking (single-process Phase 2)

OLP at v0.2.0 is a single Node process per host. Concurrent manifest writes from inside the process are serialized via an in-process Map of per-key write-locks (Map<key-id, Promise>). Concurrent writes from outside the process (e.g., maintainer running olp keys revoke while server is running) are not protected by file locks at Phase 2.

Safety frame: the atomic-rename pattern guarantees corruption-free file content (no partial-merge state on disk), and the read-before-write discipline in §6.3 makes the worst case "stale last_used_at field" (observability-grade) rather than "revoked_at silently cleared" (security-grade). Without §6.3, a touch carrying a pre-revoke snapshot could overwrite revoke and break acceptance criterion #6; with §6.3, any interleaving of revoke and touch leaves a revoked manifest. The CLI revoke writer always wins the dimension that matters; the touch writer may lose its last_used_at update if it raced a revoke (acceptable — the revoked key will not be used again).

Forward path: file-locking (flock(2)) is reserved if a future Phase introduces multi-writer scenarios (e.g., a setup wizard process running alongside the server). With multi-writer, §6.3's read-before-write still holds the revoke-dominates-touch contract; file-locking only adds defense-in-depth against rare time-of-check-time-of-use windows where two processes both re-read a non-revoked manifest, then both write back with the touch path silently dropping a concurrent in-flight revoke from a third writer.


7. Owner / guest / anonymous gating

7.1 The three identity classes

Class Source Cache namespace /health visibility X-OLP-Fallback-Detail visibility /v1/chat/completions
owner Valid OLP key with owner_tier: "owner" per-key (<key-id>) full per-provider details yes (header emitted) yes
guest Valid OLP key with owner_tier: "guest" per-key (<key-id>) trimmed ({ status, version } only) no (header suppressed) yes (scoped by providers_enabled)
anonymous No Authorization / x-api-key header, AND config.json auth.allow_anonymous: true __anonymous__ (shared) trimmed ({ status, version } only) no (header suppressed) yes

When auth.allow_anonymous: false (default) and no key is presented, all routes return 401 auth_required.

7.2 Configuration

~/.olp/config.json gains an auth block:

{
  "auth": {
    "allow_anonymous": false,
    "owner_only_endpoints": ["/health", "/v0/management/quota"],
    "fallback_detail_header_policy": "owner_only"
  }
}
  • allow_anonymous — default false. When true, requests without a key are accepted and namespaced under the legacy __anonymous__ cache keyId.
  • owner_only_endpoints — list of HTTP paths returning trimmed payloads to non-owner identities. /health is the canonical example.
  • fallback_detail_header_policy"owner_only" (default) emits X-OLP-Fallback-Detail only to owner tier. "all" reverts to v0.1.1 ungated behaviour. "none" suppresses unconditionally. The policy is the v0.1.1 → v0.2.0 migration knob for operators who want to delay re-gating.

7.3 Environment-based behaviour is rejected

Phase 2 deliberately does NOT auto-detect "dev" vs "production" via NODE_ENV, hostname, port, or any other heuristic. The rule is: config.json auth.allow_anonymous is the truth, and the operator sets it explicitly. Behavioural divergence on environment heuristics is a known source of "it works locally" security incidents and is out of scope by design.


8. Audit ndjson schema

One JSON object per line (newline-terminated, UTF-8), written by the per-request audit-append path (§ 6.2).

{
  "ts": "<ISO-8601 UTC>",
  "key_id": "<key-id>" | "__anonymous__" | "__env_owner__",
  "owner_tier": "owner" | "guest" | "anonymous",
  "method": "POST" | "GET" | ...,
  "path": "/v1/chat/completions" | "/v1/models" | ...,
  "provider": "<provider-key>" | null,
  "model": "<requested-model>" | null,
  "status_code": 200 | 401 | 503 | ...,
  "latency_ms": <int>,
  "cache_status": "hit" | "miss" | "bypass" | null,
  "fallback_hops": <int>,
  "tried_providers": ["<provider-key>", ...],
  "error_code": null | "<ProviderError code>",
  "ir_request_hash": "<short hex>" | null,
  "chain_id": "<correlator>" | null
}

Field origin:

  • ts / key_id / owner_tier — set by the auth middleware.
  • method / path / status_code / latency_ms — set by the request handler.
  • provider / model / cache_status / fallback_hops / tried_providers / error_code — sourced from the existing D28 per-hop log fields (no new computation; same shapes the structured log already exposes).
  • ir_request_hash / chain_id — sourced from D28 fields directly; enable join across audit, structured log, and the X-OLP-Fallback-Detail tuple.

No PII. Audit deliberately captures NO request body, NO response body, NO IR-message content. Hash + shape only. This is a personal/family deployment property; do not relax without a separate ADR amendment.

tried_providers semantics (clarification, D53 / 2026-05-25). The field captures the list of providers the server actually dispatched a spawn against for this request. A provider that was configured in the chain but filtered out by providers_enabled gating (resulting in 403 key_no_provider_access) is NOT included — the key didn't try the provider, the gate did. On the 403 path tried_providers is the empty array. The configured-but-blocked chain providers appear in the human-readable error message returned to the client but are intentionally NOT surfaced in the audit event, so downstream queries like "which providers did key X actually call" stay accurate. This semantic was implicit in the D45 implementation (where the field was set to the original chain on 403, misrepresenting "tried"); D53 corrects the implementation + amends this section to spell out the intent.

Rotation. Phase 2 does NOT rotate audit.ndjson. Rotation policy ships in Phase 3 — daily rotation via lib/audit.mjs _maybeRotateAudit synchronous trigger on first append after UTC date change + optional bin/olp-audit-rotate.mjs external cron. See ADR 0008 § 5.


9. Bootstrap & recovery

9.1 Minimal keygen command surface

Phase 2 MUST ship at least one executable entry that:

  1. Generates an opaque OLP token (§ 5 format).
  2. Computes its SHA-256 hash.
  3. Writes a keys/<key-id>/manifest.json per § 4 with owner_tier: "owner" (first key) or as specified by flag.
  4. Prints the plaintext token to stdout exactly once. The token is otherwise never logged.
  5. Returns non-zero on any failure (manifest path conflict, filesystem permission, etc.).

The concrete shape — npx olp keygen --owner, node bin/keygen.mjs --owner, node lib/keys/cli.mjs keygen --owner, etc. — is an implementation choice and lands at D44 or D45. ADR 0007 does not pin the shape; it pins the requirement that the surface exists and is reproducible without manual file editing.

9.2 First-run flow

When ~/.olp/keys/ is empty AND auth.allow_anonymous: false (defaults), the server refuses to start /v1/chat/completions requests with a clear 401 no_keys_configured until the operator runs the keygen command. The server itself does NOT auto-generate a key on first run — explicit operator action is required so the plaintext-once contract (§ 9.1 step 4) is honored on a terminal the operator can see.

When ~/.olp/keys/ is empty AND auth.allow_anonymous: true, the server starts normally and serves all requests under __anonymous__. Useful for dev / single-user-no-multi-tenancy deployments.

9.3 Owner key loss / rotation

If the operator loses their owner token, recovery is <keygen-command> --owner --force:

  1. Generate a fresh owner key (new <key-id>, new plaintext).
  2. Mark all existing owner_tier: "owner" keys' revoked_at to current timestamp. (Existing guest keys are not affected.)
  3. Print the new plaintext once.

The old token is permanently invalid after revocation; the manifest stays on disk for audit attribution.

9.4 OLP_OWNER_TOKEN environment override

For headless / CI / containerized deployments, the env var OLP_OWNER_TOKEN is honored:

  • Server startup reads OLP_OWNER_TOKEN. If set, the value is treated as a synthetic owner identity with stable key_id: "__env_owner__".
  • The plaintext token is NEVER logged, NEVER written to manifest, NEVER written to audit. The raw token leaves the env var and the request Authorization header only.
  • Cache namespacing uses __env_owner__ as the keyId, isolating env-owner traffic from filesystem-owner traffic.
  • Audit attribution uses key_id: "__env_owner__" and owner_tier: "owner".
  • Server startup logs warn non_persistent_owner_token with no token material, alerting the operator that the env-owner identity will disappear on restart unless re-set.

Filesystem-stored owner keys (from § 9.1/9.2) continue to validate independently when OLP_OWNER_TOKEN is set; the env-owner is an additive credential, not a replacement.

Token-collision policy. Hash-collision between an OLP_OWNER_TOKEN plaintext and a filesystem-stored key's plaintext is undefined behaviour at Phase 2 (cache namespacing would diverge silently between __env_owner__ and the filesystem <key-id>, while audit attribution would split). Operators MUST NOT reuse the same plaintext token across both surfaces. A future Phase MAY add a collision-detection startup check; not in Phase 2 scope.


10. Acceptance criteria

Implementation D-days (D44+) MUST land tests covering:

  1. Per-key cache isolation — Two keys A and B with identical request payloads do NOT share cache. cache_status is miss for both first calls and hit for the second call from the SAME key only.
  2. Anonymous prod-default off — With auth.allow_anonymous: false (no override), a request without a key receives 401 auth_required; the audit event is recorded with key_id: "__anonymous__" and status_code: 401.
  3. Anonymous dev-mode on — With auth.allow_anonymous: true, the same request succeeds with keyId="__anonymous__".
  4. Owner-vs-guest /health gating (with default auth.owner_only_endpoints config) — Owner key sees the full per-provider providers map in /health; guest key + anonymous see only { status, version }. Test rephrases if the operator's owner_only_endpoints config does not include /health (test must assert the same gating predicate the config produces, not a hardcoded trimmed payload shape).
  5. Owner-vs-guest X-OLP-Fallback-Detail gating — Same response payload for both owner and guest; header present for owner only.
  6. Key revocation — After revoke, subsequent requests with that token return 401 key_revoked within the next request (no caching of validation).
  7. Manifest atomicity + revoke-dominates-touch (§ 6.3, § 6.4) — Concurrent revoke + touchLastUsed writes do not corrupt the manifest AND revoke always survives. Test: spawn two writers racing on the same key (revoke vs touchLastUsed) under three orderings — revoke-then-touch, touch-then-revoke, and interleaved (touch reads pre-revoke snapshot, then revoke writes, then touch attempts write). For all three orderings, assert: (a) final file parses as valid JSON; (b) revoked_at is non-null and equals the revoke writer's timestamp; (c) last_used_at may have either writer's value. The test FAILS if any interleaving produces revoked_at: null after the revoke writer completed. This pins the §6.3 read-before-write discipline.
  8. Audit ndjson round-trip — Every line in audit.ndjson parses as valid JSON; every required field present; PII fields (message content, response content) absent.
  9. Bootstrap keygen surface — The minimal keygen command (whatever shape D44 chooses) runs end-to-end without manual file editing, produces a working owner key, and prints the plaintext exactly once.
  10. OLP_OWNER_TOKEN env override — With the env var set, a request bearing the env token validates as keyId="__env_owner__" with owner_tier="owner"; the raw token does NOT appear in any log line, audit event, or stack trace.
  11. providers_enabled scope enforcement — A guest key with providers_enabled: ["anthropic"] requesting model that routes to openai receives 403 key_no_provider_access and an audit event with the rejection reason.

11. Node baseline / storage portability

Option 2 (filesystem-only) was chosen at v0.2.0 over Option 1 (direct port of OCP's SQLite-backed keys.mjs) because of a runtime-baseline mismatch, not a critique of SQLite or of OCP's design.

Evidence:

  • OLP package.json declares engines.node ">=18" (file line 11).
  • CI test matrix in .github/workflows/test.yml runs Node 20 and 24 (file line 13).
  • node:sqlite was added in Node v22.5.0; v22.12 still required the --experimental-sqlite runtime flag to import; current Node API docs mark the module as Release Candidate (post-experimental but pre-stable). Source: https://nodejs.org/api/sqlite.html (retrieved during D43-B drafting 2026-05-25).

Adopting node:sqlite at v0.2.0 would require, in this order:

  1. Raise engines.node to a version where the API is at minimum non-flag-gated. Per Node's release-history docs — v22.5.0 added the API behind --experimental-sqlite (source: https://nodejs.org/download/release/v22.12.0/docs/api/sqlite.html confirms v22.12 still required the flag); the module moved past flag-gating in v22.13.0 (LTS line) and v23.4.0 (current line); the API entered Release Candidate at v25.7.0 per current docs (https://nodejs.org/api/sqlite.html). The minimum non-flag-gated baseline for engines.node is therefore >=22.13.0 (or >=23.4.0 on the non-LTS path). A stable (post-RC) baseline is TBD pending future Node releases beyond v25.x.
  2. Update the CI test matrix to drop Node 20 (or move the SQLite-using code behind a runtime feature check that exercises both code paths in CI).
  3. Accept Release-Candidate API stability risk in the project's storage layer for the period until the API moves to stable.

These three are achievable but are not zero-cost and have second-order effects (e.g., existing Node 20 deployments by family clients break on upgrade). Phase 2 does not undertake them; § 13 documents the forward path.

Decision posture statement. "SQLite is good; the runtime baseline says not yet."


12. Out of scope (Phase 3+)

The following are deliberately deferred from Phase 2 and tracked elsewhere:

  • Dashboard (dashboard.html) — owner-only multi-provider quota / fallback / cache-hit-rate panels. Deferred to Phase 3. (Was originally bundled into "Phase 6" in the pre-v0.1.1 README phase plan; the post-D43-A plan re-aligned this to Phase 3.)
  • Quota enforcement (manifest.quota non-null shapes) — manifest schema reserves the field; semantics + enforcement land in a Phase 3 ADR amendment.
  • Audit query layer / rotationaudit.ndjson is append-only at Phase 2; rotation policy + indexed query lands with Dashboard work (Phase 3).
  • Per-key per-provider auth artifact mapping — Phase 2 uses the global ~/.olp/providers/<name>/ artifacts for all keys. Per-key override (e.g., two OLP keys each authenticated to a different OpenAI Codex account) is a Phase 3+ concern; the spec § 4.5 phrasing "Multi-key support per provider" anticipates this without locking the design.
  • Audit memory buffer on append failure — see § 6.2 note; deliberate forward-path-only.
  • File-locking (flock(2)) — see § 6.4 note.

13. Future forward — Option 3 migration (Phase 3+)

When Dashboard / SQL-aggregate quota / >5 users / multi-second audit-query workload arrives, OLP's storage layer migrates to a hybrid model that retains manifest as the declarative SPOT and adds a SQLite-indexed query mirror.

Required preconditions BEFORE any migration commit:

  1. A separate prior PR raises engines.node and updates the CI matrix per § 11. This PR ships independently of any storage change.
  2. An ADR amendment to this file documents the migration trigger (which of the criteria above fired) and the schema mapping from manifest → SQLite rows.
  3. The migration code is a one-shot sync that reads every existing manifest, replays the audit log, populates SQLite from scratch, then begins dual-writing. Manifest writes remain authoritative; SQLite is rebuildable from manifest + audit at any time.

The migration is one-way (additive — SQLite gets added; manifest stays). Reverting from hybrid to manifest-only is supported by stopping SQLite writes and deleting the DB file.

Forward-path audit memory buffer. If audit append failures become non-rare (operational hint: audit_append_dropped count exceeds threshold in /health), Phase 3+ may add an in-process bounded buffer that flushes opportunistically. The buffer's design (size cap, flush interval, persistence on shutdown) is out of scope for Phase 2 and is a separate ADR amendment.


Consequences

Positive:

  • Closes the long-standing lib/keys.mjs 📋-Planned gap in AGENTS.md / README.md / v1x-roadmap.md.
  • Lets D40 X-OLP-Fallback-Detail re-gate per its v0.1 deferral note.
  • Lets README's long-standing claim "/health is owner-only" become factually true.
  • Per-key cache namespacing becomes observable behaviour (was a latent affordance only).
  • Family members can each have their own OLP key without sharing cache state.
  • Audit trail per request enables troubleshooting questions ("did my call hit cache?", "which key triggered the fallback to mistral?") without inspecting logs.

Negative / trade-offs:

  • Filesystem audit is O(N) for any aggregate query — acceptable until Phase 3 Dashboard work.
  • Manifest atomicity at multi-writer scale is not bulletproof — see § 6.4; mitigated by the single-process Phase 2 deployment model.
  • The plaintext-once contract puts UX burden on the keygen command output — operators must capture the token immediately on creation; lost = revoke + regenerate.
  • Existing OCP users migrating will need new OLP keys (OCP's SQLite-backed keys are not portable to OLP's manifest layout — § 9 "Migration from OCP" in scripts/migrate-from-ocp.mjs 📋 Phase 7 may add a one-shot translator; not in Phase 2 scope).

Reversibility:

  • Migration to Option 3 hybrid (§ 13) is supported and explicitly planned.
  • Reverting Phase 2 entirely would require restoring the __anonymous__ hardcoding in server.mjs and removing the auth middleware. The decision is reversible but no concrete trigger has been imagined; the decision is treated as durable.

Authority citations

  • OLP v0.1 spec § 4.5 (planning authority for ~/.olp/ layout in § 3) — at ~/.cc-rules/memory/projects/olp_v0_1_spec.md on the maintainer's workstations.
  • OCP keys.mjs (prior-art reference for opaque-key + per-key isolation model) — at ~/ocp/keys.mjs on the maintainer's workstation; OCP v3.13.0 production.
  • Phase 2 kickoff handoff (decision provenance for Option 2 + opaque + four amendments) — ~/.cc-rules/memory/handoffs/2026-05-25-phase-2-kickoff.md committed in cc-rules d9da966.
  • Node node:sqlite documentation (rejection rationale for Option 1 in § 11) — https://nodejs.org/api/sqlite.html (retrieved 2026-05-25).
  • lib/cache/store.mjs:77-79, :287 (proof that per-keyId namespace + singleflight composition are wired and ready to receive a real keyId).
  • server.mjs:502, :531 (the two hardcoded '__anonymous__' call sites Phase 2 implementation replaces).
  • server.mjs:392, :1072, :1101 (the three call sites Phase 2 implementation gates: /health handler entry and the two X-OLP-Fallback-Detail header-write paths).
  • ADR 0004 Amendment 5 (D40 ungated header + Phase 2 re-gating deferral) — docs/adr/0004-fallback-engine.md.
  • CLAUDE.md release_kit.phase_rolling_mode current_pre_release_identifier = "0.2.0-phase2" — confirms this ADR lands in the Phase 2 sprint.