Files
olp/docs/adr/0015-session-nat-layer.md
T
taodengandClaude Opus 4.7 1fc3a1be06 docs(adr): Draft ADR 0015 — Session-NAT Layer (Phase 8 candidate)
Forward-looking design proposal triggered by user-side architectural
insight (NAT-routing analogy) and the 2026-06-15 Anthropic billing-pool
split. Captures Option A (per-OAuth rate limit) + Option B (session
persistence) discussion as a unified Session-NAT architecture.

**Status:** Draft (Phase 8 candidate; NOT yet Accepted; spike work
required before transition to Proposed).

**Core insight:** Map (many OLP keys -> one Anthropic OAuth) the way
NAT maps (many internal IPs -> one external IP via stable port
multiplexing). claude CLI's --session-id <uuid> primitive (verified
on v2.1.154) provides the per-conversation tag. OLP holds per-OLP-key
stable session_id in ~/.olp/sessions/<key-id>/ and symlinks the
session log into Phase 7 Solution 1's ephemeral home.

**Anthropic-side observable change:**
- Before: 1 OAuth + infinite ephemeral session_ids (bot-like
  fingerprint)
- After: 1 OAuth + small bounded number of stable session_ids
  (power-user-IDE fingerprint)

**Co-merge requirements documented:**
- ADR 0001 amendment — narrow "no conversation state" to distinguish
  routing/identifier state (allowed; key manifest, cache namespace,
  session_id) from conversation/content state (still excluded; OLP
  doesn't read claude's session log)
- ADR 0009 Amendment 2 — drop --no-session-persistence from Phase 6c
  spawn args; replace with --session-id <stable_uuid>; all other
  Phase 6c flags unchanged (stream-json, --verbose, --system-prompt)
- ADR 0002 Amendment 10 — additive ISOLATION.sessionLayer field
  (optional; anthropic populated, codex/mistral omit)

**Open questions blocking transition to Proposed (7):**
1. Whether --no-session-persistence is required to be ABSENT for
   stream-json without --print (spike empirical question)
2. cwd-encoding stability across claude CLI versions
3. session file format compat across versions
4. Cost prediction validation (-20% to +50% range; needs measurement)
5. Concurrent same-key pool exhaustion behavior
6. Anthropic-side observability for billing-classification validation
7. Operator UX for forced session rotation

**Honest assessment included:** Session-NAT dampens 5 of 9 observable
fingerprint signals. Remaining 4 (per-OAuth request rate, --system-prompt
customization, HTTP headers, concurrent burst) addressed by orthogonal
mechanisms (ratelimit ADR, accepted Phase 6c trade-off, no-spoofing per
AGENTS.md, pool size cap). Not a magic bullet; durable answer remains
multi-provider fallback per ADR 0001.

**Reversibility:** Fully reversible by removing --session-id from spawn
args + deleting ~/.olp/sessions/. ~300 LOC delete. No state migration.

**Authority citations (per ALIGNMENT.md Rule 1):**
- claude CLI v2.1.154 --help capture (PI231, 2026-05-29 11:33 UTC)
- Anthropic billing announcement 2026-05-14
- ADR 0001 / 0002 / 0007 / 0009 / 0014 cross-references
- cc-mem incident_2026_05_27_spawn_cli_security.md § 6, § 9
- Phase 7 PI231 spike (docs/spikes/2026-05-29-ephemeral-home.md)

Doc-only PR. No code change. Existing OLP runtime unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:50:14 +10:00

38 KiB
Raw Blame History

ADR 0015 — Session-NAT Layer: Per-OLP-key Stable Session_id for IDE-like Anthropic Billing Classification

Status: Draft (Phase 8 candidate, NOT YET ACCEPTED) Date: 2026-05-29 Phase: Phase 8 (not yet started) Authors: project maintainer (with AI drafting assistance)


  • ADR 0001 (Project Founding) — § Non-mission says "OLP is a pure stateless proxy. Memory and continuity are client-side concerns." This ADR proposes carrying a small piece of state (session_id per OLP key) and requires an ADR 0001 amendment co-merge to redraw the state boundary.
  • ADR 0007 (Multi-key Auth) — provides the per-OLP-key isolation primitive (keyId) that this ADR's NAT-style mapping uses as its left-hand side.
  • ADR 0009 Amendment 1 (Phase 6c stream-json transport) — established --no-session-persistence as part of the spawn args. This ADR proposes dropping that flag; requires an ADR 0009 Amendment co-merge.
  • ADR 0014 Amendment 1 (Phase 7 Solution 1) — ephemeral $HOME + symlinked credentials per spawn. This ADR layers Session-NAT on top: each OLP key gets a stable session file outside the ephemeral home, symlinked in at spawn time.
  • Anthropic billing announcement (2026-05-14, effective 2026-06-15) — the policy event that motivates this ADR. Splits claude -p / third-party-app traffic out of subscription pool into Agent SDK Credit pool.
  • cc-mem incident_2026_05_27_spawn_cli_security.md § 9 — bridge-value caveat for the Phase 6c spawn-mode change.

1. Context

1.1 The user's NAT framing

The architectural insight that motivates this ADR is a network-address-translation analogy. In a home router:

NAT concept Implementation
Many internal IPs All devices behind the router
One external IP The ISP-assigned WAN address
Port number (multiplexing tag) TCP/UDP source port the router rewrites
Conntrack table Router-side map of (internal IP, internal port) ↔ (external IP, external port)

The external network sees one IP with many distinct connections distinguished by port. The downstream (ISP) does not need to know that NAT is happening to bill correctly — they bill the one external IP for the aggregate traffic.

The OLP-side analogue:

NAT concept OLP-NAT mapping
Many internal IPs Many OLP keys (one per family member / device / use-case)
One external IP One Anthropic OAuth credential (pooled subscription)
Port number claude CLI session_id (a UUID identifying a conversation)
Conntrack table OLP-side Map<olp_key_id, stable_session_id> persisted to disk under ~/.olp/sessions/<key-id>/

The Anthropic API server sees one OAuth with N concurrent conversations, each identified by a stable session_id that persists across requests. This is structurally indistinguishable from one power-user who has N project windows open in Claude Code.

1.2 The signal OLP currently emits

Post-Phase-7, OLP spawns claude with --no-session-persistence and --system-prompt <OLP wrapper>. Every /v1/chat/completions request produces a fresh session_id that claude never reuses. The Anthropic-side observable pattern is:

  • One OAuth token
  • Infinite never-repeated session_id values
  • Session lifetime ≈ duration of the HTTP request
  • Cross-session_id correlation: zero

This pattern is mechanically distinguishable from any plausible IDE user — IDE users keep session IDs alive for hours, reuse them across many requests, and rarely emit > 510 distinct sessions per OAuth. The current OLP spawn shape is therefore an unintentional but reliable fingerprint that Anthropic's billing classifier can use to flag OLP traffic as "third-party app authenticating via Agent SDK" — exactly the bucket that moves to the $100/mo Agent SDK Credit pool on 2026-06-15.

1.3 What this ADR is, and is not, trying to do

This ADR is trying to:

  • Reshape OLP's observable Anthropic-side traffic so that it falls inside the statistical envelope of "one power user with multiple project windows" rather than "third-party API client churning ephemeral sessions"
  • Inherit any cost benefit Anthropic's prompt-cache regime gives to long-lived sessions
  • Preserve the four orthogonal values of Phase 6c (cost reduction, hallucination fix, NDJSON observability, possible bridge value) — none of those are sacrificed
  • Compose cleanly with Phase 7 Solution 1 ephemeral-home + symlink + per-provider ISOLATION

This ADR is not trying to:

  • Permanently evade Anthropic's billing classification. Anthropic can, and probably will, add additional signals beyond session_id count (per-OAuth request rate, prompt-content fingerprinting, etc.). Session-NAT addresses one signal; durability comes from the multi-provider fallback chain (ADR 0001).
  • Hide that OLP is OLP. ALIGNMENT.md Anti-Fingerprinting clause is respected: this ADR uses claude CLI's documented native flags (--session-id, --resume) and does not patch the binary, MITM the HTTPS transport, or fabricate HTTP headers.
  • Provide conversational memory to OLP clients as a user-visible feature. The session_id state is for routing / billing-classification purposes only; clients still send full conversation history in their OpenAI-format requests, and OLP still relays it. The model gets the same input it would without this ADR; what changes is what session_id claude tags the API call with.

This last distinction is what makes the ADR 0001 amendment claim defensible: OLP holds the identifier of a session, never the content of one.

1.4 Why this is Phase 8, not Phase 7

Phase 7 closed at v0.7.0 on 2026-05-29 with the Solution 1 isolation layer. Session-NAT is an independent architectural decision that:

  • Depends on Phase 7 being in place (uses ephemeral-home + symlink primitives)
  • Is not on the critical path for any current OLP behaviour (ratelimit + session-pool are quality-of-service features, not correctness features)
  • Requires spike work to verify claude CLI --session-id + cwd-encoding behaviour before implementation
  • Has an open ADR 0001 amendment that needs maintainer-level signoff

Treating it as a Phase 8 candidate gives the maintainer time to observe what Anthropic actually does to OLP traffic post-2026-06-15 before committing to the engineering work. If Anthropic does not in fact reclassify OLP traffic (or the reclassification is benign), this ADR can be abandoned without cost.


2. Decision

Adopt the Session-NAT layer: a per-OLP-key stable claude session_id pool maintained by OLP, symlinked into the Phase 7 ephemeral spawn home so that each /v1/chat/completions request to the anthropic provider invokes claude with a session that persists across requests for that OLP key.

Co-merge requirements:

  • ADR 0001 amendment clarifying that session_id is a routing identifier held by OLP and is not "conversation state" in the ADR-0001 sense (which excluded prompt content, memory continuity, and IDE-side context — none of which OLP starts holding under this ADR).
  • ADR 0009 amendment narrowing the Phase 6c spawn-arg specification: --no-session-persistence is replaced by --session-id <stable_uuid> (computed by OLP per key + lifecycle policy). All other Phase 6c args (stream-json + verbose + --system-prompt) are unchanged.
  • ADR 0014 Amendment (optional, possibly Amendment 2 to 0014) noting that the Phase 7 ephemeral-home requiredHomePaths list is extended for the anthropic provider to include the session-mount target dir.

3. Architecture

3.1 Per-key session state filesystem layout

~/.olp/
  sessions/
    <olp-key-id>/                         <-- chmod 0700, per ADR 0007 § 3
      session_state.json                   <-- chmod 0600
      conversation.jsonl                   <-- chmod 0600; claude session log
      session_state.json.tmp.<pid>.<counter>  <-- transient, atomic-replace target

session_state.json schema (v1):

{
  "schema_version": 1,
  "current_session_id": "<uuid-v4>",
  "created_at": "<ISO-8601 UTC>",
  "last_used_at": "<ISO-8601 UTC>",
  "turn_count": 0,
  "rotation_reason_history": [
    { "reason": "lifetime_expired" | "turn_limit_reached" | "conversation_boundary" | "initial", "at": "<ISO-8601 UTC>" }
  ]
}

Field semantics:

  • current_session_id: the UUID currently in use for this OLP key's claude spawns. Pre-generated as UUID-v4 (per crypto.randomUUID()); fed to claude via --session-id <uuid>. Rotated per the policy in § 3.2.
  • turn_count: incremented after every successful /v1/chat/completions spawn that reaches the result event (failed spawns don't increment).
  • rotation_reason_history: bounded ring buffer of last N rotations for debugging / audit. N = 10.

conversation.jsonl is claude CLI's native session log format. OLP does not parse, read, or modify this file. It exists on disk solely so claude can read+write its own session state across spawns; OLP's only interaction with it is the symlink in § 3.4.

This boundary is the ADR 0001 amendment's load-bearing claim: OLP holds the file path and inode reference (via session_state.json's current_session_id and the symlink layout), but the conversation content lives in a file format OLP does not consume.

3.2 Session lifecycle

A session is rotated (new UUID generated, old conversation.jsonl archived or pruned) when any of the following triggers fire:

  1. Lifetime expirednow() - created_at > session_lifetime_hours (default: 8h, configurable via ~/.olp/config.json security.session_nat.lifetime_hours)
  2. Turn limit reachedturn_count >= session_max_turns (default: 200, configurable)
  3. Conversation boundary detected — see § 3.3
  4. Operator-forcedolp keys rotate-session --key <id> admin command (Phase 8.x deliverable)
  5. Initial — first spawn for an OLP key that has no session_state.json yet

When a session rotates, the old conversation.jsonl is renamed to conversation-<rotated_at>.jsonl.old and the new conversation.jsonl is fresh-empty. The old log is retained on disk for N days (default 7) then garbage-collected by a background sweep. This retention is non-load-bearing — OLP does not query it — but supports operator debugging.

Why two upper bounds, not one: the lifetime cap matches a typical IDE work-day rhythm (and matches what Anthropic's classifier most likely models as "session timeout"). The turn cap protects against unbounded prompt-history growth for high-frequency OLP keys. A power user with light usage stays in one session all day; a heavy user rotates by turn count.

3.3 Conversation-boundary detection (heuristic)

Anthropic's IDE-user pattern includes session rotation when the human "starts a new conversation" (clears the chat, opens a new pane, etc.). OLP can't observe a user click "new chat", but can detect a probable conversation boundary using OpenAI-format request inspection:

  • Heuristic A: the incoming request's messages[0] differs structurally from the prior request's messages[0] (different system prompt OR first user turn is different).
  • Heuristic B: the incoming request's messages array length is 1 (a user starting fresh from scratch), and the prior request had > 1 message.
  • Heuristic C: the time gap between this request and last_used_at exceeds a configurable idle threshold (default 2h).

Any heuristic firing triggers a session rotation. The heuristics are conservative-bias — false positives (rotating when the user is actually continuing) are mild (small token-cost hit from losing prompt-cache continuity); false negatives (not rotating when they actually started a new conversation) are mild (slightly worse fingerprint match to IDE behaviour, but not catastrophic).

Heuristics A and B are stateless and cheap; C requires a single Date comparison. None of the three reads messages[i].content beyond shape inspection, preserving the OLP-doesn't-hold-conversation-content invariant.

3.4 Integration with Phase 7 ephemeral home

Phase 7 Solution 1 creates an ephemeral $HOME at /tmp/olp-spawn/<keyId>/<reqId>/home/ per request, with credentials symlinked in. Session-NAT adds one more symlink:

/tmp/olp-spawn/<keyId>/<reqId>/home/
├── .claude/
│   ├── .credentials.json                          <-- symlink to ~/.claude/.credentials.json (existing Phase 7)
│   └── projects/
│       └── <ephemeral_cwd_encoded>/
│           └── <session_id>.jsonl                 <-- symlink to ~/.olp/sessions/<keyId>/conversation.jsonl  (NEW)

The <ephemeral_cwd_encoded> segment is claude's session-file-path encoding of the spawn cwd. From the Phase 7 spike on PI231, claude encodes cwd by replacing / with -; the spawn cwd is /tmp/olp-spawn/<keyId>/<reqId>/work (or whatever cwd OLP sets), so the encoded form is -tmp-olp-spawn-<keyId>-<reqId>-work.

The crucial invariant: claude's session storage path is $HOME/.claude/projects/<cwd-encoded>/<session-id>.jsonl. To make session state persist across spawns (which is the whole point), this path must resolve (via the symlink) to a single per-OLP-key file outside the ephemeral home.

Open issue: <cwd-encoded> depends on the spawn cwd, which under Phase 7 is per-request unique. This means each spawn's <cwd-encoded> is different, so each spawn's <session_id>.jsonl lookup path is different. Even with --session-id <stable_uuid>, claude will not find prior session state because the lookup path includes per-request cwd.

This is a real architectural problem that the implementation spike must resolve. Three candidate fixes, in increasing order of cleanliness:

a) Stable spawn cwd per OLP key — OLP changes the spawn cwd from /tmp/olp-spawn/<keyId>/<reqId>/work to /tmp/olp-spawn/<keyId>/work (drop the reqId segment). Cwd becomes per-key not per-request. Loses per-request cwd isolation. Acceptable if cwd-level isolation isn't load-bearing (the per-key ephemeral home still isolates files; cwd is just a label).

b) Synthetic stable cwd via cwd: spawn option — OLP passes cwd: /home/olp/.claude-cwd/<keyId> (a stable per-key directory) to child_process.spawn. The cwd directory exists on disk but is empty; claude only uses it for label-encoding purposes. The actual filesystem reads/writes go through the ephemeral home's .claude/projects/<encoded>/ symlink.

c) Two-symlink approach — symlink the parent projects/<encoded>/ directory itself (not just the session file inside) so any <session_id>.jsonl inside resolves to the per-key persistent location. Then changing cwd between spawns doesn't break the resolve because all encoded paths point to the same backing dir.

Approach (b) is cleanest from a layering standpoint (Phase 7 ephemeral home stays untouched, cwd is the only knob); approach (c) is most robust to claude CLI changes in cwd-encoding. The spike picks one.

3.5 Same-key concurrent requests — session pool

A single OLP key can receive parallel /v1/chat/completions requests (e.g., a family member's laptop fires two browser tabs at once). claude's session file is not safe for concurrent append/edit — two spawns writing the same conversation.jsonl would corrupt it.

The session-NAT design needs to handle this gracefully without forcing client-side serialisation. Three options:

  1. Per-key serial lock: second concurrent request waits for the first to release. Bad UX — every parallel request gets latency from the slower predecessor.

  2. Per-key session pool: each OLP key owns a small fixed number of session slots (default 3, configurable). Concurrent requests cycle through available slots; if all are busy, the request queues briefly then proceeds to a fresh ephemeral session (degraded mode — that request loses session continuity but completes). Acceptable parallelism; bounded growth in session count.

  3. One session per concurrent conversation thread: dynamically create sessions on demand, garbage-collect after timeout. Most flexible; highest session count per OAuth.

Recommended: option 2 with session_pool_size: 3 default. This gives each OLP key a small bounded session set — (N OLP keys) × 3 total session count on the Anthropic API side per OLP deployment. For a family-scale deployment of 5 keys, that's 15 maximum concurrent session_ids on one OAuth, which is still well within "power-user with multiple project windows" plausibility.

The pool slot in use for each spawn is recorded in session_state.json (extended schema):

{
  "schema_version": 1,
  "pool": [
    { "session_id": "<uuid>", "in_use": false, "last_used_at": "...", "turn_count": 12 },
    { "session_id": "<uuid>", "in_use": true,  "last_used_at": "...", "turn_count": 87 },
    { "session_id": "<uuid>", "in_use": false, "last_used_at": "...", "turn_count": 4 }
  ]
}

Lock acquisition: atomic-rename pattern on session_state.json (the same atomic-write discipline ADR 0007 § 6.1 codified for key manifests). A request holds its slot's in_use=true for the duration of the claude spawn, releases on spawn exit (happy or error path) via the existing Phase 7 cleanup mechanism extended for session lifecycle.

3.6 claude CLI flags used (verified empirically)

Empirical verification of claude --help on PI231 v2.1.154 (2026-05-29) shows the following flags available:

  • --session-id <uuid> — "Use a specific session ID for the conversation (must be a valid UUID)". This is the canonical Session-NAT primitive. When passed with a UUID claude has seen before in the same session-storage path, claude resumes that session; with a new UUID it creates fresh.
  • -r, --resume [value] — "Resume a conversation by session ID, or open interactive picker with optional search term". Alternative to --session-id for resume-only semantics. Less flexible (no fresh-create); may not coexist with --session-id cleanly. Spike verifies whether --session-id alone gives resume-or-create-as-needed behaviour (preferred for Session-NAT).
  • -c, --continue — "Continue the most recent conversation in the current directory". Cwd-dependent; not used by Session-NAT (we want explicit session control via UUID).
  • --fork-session — "When resuming, create a new session ID instead of reusing the original". Not used; we want session reuse, which is exactly what the flag inhibits.
  • --no-session-persistence — currently passed by OLP per Phase 6c. This ADR proposes removing it (ADR 0009 amendment co-merge).

Flag combination proposed for Session-NAT spawn:

claude \
  --session-id <stable_uuid_from_pool> \
  --output-format stream-json \
  --verbose \
  --system-prompt <OLP_SYSTEM_PROMPT_WRAPPER> \
  --model <model_from_request>
# Note: --no-session-persistence removed
# Note: --resume / --continue NOT added (--session-id covers both new-create and resume semantics per spike validation)

The spike must confirm:

  • Whether --session-id <new_uuid> creates a fresh session when no prior session file exists at the expected path.
  • Whether --session-id <existing_uuid> resumes successfully when the session file exists at the expected path (under stable cwd from § 3.4 fix).
  • Whether --no-session-persistence is required to be absent (its presence might override --session-id and force ephemeral).
  • Whether the spawn behaves correctly when the session file is a symlink rather than a real file (Phase 7 symlinked credentials work fine; assume yes but verify).

4. ADR 0001 Amendment Co-merge — state framing

Current ADR 0001 § Non-mission text (relevant excerpt):

"OLP is a pure stateless proxy. Memory and continuity are client-side concerns (Memory Continuity, Hermes equivalents, IDE-side context). OLP does not retain it."

Proposed amendment (additive paragraph):

Amendment N — Session-NAT routing-identifier carveout (Phase 8, ADR 0015 co-merge, 2026-05-29)

The "stateless proxy" framing in § Non-mission carves out a distinction between routing/identification state and conversation/content state:

  • Routing/identification state that OLP holds: per-OLP-key key manifest (ADR 0007 § 4), per-key cache namespace (ADR 0005 D1), and — under ADR 0015 — a per-OLP-key claude session_id (a UUID identifier with no conversation content). All of these are bounded-size identifiers used to route requests to the correct downstream slot.

  • Conversation/content state that OLP does NOT hold: prompt content of in-flight or past requests, response content beyond what flows through OLP's per-request cache (ADR 0005), memory or continuity that survives request boundaries, IDE-side editor / cursor / project context, or any other data that constitutes "what was talked about". Under ADR 0015, claude CLI's session log file conversation.jsonl lives on the OLP host's disk because the spawn architecture requires it, but OLP does not read, parse, or transmit its content beyond claude's own consumption.

The thesis "OLP is a pure stateless proxy" is preserved in the content sense — the dimension on which it actually matters for ADR 0001's reasons (privacy, simplicity, no migration concerns, no conversation-history liability). The carveout above codifies that OLP has always held a small amount of identifier state (the key manifest); ADR 0015 widens this by one UUID per key.

Operationally: an OLP shutdown loses all in-flight request state but preserves session_id identifiers across restarts. A user wiping ~/.olp/sessions/<key-id>/ loses claude's conversation continuity for that key but does not affect anything else about that key (audit trail, cache, manifest all intact).

This amendment is co-merge required with ADR 0015 acceptance. ADR 0015 acceptance without ADR 0001 amendment leaves the constitution self-contradictory.


5. ADR 0009 Amendment 1 amendment Co-merge — drop --no-session-persistence

Current ADR 0009 Amendment 1 spawn-arg specification (Phase 6c):

claude --output-format stream-json --verbose --no-session-persistence --system-prompt <OLP_SYSTEM_PROMPT_WRAPPER>

Proposed change (ADR 0009 Amendment 2):

Amendment 2 — Session-NAT layer (Phase 8, ADR 0015 co-merge, 2026-05-29)

The --no-session-persistence flag is removed from the Phase 6c spawn args specification. The flag's original purpose (per-spawn statelessness, no session continuity) is superseded by the Session-NAT layer (ADR 0015), which deliberately introduces controlled per-OLP-key session continuity to match IDE-user fingerprint patterns. All other Phase 6c flags (--output-format stream-json, --verbose, --system-prompt <OLP wrapper>) are unchanged — their cost-reduction and hallucination-suppression and observability values are preserved.

The new flag set adds --session-id <stable_uuid_from_pool> per-spawn. The UUID is computed by OLP per the ADR 0015 § 3.2 lifecycle policy and § 3.5 pool selection.

If the implementation spike (ADR 0015 § 10 open question 1) reveals that --no-session-persistence is required to be present for --output-format stream-json to work without --print, this amendment is rejected and Session-NAT is re-scoped to use --print mode (with the associated cost regression). In that case ADR 0015 is also re-scoped — see ADR 0015 § 10 open question 1's contingency.


6. Cost analysis

6.1 Anthropic prompt cache interaction

Anthropic's API supports prompt caching: identical prefix tokens in subsequent requests hit a 90%-off price (cache-read) instead of full price (cache-creation). Long-lived sessions naturally accumulate identical prefixes (system prompt + early conversation turns are constant), so persistent sessions should have higher prompt-cache hit rates than ephemeral ones.

Current OLP (post-Phase-7, ephemeral session) observed cost per Sonnet 4.6 request: $0.0078 average (from Phase 6c measurement). Under Session-NAT, two competing forces:

  • Prompt-cache hit rate increase (cost-down): ~30-50% of input tokens were cache_creation in the post-Phase-6c measurement. Under Session-NAT, these become cache_read on subsequent turns within the session → ~70% input-token-cost reduction on the cached portion → net 20-35% cost reduction.

  • Conversation-history growth (cost-up): each turn N's request prompt includes turns 1..N-1's content (claude prepends prior conversation as context). After 50 turns the input token count is N× higher than turn 1. Even with 90% cache-read discount, the total cost trends upward with conversation length.

Net effect depends on the turn-count distribution and conversation-content size. For a family deployment with light usage (~50 turns per session before rotation), prompt-cache benefits dominate and net cost decreases. For heavy usage (hitting the 200-turn rotation cap), conversation growth dominates and net cost increases vs Phase 7 baseline.

Predicted range (un-spiked): -20% to +50% per-request cost vs Phase 7 baseline, depending on usage pattern. Spike § 10 open question 4 measures actual.

6.2 Cache layer (ADR 0005) interaction

OLP's own cache layer hashes (provider, model, IR-request) and stores response — orthogonal to claude's session cache. Under Session-NAT, two interactions:

  • OLP-cache key stability: same IR-request still hashes to the same OLP cache key regardless of session_id, so OLP cache hits are unaffected. Good.
  • OLP-cache hit-rate vs claude-cache hit-rate: when OLP cache hits, no claude spawn fires, so claude prompt-cache doesn't matter. OLP-cache misses go to claude with possible prompt-cache hit. The two layers compose; no negative interaction expected.

6.3 Storage growth

Session log files (conversation.jsonl) grow per turn. Rough estimate: a 10-turn conversation in claude's session log is ~20-50 KB. Per OLP key, 200 turns × 3 pool slots = ~30 MB max before rotation. Family-scale deployment of 5 keys: ~150 MB session storage. Acceptable.

Rotated log retention (default 7 days) adds another factor: ~150 MB × 7 = ~1 GB worst case. Operator can tune session_nat.retention_days lower if disk is constrained.


7. Provider contract impact

The Provider ISOLATION contract (ADR 0002 Amendment 9) currently has no session-related field. Session-NAT touches only the anthropic provider's spawn path; codex and mistral are unaffected (codex has its own session model via ~/.codex/sessions/; mistral pending).

Proposed ISOLATION extension (optional, additive):

export const ISOLATION = {
  // ... existing fields per ADR 0002 Amendment 9 ...

  // ADR 0015 (Session-NAT) extension (OPTIONAL — providers without
  // sessionLayer fall back to ephemeral-per-spawn behaviour).
  sessionLayer: {
    enabled: true,
    persistDir: ({ keyId }) => `~/.olp/sessions/${keyId}`,
    // Function returning the spawn args that activate the session for
    // a given session_id. Allows provider-specific flag composition.
    sessionFlagsForSpawn: ({ sessionId }) => ['--session-id', sessionId],
    // Pool configuration; orchestrator selects an available slot.
    poolSize: 3,
    lifetimeHours: 8,
    maxTurns: 200,
  },
};

For anthropic this is populated as above; for codex and mistral the field is omitted (or enabled: false), and the orchestrator's pre-spawn step skips session work for those providers — equivalent to current Phase 7 behaviour.

This is a forward-compatible additive change to ADR 0002 Amendment 9. A separate ADR 0002 Amendment 10 co-merge captures it.


8. Detection-resistance analysis (honest assessment)

What signals does Session-NAT dampen, and which does it not?

Signal Current Phase 7 OLP Session-NAT
Active session_ids per OAuth ♾ ephemeral (very bot-like) 515 stable (IDE-power-user-like)
Session reuse over time Never Yes (hours to days per session)
Session lifetime < 1 second Hours
Cross-session_id correlation None None (preserved)
Per-OAuth request rate Aggregated, can spike Unchanged — needs separate ratelimit ADR
Per-session request rate n/a (sessions don't survive a single request) Bounded by usage pattern; comparable to human-paced IDE rate
--system-prompt customization OLP-specific wrapper, not Claude Code default Unchanged — same fingerprint
User-agent / HTTP headers claude CLI default Unchanged — same fingerprint
Concurrent active sessions per OAuth (same time-window) Bounded by pool_size × key count

The honest conclusion: Session-NAT shifts OLP's traffic into the "one power user with several persistent IDE projects" envelope across 5 of 9 observable signals. The remaining 4 signals (per-OAuth request rate, --system-prompt customization, HTTP headers, concurrent-session burst) are addressed by orthogonal mechanisms:

  • Per-OAuth request rate: covered by the Phase 8 ratelimit ADR (see § 1.3 — separate work).
  • --system-prompt customization: a known fingerprint we accept the cost of (Phase 6c benefits outweigh).
  • HTTP headers: claude CLI controls these; OLP doesn't touch (and shouldn't, per AGENTS.md).
  • Concurrent burst across pool slots: ratelimit pool layer caps this.

Session-NAT is therefore best understood as one of 2-3 layers that together approximate IDE-user-pattern matching. It is not sufficient alone.


9. Authority citations

Per ALIGNMENT.md Rule 1:

  • claude CLI v2.1.154 --help capture (PI231, 2026-05-29 11:33 UTC):

    • --session-id <uuid> — "Use a specific session ID for the conversation (must be a valid UUID)"
    • --resume [value] — "Resume a conversation by session ID, or open interactive picker with optional search term"
    • --continue — "Continue the most recent conversation in the current directory"
    • --fork-session — "When resuming, create a new session ID instead of reusing the original"
    • --no-session-persistence — "Disable session persistence — sessions will not be saved to disk and cannot be resumed (only works with --print)"
    • Full capture stored in docs/spikes/2026-05-29-claude-session-flags.md (to be created at spike time)
  • Anthropic billing announcement 2026-05-14, effective 2026-06-15 — original public announcement URL (placeholder; verify at impl time): the Pro/Max subscription pool excludes claude -p / Agent SDK traffic post-effective-date. Source: cc-mem ~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md.

  • ADR 0001 § Mission and § Non-mission — the "no conversation state" framing this ADR amends.

  • ADR 0007 § 3 (filesystem layout) + § 6 (atomic-write discipline) — the per-OLP-key isolation primitive and the atomic-write pattern reused for session_state.json.

  • ADR 0009 Amendment 1 — the Phase 6c spawn-arg specification this ADR proposes amending.

  • ADR 0014 Amendment 1 (Phase 7 Solution 1) + cc-mem incident_2026_05_27_spawn_cli_security.md § 6 — the ephemeral home + spawn architecture this ADR layers on top.

  • Phase 7 PI231 spike (docs/spikes/2026-05-29-ephemeral-home.md) — established that claude v2.1.152 honours HOME env override and writes session-derived state under $HOME/.claude/projects/<cwd-encoded>/. This ADR's § 3.4 fix builds on that observation.


10. Open questions (spike-required before implementation)

These must be resolved by a PI231 (or comparable) spike before ADR 0015 transitions from Draft to Accepted. Each is a blocking issue.

  1. --no-session-persistence and stream-json interaction. claude CLI --help documents that --no-session-persistence "only works with --print", and separately documents that --output-format stream-json works "only with --print". The Phase 6c spike empirically discovered that stream-json works without --print on v2.1.104-v2.1.154 despite the help text. The open question for Session-NAT: does removing --no-session-persistence work in the no---print stream-json mode? Spike: spawn claude with stream-json + --session-id <uuid> and no --print, verify session state is written to disk and a second spawn with same session-id resumes correctly.

  2. cwd-encoding stability across claude versions. claude encodes cwd into the session file path by replacing / with - (observed empirically on v2.1.152, Phase 7 spike). Is this stable across the v2.1.100-v2.1.154 window? Future versions? Session-NAT's symlink layout in § 3.4 depends on the encoding being predictable. Spike: test cwd-encoding on at least 3 claude versions; document the formula; fall back to approach (c) two-symlink design if the encoding ever changes.

  3. Session file format compatibility across versions. claude CLI may evolve conversation.jsonl format. If a session created on v2.1.154 is resumed on v2.1.158 with a different format, does claude reject it gracefully or corrupt? Spike: cross-version resume test; if any version rejects, add a session-rotation-on-claude-version-change policy.

  4. Cost prediction validation. § 6.1 predicts -20% to +50% per-request cost vs Phase 7 baseline. Spike: measure 50 sequential requests in a single OLP key with Session-NAT vs Phase 7 baseline; report actual cost delta. If cost is > +30%, re-evaluate the architecture (perhaps lower turn cap, or accept it).

  5. Concurrent same-key pool exhaustion behaviour. § 3.5 specifies pool slots with in_use=true flag and queueing. The current Phase 7 cleanup path runs on spawn exit. Verify the slot-release timing under (a) happy path, (b) spawn error path, (c) HTTP client disconnect mid-stream. Spike: spawn pool exhaustion under load test; verify no permanent slot leaks.

  6. OAuth-side observability for Session-NAT validation. Once Session-NAT is shipped, how do we verify Anthropic is in fact treating OLP traffic as IDE-pattern? Options: (a) Anthropic dashboard quota probe (ADR 0008 Amendment 2 quota_v2) — does the representative_claim or status_5h field change semantically? (b) Per-OAuth bill at end of month — does it land in subscription pool or Agent SDK Credit pool? Spike: define a measurable success criterion before shipping, not after.

  7. Operator UX for forced-rotate. § 3.2 trigger 4 ("Operator-forced") needs a CLI surface. Probably olp keys rotate-session --key <id> (or all-keys variant). Out-of-band from main spike but should be specified before implementation begins.


11. Consequences

Positive

  • Anthropic-side fingerprint approaches power-user-IDE pattern for the dominant session_id signal. Reduces probability of OLP traffic being unilaterally reclassified into the Agent SDK Credit pool on 2026-06-15.
  • Prompt-cache hit-rate improvement for repeat-prefix queries within a session. Net cost direction depends on usage pattern but expected to be at worst neutral, possibly positive (§ 6.1).
  • Conversation continuity as an emergent feature for OLP clients — each OLP key now has a claude-side memory across requests. Clients that benefit (e.g., a single user reusing one key for related questions) get an improved UX. Clients that don't (stateless API clients) are unaffected since they always send full conversation history anyway.
  • Architectural cleanness via the NAT framing — the conntrack-table-style stable mapping is well-understood, debug-friendly, and analytics-clean (session-NAT log queries directly map to per-OLP-key billing).
  • Composes with Phase 7 ephemeral home (additive symlink only; no changes to Phase 7 logic).

Negative

  • ADR 0001 amendment required to redraw the state boundary. The amendment is defensible (routing state vs content state) but is constitutional surgery that future readers will scrutinize.
  • ADR 0009 amendment required to drop --no-session-persistence. Reversal of a Phase 6c decision that shipped only weeks earlier. If the spike (§ 10 open question 1) reveals that flag is required for stream-json mode, ADR 0015 is rescoped to use --print mode at the cost of regressing Phase 6c's NDJSON observability.
  • Bounded but real complexity increase: session pool, lifecycle, rotation, cwd-fix layering, ADR-0014 § requiredHomePaths extension. Phase 7 added a lot of orchestration; Phase 8 Session-NAT adds more.
  • Cost direction not guaranteed positive (§ 6.1). Worst-case scenario: heavy users see +50% per-request cost vs Phase 7 baseline due to conversation-history growth.
  • Anthropic can still classify out of "power user" pattern via other signals (per-OAuth request rate, system-prompt customization, HTTP headers). Session-NAT is one layer; full IDE-user pattern matching requires several.
  • Bridge value still uncertain per cc-mem § 9: Anthropic can update classification rules without a technical change. Session-NAT extends the bridge but does not make it durable.

Reversibility

  • Fully reversible by removing --session-id from the spawn args (revert ADR 0009 amendment) and deleting the ~/.olp/sessions/ directory. The OLP code refactor to remove the session-pool layer is ~300 LOC delete.
  • No state migration required for revert: if Session-NAT is in production and the maintainer reverts, the next request goes back to ephemeral session_id; in-flight conversation continuity is lost but no other state corrupts.
  • Per-OLP-key opt-out possible at implementation time: ~/.olp/keys/<key-id>/manifest.json could gain a session_nat: false field that disables Session-NAT for that key only. Useful for testing / debug / rollback per-tenant. Out of ADR 0015 scope but trivially additive.

Mission boundary check

ADR 0001 § Non-mission excludes (1) commercial multi-tenant SaaS, (2) generic enterprise gateway, (3) model-capability router, (4) conversation-state store. Item (4) is the one this ADR brushes against; the proposed § 4 amendment redraws the line precisely. Items 1-3 are unaffected.

The "personal- and family-scale" mission frame is preserved: Session-NAT is a quality-of-service feature that family deployments benefit from (per-key continuity + cost stability + billing-classification softening). Commercial multi-tenant trust isolation is not introduced; the recommendedDeploymentTier advisory (ADR 0014 Amendment 1 § A1.3) is unchanged.


12. Status transitions

  • 2026-05-29 — Drafted. Status: Draft (Phase 8 candidate). Spike work in § 10 required before transition to Proposed.
  • (TBD) — Spike complete, open questions resolved → Proposed. Reviewer assigned per Iron Rule 10.
  • (TBD) — Reviewer APPROVE, ADR 0001 amendment + ADR 0009 Amendment 2 + ADR 0002 Amendment 10 drafted as co-merge → Accepted.
  • (TBD) — Implementation begins (estimated 2-3 weeks based on Phase 7 baseline).

Authors: project maintainer (with AI drafting assistance, 2026-05-29). This ADR is a forward-looking design proposal triggered by user-side architectural insight (NAT-style framing) and the upcoming Anthropic billing-pool split (2026-06-15). It is not ratified by any spike-validated empirical work; § 10 open questions block transition to Proposed.