From 0ce2243dfb799335aed6fe2b7a2942c5562fbf2d Mon Sep 17 00:00:00 2001 From: chopratejas Date: Fri, 1 May 2026 23:34:46 -0700 Subject: [PATCH] docs: add Realignment plan (40 PRs, 9 phases) Comprehensive PR-by-PR plan to realign Headroom around live-zone-only compression with prefix-cache safety as a non-negotiable invariant. Drafted from a 10-agent deep audit against the LLM-proxy compression guide. - 14 documents under REALIGNMENT/ - 72 ranked bugs (P0 cache-killers through P6 test-infra) - 40 feature PRs + 10 test-infra PRs across 9 phases - ~25K LOC retirement (ICM + scoring + relevance + rolling-window + summarizer + tool-crusher + LiteLLM-fake-Bedrock) - Preserves TOIN, CCR, Kompress-base per user direction - Auth-mode policy gates (PAYG / OAuth / subscription) - Phase 3 cache stabilization surface (tool-sort, schema-sort, cache_control auto-place, prompt_cache_key) - Native Bedrock SigV4 + Vertex ADC handlers - Test infrastructure: SHA-256 byte-faithful gate, SSE corner cases, property tests, real-traffic shadow --- .claude-plugin/marketplace.json | 4 +- .github/plugin/marketplace.json | 4 +- REALIGNMENT/00-overview.md | 53 ++ REALIGNMENT/01-bug-list.md | 421 ++++++++++++++++ REALIGNMENT/02-architecture.md | 322 ++++++++++++ REALIGNMENT/03-phase-A-lockdown.md | 420 ++++++++++++++++ REALIGNMENT/04-phase-B-live-zone.md | 470 ++++++++++++++++++ REALIGNMENT/05-phase-C-rust-proxy.md | 329 ++++++++++++ REALIGNMENT/06-phase-D-bedrock-vertex.md | 222 +++++++++ REALIGNMENT/07-phase-E-cache-stabilization.md | 338 +++++++++++++ REALIGNMENT/08-phase-F-auth-mode.md | 254 ++++++++++ REALIGNMENT/09-phase-G-rtk-observability.md | 194 ++++++++ REALIGNMENT/10-phase-H-python-retirement.md | 214 ++++++++ REALIGNMENT/11-phase-I-test-infra.md | 385 ++++++++++++++ REALIGNMENT/12-decisions-needed.md | 196 ++++++++ REALIGNMENT/INDEX.md | 82 +++ .../.claude-plugin/plugin.json | 2 +- .../.github/plugin/plugin.json | 2 +- 18 files changed, 3906 insertions(+), 6 deletions(-) create mode 100644 REALIGNMENT/00-overview.md create mode 100644 REALIGNMENT/01-bug-list.md create mode 100644 REALIGNMENT/02-architecture.md create mode 100644 REALIGNMENT/03-phase-A-lockdown.md create mode 100644 REALIGNMENT/04-phase-B-live-zone.md create mode 100644 REALIGNMENT/05-phase-C-rust-proxy.md create mode 100644 REALIGNMENT/06-phase-D-bedrock-vertex.md create mode 100644 REALIGNMENT/07-phase-E-cache-stabilization.md create mode 100644 REALIGNMENT/08-phase-F-auth-mode.md create mode 100644 REALIGNMENT/09-phase-G-rtk-observability.md create mode 100644 REALIGNMENT/10-phase-H-python-retirement.md create mode 100644 REALIGNMENT/11-phase-I-test-infra.md create mode 100644 REALIGNMENT/12-decisions-needed.md create mode 100644 REALIGNMENT/INDEX.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a8a6b362a..66d7d5528 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.20.8" + "version": "0.20.11" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.20.8", + "version": "0.20.11", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a8a6b362a..66d7d5528 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.", - "version": "0.20.8" + "version": "0.20.11" }, "plugins": [ { "name": "headroom", "source": "./plugins/headroom-agent-hooks", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", - "version": "0.20.8", + "version": "0.20.11", "author": { "name": "Headroom Contributors", "url": "https://github.com/chopratejas/headroom" diff --git a/REALIGNMENT/00-overview.md b/REALIGNMENT/00-overview.md new file mode 100644 index 000000000..75576258d --- /dev/null +++ b/REALIGNMENT/00-overview.md @@ -0,0 +1,53 @@ +# 00 — Overview & Wrong Mental Model + +## Executive summary + +Headroom is built on the wrong mental model: **"compression means choosing what to drop from conversation history."** The flagship `IntelligentContextManager` (ICM) tokenizes the entire `messages` array, scores each message for importance, and removes old messages until the budget is hit. It has been wired into the Rust proxy on `/v1/messages` with `frozen_message_count: 0` hardcoded — so every compression event drops messages from index 0, busting the Anthropic prompt cache for every customer that triggers it. + +The correct mental model — confirmed by an authoritative engineering guide and ten parallel deep-audit subagents — is the opposite: **"passthrough is sacred; compress only the live zone, type-aware, hash-keyed, position-preserving, with side-channel metadata."** The cache hot zone (system prompt, tools, old turns, reasoning/thinking/redacted/compaction items) is **never** touched. + +The audit found: + +- **5 top-tier cache-killer bugs** all stemming from the wrong model +- **~10 K LOC of architectural over-build** (ICM + scoring + relevance + rolling-window + progressive-summarizer + tool-crusher + cache-aligner rewrite path + most of `crates/headroom-core/src/{context,scoring,relevance}/`) +- **Wire-format gaps** in the streaming SSE parser (missing `thinking_delta`, `signature_delta`, `citations_delta`; UTF-8-split corruption; single-`\n` SSE split bugs in fallback paths) +- **Bedrock/Vertex parity is fake** — a lossy LiteLLM Anthropic-to-OpenAI conversion drops `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use` blocks +- **No tool-definition normalization** anywhere +- **No auth-mode awareness** — PAYG, OAuth, and subscription CLIs all get the same policy and the same fingerprint-leaking re-serialization +- **`X-Headroom-*` request headers leak upstream**, plus `anthropic-beta` mutation and `OpenAI-Beta` auto-injection — fingerprint-class subscription-revocation risks +- **CCR markers** are computed but never injected into the outgoing request body in the Rust path; the `ccr_retrieve` tool flips on/off per request — busts the tools array on every state change + +## What changes + +The realignment is structured in 9 phases, 40 PRs, ~13 weeks sequential or ~8 weeks with parallel work: + +- **Phase A — Lockdown (1 week):** stop the cache bleeding immediately. Make `/v1/messages` compression a passthrough; stop mutating the system prompt; switch Python forwarders from `httpx ... json=body` (re-serializes) to `httpx ... content=raw_bytes`; honor customer-set `cache_control` markers in Rust; strip `x-headroom-*` from upstream-bound headers; pin `anthropic-beta` order and make it session-sticky; add a SHA-256 byte-faithful round-trip test. +- **Phase B — Live-zone engine (2 weeks):** delete ICM, scoring, relevance, rolling-window, progressive-summarizer, tool-crusher (~10 K LOC). Build a live-zone-only block dispatcher in Rust that runs SmartCrusher / LogCompressor / DiffCompressor / SearchCompressor / KompressCompressor on the latest user message content + latest tool_result + latest function_call_output + latest local_shell_call_output. Token-validate every compression with fallback. CCR hardens: persistent backend + always-on `ccr_retrieve` tool registration. +- **Phase C — Rust proxy paths (3 weeks):** byte-level SSE parser with full state machine; `/v1/chat/completions`, `/v1/responses` (HTTP and streaming) handlers; per-item-type passthrough preservation (V4A patches, `local_shell_call.action.command` argv, Codex `phase` field, MCP items, `compaction`). +- **Phase D — Bedrock/Vertex native (2 weeks):** delete the LiteLLM lossy converter; build native `/model/.../invoke` (AWS) and `/v1beta1/projects/.../publishers/anthropic/.../streamRawPredict` (GCP) routes with SigV4 + ADC signing. Cache fidelity restored on Bedrock/Vertex traffic. +- **Phase E — Phase 3 cache stabilization (1 week):** sort tool array deterministically; sort JSON Schema keys recursively; auto-place up to 4 `cache_control` breakpoints (Anthropic); auto-inject `prompt_cache_key` (OpenAI); volatile-content detector with customer warning (no rewrite); cache-bust drift telemetry. +- **Phase F — Auth-mode policy (1 week):** `classify_auth_mode(headers)` helper returning `payg | oauth | subscription`; per-mode compression policy gates; TOIN aggregation key extended to `(auth_mode, model_family, structure_hash)`; conditional `X-Forwarded-*` headers in Rust. +- **Phase G — RTK + observability (1 week):** extend wrap CLIs (cline, continue, goose, openhands); wire the dead `tokens_saved_rtk` field; per-invocation RTK Prometheus metrics. +- **Phase H — Python retirement (2 weeks):** delete `headroom/proxy/server.py`, all handlers, `responses_converter.py`, `memory_handler.py`, `memory_tool_adapter.py`, `batch.py`, `semantic_cache.py`, all of `headroom/transforms/*` Python (per Phase B); keep CLI wrappers, RTK installer, evals, learn, memory writers, tokenizers, TOIN. +- **Phase I — Test infra (continuous, parallel):** SHA-256 round-trip tests; SSE corner-case fixtures (UTF-8 split, ping, all delta types, `[DONE]`, mid-stream error); property tests (no-panic SSE parser, tokens-non-increasing compression); cache-hit-rate continuous metric; promote `ccr` / `log_compressor` / `cache_aligner` parity comparators from `Skipped` stubs to real; make `make test-parity` a per-PR gate. + +## Top 5 wrong assumptions + +1. **"Compression means choosing what to drop from history."** Implemented as ICM + DropByScoreStrategy + MessageScorer + relevance + scoring + rolling-window + progressive-summarizer. Fix: retire entirely; compress live-zone content only. +2. **"TOIN can influence per-request compression decisions."** `headroom/telemetry/toin.py:853-927` mutates pattern state during a call and returns hints that bias the same-input-bytes decision. Fix: strict observation-only; recommendations published between deploys. +3. **"CCR can mutate the cache hot zone (tools array, system prompt) on demand."** `headroom/ccr/tool_injection.py:302-328` only adds `ccr_retrieve` when content was compressed — tools list flips between requests. `cache_aligner.py:160-262` and `headroom/proxy/server.py:1051` rewrite the system prompt. Fix: register `ccr_retrieve` on every request; route memory injection to the live zone tail; delete the cache_aligner rewrite path. +4. **"Summarizing past turns is a strategy."** `intelligent_context.py:316-353` SUMMARIZE replaces messages with a single summary at the same position — head modification. Fix: delete; offer compaction only as an explicit customer-initiated action. +5. **"ToolCrusher operates on every tool message in history without a frozen check."** `headroom/transforms/tool_crusher.py:106` iterates all tool messages. Fix: delete; ContentRouter covers the use case correctly. + +## What's preserved + +Per your direction: +- **TOIN** (Tool Output Intelligence Network) — observation-only refactor; per-tenant key +- **CCR** (Compress-Cache-Retrieve) — persistent backend + always-on tool +- **Kompress-base** — plain-text §8.6 compressor; stays in Python now, Rust port via `ort` crate later +- **ContentRouter** — Python ~2150 LOC, the architecturally correct piece (NOTE: earlier project memory said 53 K lines — that was wrong by 25×; the file is fine) +- All per-type compressors: SmartCrusher (Rust 25 files), CodeCompressor, LogCompressor, SearchCompressor, DiffCompressor + +## What's deleted + +~25 K LOC across two languages. See [01-bug-list.md](./01-bug-list.md) §6 for the full retirement list with file:line evidence. diff --git a/REALIGNMENT/01-bug-list.md b/REALIGNMENT/01-bug-list.md new file mode 100644 index 000000000..cb1a63f79 --- /dev/null +++ b/REALIGNMENT/01-bug-list.md @@ -0,0 +1,421 @@ +# 01 — Comprehensive Bug & Gap List + +Ranked P0 (cache-killer) → P5 (long tail). Every entry has: title, file:line, evidence, guide §, fix, ROI estimate. + +Sources: 10 parallel deep-audit subagents (Rust proxy passthrough; Rust compression correctness; Python proxy + bridges; prefix cache safety; streaming + wire-format; RTK + tests/parity; over-engineering; OpenAI long-tail + Bedrock; Headroom-side injections; auth-mode handling). + +--- + +## P0 — Cache-killer smoking guns (every customer affected) + +These bugs collapse Anthropic prompt-cache hit rate toward 0% for any traffic that triggers them. Fix in Phase A. + +### P0-1. System prompt mutated by `.strip()` and memory-context append +- **File:** `headroom/proxy/server.py:1050-1058`; `headroom/proxy/handlers/openai.py:1212` +- **Evidence:** `body["system"] = (existing_system + "\n\n" + context).strip()` — strips whitespace and appends dynamic memory context to the cache hot zone on every memory-enabled call. +- **Guide:** §1.11 (whitespace fidelity), §6.3 #10 (tiny system prompt edits invalidate cache), §10.1 (system = always cache hot). +- **Fix:** Remove `_inject_system_context` path; route memory context to the **first block of the latest user message** (live zone). The existing `_append_context_to_latest_non_frozen_user_turn` already does this — make it the only path. +- **ROI:** Restores cache hits for ~all memory-enabled traffic. +- **Phase A → PR-A2.** + +### P0-2. Every Python forwarder re-serializes JSON via `httpx ... json=body` +- **File:** `headroom/proxy/server.py:1088, 1090`; `headroom/proxy/handlers/streaming.py:651`; `headroom/proxy/handlers/openai.py:2392-2397`; `headroom/proxy/handlers/batch.py:344` +- **Evidence:** httpx default encoder calls `json.dumps(body, separators=(", ", ": "), ensure_ascii=True)`. Inbound bytes use `,`/`:` and raw UTF-8 in user content; outbound bytes use `, `/`: ` and `\uXXXX` escapes. Bytes never reach upstream byte-equal to bytes that arrived. +- **Guide:** §1.9 (the single most expensive proxy mistake), §1.10 (numeric precision), §1.11 (whitespace fidelity). +- **Fix:** Switch every forwarder to `httpx ... content=raw_bytes_modified_in_place`. Keep the original `await request.body()` bytes; if a transform mutated the body, re-serialize with `separators=(",", ":")` + `ensure_ascii=False`. Better: surgical byte-fragment replacement on `messages` only, leaving the envelope's bytes untouched. +- **ROI:** Restores cache hits for **all** Python-forwarded traffic. +- **Phase A → PR-A3.** + +### P0-3. Rust proxy ignores customer `cache_control` markers +- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:151-156` +- **Evidence:** `frozen_message_count: 0` hardcoded with `TODO: detect provider prefix-cached messages from the request. Until we wire that detection, we treat the whole list as droppable.` Combined with ICM, every compression event drops messages from index 0. +- **Guide:** §2.19 (up to 4 cache_control markers), §6.2 (cache breakpoints define the prefix). +- **Fix:** Walk `messages[*].content[*].cache_control`, `system[*].cache_control`, `tools[*].cache_control`; set `frozen_message_count` to the highest message index that contains a cache_control marker. +- **ROI:** Restores cache hits for **all** clients using Anthropic prompt caching (which is virtually all production Anthropic traffic). +- **Phase A → PR-A4.** + +### P0-4. ICM compresses by dropping messages from cache hot zone (wrong scope) +- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:146-157`; `crates/headroom-core/src/context/strategy/drop_by_score.rs:64-80`; `crates/headroom-core/src/context/manager.rs`; `headroom/transforms/intelligent_context.py:354-450` +- **Evidence:** ICM with default `keep_last_turns: 2` is allowed to drop any message older than the last two turns. Combined with P0-3, this is a 100%-likely cache-buster on any conversation with ≥3 user turns. +- **Guide:** §6.5 (live zone vs hot zone), §10.1 ("Old conversation turns ... never compress"), §6.3 #11 ("Truncation/summarization at the head"), §7.2 (append-only compression). +- **Fix:** Delete ICM. Replace with live-zone-only block-level compression. Phase B builds the replacement. +- **ROI:** Eliminates the largest single class of cache-bust events. +- **Phase A → PR-A1 (stop calling ICM); Phase B → PR-B1 (delete ICM).** + +### P0-5. Numeric precision lost via `serde_json::Value` round-trip +- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:91, 172` +- **Evidence:** Body parsed into `serde_json::Value` and re-serialized via `serde_json::to_vec(&parsed)`. `Value::Number` is `i64|u64|f64` so any `1.0` round-trips to `1`; large integers above 2^53 lose precision. `Cargo.toml:34` enables `preserve_order` only — no `arbitrary_precision`, no `RawValue`. +- **Guide:** §1.10. +- **Fix:** Add `arbitrary_precision` and `raw_value` features to `serde_json`. Use `&RawValue` for `messages[*]` so individual messages forward as exact byte copies. Strategy outputs only need to be "drop this index" or "replace this block's content." +- **ROI:** Closes the second-largest re-serialization byte-drift class. +- **Phase A → PR-A4 (jointly with P0-3).** + +### P0-6. Memory tool injection toggles tools list and mutates `anthropic-beta` +- **File:** `headroom/proxy/memory_handler.py:389-398`; `headroom/proxy/handlers/anthropic.py:1147-1171` +- **Evidence:** Memory adds `memory_save`, `memory_search` tools to `body["tools"]` only when memory is enabled for the request. Mid-session config flicker → tool set changes → cache busts (§6.3 #2). Same code mutates `anthropic-beta` adding `context-management-2025-06-27` when injection happens (§6.3 #6). +- **Fix:** Make memory tool injection **session-sticky**: once injected, always inject for the lifetime of the session. Pin `anthropic-beta` order; never reorder tokens within the comma-list. +- **ROI:** Eliminates mid-session cache busts. +- **Phase A → PR-A6, PR-A7.** + +### P0-7. `responses_converter.py` drops Codex `phase` field and corrupts multi-text-part rebuild +- **File:** `headroom/proxy/responses_converter.py:94, 221-256` +- **Evidence:** `phase` field is dropped on the Chat-Completions trip (line 94 maps `role` only); only `copy.copy(original)` accidentally retains it on the rebuild path. Multi-text-part input messages get corrupted: `_extract_text_from_parts` joins with `\n`, `_reconstruct_item:254-256` puts the concatenated text into the first part only and leaves parts 1..N as-is, doubling content. +- **Guide:** §4.5 (preserve `phase` exactly), §7.9 (position preservation). +- **Fix:** Stash `phase` and restore in `_reconstruct_item`. Rebuild text parts by index, replacing each part's text in place. Better: in Phase C, port `/v1/responses` to Rust and never decompose item structure for compression. +- **Phase A → PR-A8 (Python hotfix), Phase C → PR-C5 (full rebuild).** + +--- + +## P1 — Wire-format / streaming corruption + +### P1-8. SSE buffers decoded with `errors="ignore"` / `errors="replace"` +- **File:** `headroom/proxy/handlers/streaming.py:58, 772`; `headroom/ccr/response_handler.py:672` +- **Evidence:** `chunk.decode("utf-8", errors="ignore")` silently drops emoji/CJK bytes split across TCP reads. The wire passthrough at `streaming.py:788` is bytes (correct), but every `_parse_sse_usage_from_buffer` and `_parse_sse_to_response` decision is made on a string that may have lost bytes. +- **Guide:** §1.4 (UTF-8 multi-byte split across chunks). +- **Fix:** Bytes-level buffer; find `\n\n` boundary in bytes; decode each complete event after split. +- **Phase C → PR-C1 (Rust SSE parser); Phase A → PR-A8 includes a Python hotfix.** + +### P1-9. SSE parser misses `thinking_delta`, `signature_delta`, `citations_delta` +- **File:** `headroom/proxy/handlers/streaming.py:213-298` +- **Evidence:** Only `text_delta` and `input_json_delta` are switched on (lines 268-271). Thinking blocks reconstructed without text or signature; signature-protected blocks rejected on replay. +- **Guide:** §2.5, §2.7, §5.1 transitions table. +- **Fix:** Add all delta-type arms. In Rust SSE parser (Phase C), implement guide §5.1 fully. +- **Phase A → PR-A8 (Python); Phase C → PR-C1 (Rust).** + +### P1-10. Memory continuation re-emitter emits whole `partial_json` in one delta +- **File:** `headroom/proxy/handlers/streaming.py:300-391` (`_response_to_sse`) +- **Evidence:** `"partial_json": json.dumps(block["input"])` (line 366-374) emits the entire input JSON as a single delta — clients accumulating per-spec receive one giant fragment instead of an incremental stream. Tool IDs are fabricated as `f"toolu_{idx}"` (line 345). Thinking blocks dropped entirely. +- **Guide:** §2.6. +- **Fix:** Either delete this function (do memory continuation as non-streaming retry) or rewrite to spec. +- **Phase B → PR-B6 (memory injection refactor likely deletes it).** + +### P1-11. LiteLLM bridge fabricates `toolu_` when upstream `tc.id` missing +- **File:** `headroom/backends/litellm.py:860` +- **Evidence:** `tool_id = tc.id or f"toolu_{uuid.uuid4().hex[:24]}"`. If upstream omits `id` on chunk 1, fake ID is generated and the upstream tool_call_id is lost forever; next turn `tool_result` references the fake ID and pairing breaks. +- **Guide:** §3.5, §2.10. +- **Fix:** Drop the fallback; surface an error if `tc.id` is None on first appearance. +- **Phase D → PR-D1 deletes this whole file.** + +### P1-12. OpenAI WS→HTTP fallback uses single-`\n` SSE split +- **File:** `headroom/proxy/handlers/openai.py:2422-2447` +- **Evidence:** `aiter_text()` decodes UTF-8 chunk-by-chunk → `buffer.split("\n", 1)` instead of `\n\n`. Multi-line `data:` payloads get wrong-split. +- **Fix:** Switch to `aiter_bytes()` + bytes-level `\n\n` boundary. +- **Phase C → PR-C3 ports this surface to Rust.** + +### P1-13. Re-serialization in Rust path even when no body fields mutated +- **File:** `crates/headroom-proxy/src/compression/anthropic.rs:90-188` +- **Evidence:** When `should_apply` is true and ICM doesn't drop anything (`anthropic.rs:162-168`), the function correctly returns `NoCompression` and forwards original bytes. But the `Compressed` path always re-serializes via `serde_json::to_vec(&parsed)` — even if only one message changed, every retained message gets re-encoded through `Value`. +- **Guide:** §1.12. +- **Fix:** Use `RawValue` for retained `messages[*]` entries; only the modified message gets re-encoded. +- **Phase A → PR-A4 / Phase B → PR-B2 (live-zone replacement).** + +### P1-14. Mid-stream `error` events not handled (Anthropic + OpenAI) +- **File:** `headroom/proxy/handlers/streaming.py:160-211, 213-298` +- **Evidence:** No `event_type == "error"` arm. The wire passthrough is byte-faithful (good) but Headroom's bookkeeping (`stream_state.input_tokens` etc.) silently doesn't reflect the failure; `_finalize_stream_response` reports a clean PERF line for an errored stream. +- **Guide:** §1.7, §2.21. +- **Fix:** Add `error` handling to telemetry. +- **Phase C → PR-C1 (Rust SSE).** + +### P1-15. Connection drop without `message_stop`/`[DONE]` not surfaced +- **File:** `headroom/proxy/handlers/streaming.py:899` (`finally: await self._finalize_stream_response`) +- **Evidence:** The `finally` runs but there's no flag indicating the stream was truncated; logs report a PERF line as if it succeeded. +- **Guide:** §1.8. +- **Fix:** Track terminator-seen flag; emit truncation telemetry when missing. +- **Phase C → PR-C1.** + +### P1-16. OpenAI `refusal` field on Chat assistant message not handled +- **File:** None (zero references) +- **Evidence:** Memory and tool-call extraction look only at `message.content` / `tool_calls`; refusal turns silently look like content==null with output_tokens=0. +- **Guide:** §3.7. +- **Fix:** Inspect `refusal` field; surface in telemetry. +- **Phase C → PR-C2 (Rust /v1/chat/completions).** + +### P1-17. `current_block: Optional[dict]` instead of `blocks: HashMap` +- **File:** `headroom/proxy/handlers/streaming.py:227, 700` +- **Evidence:** Anthropic emits one block at a time today, but the guide explicitly says "track blocks by `index`" — current code captures `index` and never uses it as a key. +- **Guide:** §2.4, §5.1. +- **Fix:** Index-keyed map. +- **Phase C → PR-C1.** + +--- + +## P2 — Architectural over-build + +### P2-18. ICM-as-history-dropper (the structural mismatch) +- **Files:** `headroom/transforms/intelligent_context.py`; `crates/headroom-core/src/context/manager.rs`; `crates/headroom-proxy/src/compression/icm.rs` +- **Status:** Delete in Phase B (PR-B1). + +### P2-19. `RollingWindow`, `ProgressiveSummarizer` (head-truncation strategies) +- **Files:** `headroom/transforms/rolling_window.py` (395 LOC); `headroom/transforms/progressive_summarizer.py` (508 LOC) +- **Guide:** §6.3 #11, §6.4 (compaction is the explicit exception, intended to break cache once). +- **Status:** Delete in Phase B (PR-B1). + +### P2-20. `MessageScorer`, `scoring/`, `relevance/` machinery +- **Files:** `crates/headroom-core/src/scoring/{scorer,score,weights,traits,mod}.rs` (~1500 LOC); `crates/headroom-core/src/relevance/{embedding,bm25,hybrid,base,mod}.rs` (~1600 LOC); `headroom/transforms/scoring.py` (459 LOC) +- **Evidence:** Sole consumer is `DropByScoreStrategy::try_fit`. Without ICM, no consumer. +- **Status:** Delete in Phase B (PR-B1). MessageScorer Rust port (PR #338, #343) becomes wasted work. + +### P2-21. `crates/headroom-core/src/context/` — except `safety.rs` +- **Files:** `crates/headroom-core/src/context/{config,workspace,candidate,ccr_drop,manager,strategy/}.rs` (~1500 LOC) +- **Status:** Delete in Phase B (PR-B1). `safety.rs` (tool-pair atomicity) is moved to `crates/headroom-core/src/transforms/safety.rs` and kept. + +### P2-22. `ToolCrusher` operates without `frozen_message_count` +- **File:** `headroom/transforms/tool_crusher.py:106` +- **Evidence:** Iterates all result_messages, no frozen check. Crushes any tool message above token threshold regardless of position. +- **Guide:** §10.1 (old tool results are cache-hot). +- **Status:** Delete in Phase B (PR-B1). ContentRouter covers the use case correctly. + +### P2-23. `CacheAligner` rewrite path violates the very thing it claims to stabilize +- **File:** `headroom/transforms/cache_aligner.py:160-262` +- **Evidence:** Strips dynamic content from system prompt and re-inserts as a context block — mutates the cache hot zone. Currently `enabled=False` in `server.py:299`. +- **Guide:** §9.3. +- **Fix:** Delete the rewrite path (~400 LOC); keep detector + customer warning (~140 LOC). +- **Phase A → PR-A2 includes the deletion.** + +### P2-24. Memory-handler injection at request lifecycle entry +- **File:** `headroom/proxy/memory_handler.py:498-510`; `headroom/proxy/handlers/openai.py:535-540` +- **Evidence:** Prepends a system message with retrieved memories on every turn. Retrieval is non-deterministic (vector store grows turn-to-turn). +- **Fix:** Move retrieval out of the request lifecycle; treat as an explicit customer-invoked tool. +- **Phase B → PR-B6 (memory refactor).** + +### P2-25. CCR `ccr_retrieve` tool injected only when content was compressed +- **File:** `headroom/ccr/tool_injection.py:302-328` +- **Evidence:** `inject_tool_definition()` only adds the tool when `has_compressed_content` is true. Tool list size flips between requests. +- **Guide:** §6.3 #2 (tool list reordering). +- **Fix:** Inject `ccr_retrieve` on **every** request once a session has ever done CCR; or always inject for sessions that have CCR enabled. +- **Phase B → PR-B7.** + +### P2-26. CCR markers computed but never injected into outgoing body in Rust path +- **File:** `crates/headroom-core/src/context/manager.rs:172-185`; `crates/headroom-proxy/src/proxy.rs:285` +- **Evidence:** `markers_inserted` is logged but never written into the body. The model is never told about dropped messages or about `ccr_retrieve`. +- **Guide:** §7.3 (reversibility). +- **Fix:** Once Phase B replaces ICM, CCR-on-live-zone-content writes the marker into the block content as a side-channel. Phase B PR-B7. + +### P2-27. TOIN influences per-request decisions +- **File:** `headroom/telemetry/toin.py:853-927` +- **Evidence:** `get_recommendation()` consults pattern stats and returns hints that bias compression decisions; `pattern.observations += 1` mutates state during the call. +- **Guide:** §7.1, §11.17, §11.18. +- **Fix:** Strict observation-only. Recommendations published at deploy time, never altered request-time. +- **Phase B → PR-B5.** + +--- + +## P3 — Missing infrastructure (Phase 3 cache stabilization) + +### P3-28. No tool-array deterministic sort in Rust path +- **File:** Missing entirely in `crates/headroom-proxy/` +- **Evidence:** Python sorts at `handlers/anthropic.py:1198, 1217, 2041, 2118`; Rust does not. +- **Guide:** §8.5, §9.11. +- **Phase E → PR-E1.** + +### P3-29. JSON Schema keys never sorted recursively +- **File:** None — `_sort_tools_deterministically` only sorts the tools array, not their `input_schema` contents. +- **Guide:** §8.5. +- **Phase E → PR-E2.** + +### P3-30. No `prompt_cache_key` auto-injection +- **Evidence:** Zero references in the codebase. +- **Guide:** §4.17. +- **Phase E → PR-E4.** + +### P3-31. No `cache_control` auto-placement (Anthropic) +- **Evidence:** `cache_control` only mentioned in stripping for hashing (`helpers.py:295-304`) and pass-through (`server.py:1053`). +- **Guide:** §2.19, §6.2. +- **Phase E → PR-E3.** + +### P3-32. No volatile-content detector + warning +- **Evidence:** `cache_aligner` has detection but rewrites instead of warning. +- **Guide:** §9.3. +- **Phase E → PR-E5.** + +### P3-33. No per-block token validation with fallback +- **Evidence:** Compression acceptance is `bytes_saved > 0` (`crates/headroom-core/src/transforms/pipeline/orchestrator.rs:158-165`); ICM aggregate-checks tokens (`anthropic.rs:162-168`) but per-block transforms don't. +- **Guide:** §7.5, §11.15, §11.20. +- **Phase B → PR-B4.** + +### P3-34. No per-content-type byte thresholds +- **Evidence:** Threshold gating is by ratio (`bloat_threshold=0.5`) not by bytes (code>2KB, JSON>1KB, logs>500B, plain text>5KB per guide §7.6). +- **Phase B → PR-B4.** + +### P3-35. No cache-bust drift detector telemetry +- **Evidence:** No prefix-hash drift detection across requests. +- **Phase E → PR-E6.** + +### P3-36. No shared content-hash cache across customers (Phase 4) +- **Evidence:** `CompressionCache` is per-session, per-worker. +- **Status:** Out of scope for this realignment; queued for Phase 4 of the guide. + +--- + +## P4 — OpenAI long-tail + Bedrock/Vertex + +### P4-37. **Bedrock support is fake — lossy LiteLLM converter** +- **File:** `headroom/backends/litellm.py:486-628` +- **Evidence:** `_convert_messages_for_litellm` switch covers only `text` / `tool_use` / `tool_result`; drops `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use`. Response converter hardcodes `"stop_sequence": None` (line 626) — §11.1 violation. Function-call arguments parsed and rewrapped (line 600) — string fidelity broken (§4.4). +- **Phase D → PR-D1, D2, D3 rebuild natively.** + +### P4-38. Vertex same lossy converter +- **File:** Same — `headroom/backends/litellm.py` +- **Phase D → PR-D4 builds native Vertex.** + +### P4-39. No native Bedrock/Vertex paths in Rust +- **File:** `crates/headroom-proxy/src/compression/mod.rs:50` only matches `/v1/messages`. +- **Phase D → PR-D1-D4.** + +### P4-40. `/v1/conversations` blind spot (§4.14) +- **Evidence:** Zero references. Server-side prepended items invisible to Headroom; tokenizer count over-reports. +- **Phase C → PR-C4.** + +### P4-41. `service_tier` never logged or surfaced +- **Guide:** §4.12. +- **Phase G → PR-G3 (observability).** + +### P4-42. `incomplete`, `failed`, `cancelled` statuses never surfaced +- **Guide:** §4.10. +- **Phase C → PR-C3 / C4.** + +### P4-43. `function_call.arguments` parsed-and-rewrapped in 2 places +- **File:** `headroom/backends/litellm.py:600`; `headroom/learn/plugins/codex.py:283` +- **Guide:** §4.4. +- **Phase D → PR-D1 deletes litellm.py; learn plugin moved to read-only.** + +### P4-44. `phase` field "accidentally preserved" via `copy.copy(original)` +- **File:** `headroom/proxy/responses_converter.py:94, 235` +- **Status:** Already covered by P0-7. **Phase A → PR-A8** (hotfix), **Phase C → PR-C5** (full rebuild). + +### P4-45. `image_generation_call` no log redaction +- **File:** `headroom/proxy/request_logger.py` — no base64/image redaction +- **Guide:** §11.6. +- **Phase G → PR-G3 includes a redaction step.** + +### P4-46. `Cargo.toml` missing `arbitrary_precision` + `raw_value` features on `serde_json` +- **File:** `Cargo.toml:34` +- **Phase A → PR-A4 enables them.** + +### P4-47. Apply patch V4A, local_shell_call argv, MCP items, compaction items only "accidentally" preserved +- **File:** `headroom/proxy/responses_converter.py:99` — "Unknown item type: preserve" +- **Evidence:** Survives only because the catch-all is conservative. No log line, no test. One refactor away from silent data loss. +- **Phase A → PR-A8 adds a warning log; Phase C → PR-C5 makes it explicit.** + +### P4-48. No SSE parser in Rust at all +- **Status:** Phase 1 of the Rust proxy was passthrough; Phase C builds the parser. +- **Phase C → PR-C1.** + +--- + +## P5 — Auth-mode + observability + fingerprinting + +### P5-49. `X-Headroom-*` request headers leak upstream +- **File:** `headroom/proxy/handlers/anthropic.py:526` — `dict(request.headers.items())` captured unmodified, no strip step before `httpx.post(headers=headers)`. +- **Risk:** Subscription-revocation fingerprint. +- **Phase A → PR-A5.** + +### P5-50. `anthropic-beta` mutated when memory enabled, not session-sticky +- **File:** `headroom/proxy/handlers/anthropic.py:1162-1168` +- **Status:** Already covered by P0-6. **Phase A → PR-A6, A7.** + +### P5-51. `OpenAI-Beta` auto-injection on WS path +- **File:** `headroom/proxy/handlers/openai.py:1566-1567` +- **Risk:** OAuth scope rejection if scope doesn't grant the auto-injected beta. +- **Phase F → PR-F2 (gate by mode).** + +### P5-52. `accept-encoding` stripped — fingerprint signal +- **File:** `handlers/anthropic.py:533`, `handlers/openai.py:264` +- **Risk:** Real Claude Code negotiates compression; stripping reveals the proxy. +- **Phase F → PR-F2 (preserve when subscription mode).** + +### P5-53. `X-Forwarded-*` always added by Rust proxy +- **File:** `crates/headroom-proxy/src/headers.rs:103-117` +- **Phase F → PR-F4 (conditional on auth mode).** + +### P5-54. Subscription tracker stores raw OAuth bearer token in process memory +- **File:** `headroom/subscription/tracker.py:166` +- **Risk:** Core dump or debugger attach exposes the token. +- **Phase F → PR-F3 hardens (hash + only the ID, not the token).** + +### P5-55. Auth-mode never drives compression policy +- **Evidence:** Single policy applied to all three modes today. +- **Phase F → PR-F1 (`classify_auth_mode`), PR-F2 (gates).** + +### P5-56. TOIN aggregates globally by `structure_hash` only +- **File:** `headroom/telemetry/toin.py:477, 496` +- **Risk:** Cross-tenant pattern leakage. +- **Phase F → PR-F3 changes key to `(auth_mode, model_family, structure_hash)`.** + +### P5-57. Upstream `request-id` not captured in logs +- **File:** `crates/headroom-proxy/src/proxy.rs:355-358, 377-383` +- **Guide:** §11.10. +- **Phase A → PR-A8 (telemetry capture in Python); Phase C carries forward to Rust.** + +### P5-58. Rate-limit headers forwarded but never observed +- **File:** `crates/headroom-proxy/src/headers.rs:126-139` +- **Guide:** §11.9. +- **Phase G → PR-G3 (Prometheus metric).** + +### P5-59. Body size cap returns wrong status code (400 instead of 413) +- **File:** `crates/headroom-proxy/src/proxy.rs:243-263` +- **Phase A → PR-A8 fix, low priority.** + +### P5-60. `tokens_saved_rtk` field is dead (allocated, never populated) +- **File:** `headroom/subscription/models.py:260`; `headroom/subscription/tracker.py:173` +- **Phase G → PR-G2.** + +### P5-61. RTK never invoked from proxy (correct posture; document explicitly) +- **Status:** Per audit recommendation (Agent F): proxy-side invocation is wrong; cache hot zone risk + parallel impl with `log_compressor.rs`. Document explicitly so future contributors don't add it. +- **Phase G → PR-G1, G3.** + +### P5-62. Wrap CLIs missing for cline, continue, goose, openhands, devin-style CLIs +- **Files:** `headroom/cli/wrap.py` — only Claude/Codex/Aider/Copilot/Cursor today. +- **Phase G → PR-G1.** + +--- + +## P6 — Test-infra & parity + +### P6-63. No SHA-256 byte-faithful round-trip test on recorded production payload +- **Phase A → PR-A8.** + +### P6-64. `ccr`, `log_compressor`, `cache_aligner` parity comparators are `Skipped` stubs +- **File:** `crates/headroom-parity/src/lib.rs:172-174` +- **Phase I (parallel) — promote stubs to real comparators.** + +### P6-65. `make test-parity` not a per-PR gate +- **File:** `.github/workflows/rust.yml:125-149` — nightly only, `continue-on-error: true` +- **Phase I — make per-PR; `Diff` fails build, `Skipped` allowed.** + +### P6-66. No SSE corner-case fixtures (UTF-8 split, ping, all delta types, [DONE], mid-stream error) +- **Phase I — record fixtures during Phase C work.** + +### P6-67. No real-traffic shadow test comparing Python vs Rust output byte-for-byte +- **Phase I — implement during Phase C.** + +### P6-68. No per-session cache-hit-rate metric +- **File:** `headroom/proxy/prometheus_metrics.py` — only aggregate by provider +- **Phase G → PR-G3.** + +### P6-69. No per-block compression-ratio histogram (only invocation count) +- **Phase G → PR-G3.** + +### P6-70. No token-validation rejection counter +- **Phase B → PR-B4 emits the metric.** + +### P6-71. WS-handshake `OpenAI-Beta` injection un-tested for OAuth-scope rejection paths +- **Phase I — record a fixture.** + +### P6-72. Wrap E2E uses an `rtk` shim that just exits 0 (`e2e/wrap/run.py:250-267`) — doesn't exercise real RTK +- **Phase I — replace shim with a containerized real RTK or assert-on-shim-only-in-CI flag.** + +--- + +## Summary table + +| Priority | Count | Location | +|---|---:|---| +| P0 (cache-killer) | 7 | Phase A | +| P1 (wire-format) | 10 | Phase A + Phase C | +| P2 (over-build) | 10 | Phase B | +| P3 (missing Phase 3) | 9 | Phase E | +| P4 (long-tail + Bedrock) | 12 | Phase C + Phase D | +| P5 (auth + obs + fingerprint) | 14 | Phase F + Phase G | +| P6 (test infra) | 10 | Phase I (parallel) | +| **Total** | **72** | — | diff --git a/REALIGNMENT/02-architecture.md b/REALIGNMENT/02-architecture.md new file mode 100644 index 000000000..a158010c3 --- /dev/null +++ b/REALIGNMENT/02-architecture.md @@ -0,0 +1,322 @@ +# 02 — Realigned Target Architecture + +The Rust-only proxy after Phase H. Each subsystem documented with its scope, invariants, file layout, and what it explicitly does NOT do. + +--- + +## 2.1 Request lifecycle (Rust, post-Phase-C) + +``` + Client request + │ + ▼ + ┌──────────────────────────────────────────────┐ + │ headroom-proxy (axum) │ + │ │ + │ 1. classify_auth_mode(headers) │ ← Phase F + │ → "payg" | "oauth" | "subscription" │ + │ │ + │ 2. strip x-headroom-* from upstream-bound │ ← Phase A (PR-A5) + │ │ + │ 3. byte-buffer body via RawValue │ ← Phase A (PR-A4) + │ (numeric precision preserved) │ + │ │ + │ 4. honor cache_control markers │ ← Phase A (PR-A4) + │ → frozen_message_count │ + │ │ + │ 5. live_zone_compress(body, frozen_count, │ ← Phase B + │ auth_mode) │ + │ ├─ identify live-zone blocks │ + │ ├─ per-block content-type detection │ + │ ├─ dispatch to type-aware compressor │ + │ ├─ token-validate; fallback to original │ + │ ├─ CCR: hash-key, store, marker │ + │ └─ replace block bytes in-place │ + │ │ + │ 6. tool_def_normalize(body) │ ← Phase E (PR-E1, E2) + │ ├─ alpha-sort tools[] │ + │ └─ recursive-sort JSON Schema keys │ + │ │ + │ 7. cache_control_auto_place(body) │ ← Phase E (PR-E3) + │ (Anthropic; up to 4 ephemeral) │ + │ │ + │ 8. prompt_cache_key_inject(body) │ ← Phase E (PR-E4) + │ (OpenAI; only if not customer-set) │ + │ │ + │ 9. forward via reqwest with original bytes │ + │ for unmodified envelope (RawValue diff) │ + │ │ + │ 10. SSE response: byte-level state machine │ ← Phase C (PR-C1) + │ ├─ track blocks/items by id │ + │ ├─ all delta types handled │ + │ ├─ mid-stream error/ping/drop surfaced │ + │ └─ pure passthrough to client │ + │ │ + │ 11. usage telemetry (cache_read, │ ← Phase G + │ cache_creation, output_tokens, etc.) │ + └──────────────────────────────────────────────┘ + │ + ▼ + Upstream provider +``` + +--- + +## 2.2 The cache-safety invariants (every PR enforces) + +### Invariant I1 — Byte-faithful passthrough on unmutated bytes +For every request, the bytes sent to upstream are byte-equal (SHA-256) to the bytes received from the client, **modulo only the byte ranges that a transform explicitly modified**. No re-serialization through a `Value` type. No JSON-prettifier whitespace insertion. No `\uXXXX` ASCII escaping of UTF-8 user content. + +**Implementation:** `serde_json::value::RawValue` for `messages[*]` entries; modified messages get fresh serialization, retained messages forward as exact byte copies. Workspace `Cargo.toml` adds `arbitrary_precision` + `raw_value` features. + +**Test gate:** `proxy_byte_faithful_anthropic_sha256` — record a real Anthropic `/v1/messages` payload, send it through the proxy with compression off, assert SHA-256 byte-equal at the upstream mock. + +### Invariant I2 — Cache hot zone never modified +The following are never mutated by Headroom: +- `system` (string or block list) +- `tools[*]` (other than alpha-sorting and JSON Schema key sorting in Phase E — both deterministic) +- Any message at index < `frozen_message_count` +- Reasoning items with `encrypted_content` +- Thinking blocks with `signature` +- `redacted_thinking.data` +- Compaction items (`{"type": "compaction", "encrypted_content": ...}`) + +**Implementation:** `live_zone_compress` walks `messages` from the tail, identifies live-zone blocks (latest user message, latest tool_result, latest function_call_output, latest local_shell_call_output, latest apply_patch_call_output), and ONLY modifies bytes within those blocks. + +**Test gate:** `cache_hot_zone_unchanged_under_compression` — fixture with system + tools + 5 historical turns + new tool_result; assert system + tools + first 5 turns bytes equal at upstream. + +### Invariant I3 — Append-only +Once a message has appeared in any prior request to upstream, its bytes are frozen. Compression operates on the live zone (latest turn) only. + +**Implementation:** `frozen_message_count` is the floor; any compressor that touches index < `frozen_message_count` is rejected at compile time (Rust trait constraint) or runtime (Python assertion). + +**Test gate:** `append_only_invariant_under_recompression` — same input bytes through the compressor twice produces byte-equal output; retained messages are byte-equal across the two runs. + +### Invariant I4 — Determinism +For the same `(input bytes, frozen_count, auth_mode)`, the compressor produces byte-equal output. No timestamps, no random seeds, no time-dependent decisions. + +**Implementation:** +- TOIN is observation-only (Phase B PR-B5); it never alters request-time decisions. +- All hashing is BLAKE3 / SHA-256 with stable input ordering. +- Sort orders are explicit (`BTreeMap` for output, never `HashMap`). +- No `Instant::now()` in any compression code path. + +**Test gate:** Property test — for arbitrary valid input, `compress(input) == compress(compress(input).original)` (idempotence on already-compressed); `compress(input) == compress(input)` (run-to-run determinism). + +### Invariant I5 — Token-aware, not byte-aware +Every compression is validated post-compression with a tokenizer. If `compressed.tokens >= original.tokens`, the original is forwarded. + +**Implementation:** Phase B PR-B4. Per-content-type byte thresholds: code>2KB, JSON>1KB, logs>500B, plain text>5KB. Below threshold = no compression attempted (overhead exceeds savings). + +**Test gate:** `proptest_compression_token_count_non_increasing` — for arbitrary valid inputs from a strategy, `tokens(output) ≤ tokens(input)`. + +### Invariant I6 — Position-preserving +Compression never reorders blocks within a content array, never splits one block into multiple, never adds inline metadata fields to existing blocks. + +**Implementation:** Compressor signature is `fn(block: &mut Block) -> Result<()>` — operates in place. Block type, `tool_use_id` / `call_id`, `is_error`, all sibling fields preserved. + +**Side-channel metadata:** A separate marker block (text-type, sibling) carries CCR retrieval directives. Never an extra field on the original block. + +### Invariant I7 — Tool definitions normalized, not compressed +Tools are sorted alphabetically by name; JSON Schema keys are sorted recursively; description whitespace is normalized. The bytes of each tool definition's `input_schema.properties[*].description` are otherwise preserved. + +**Implementation:** Phase E PR-E1, PR-E2. + +### Invariant I8 — `signature`, `encrypted_content`, `redacted_thinking.data` are sacrosanct +These are passthrough only. Never inspected, never decoded, never transformed. + +**Implementation:** Compressor block-type dispatch has explicit no-op arms for these types. The Bedrock/Vertex native paths (Phase D) preserve them unlike the LiteLLM converter. + +### Invariant I9 — TOIN observes, never mutates request bytes +TOIN's pattern stats grow across requests. Recommendations are published to disk between deploys. The compressor reads recommendations at startup, not per-request. + +**Implementation:** Phase B PR-B5. TOIN's in-memory state writes are append-only; reads never block compression. + +### Invariant I10 — Auth mode gates compression policy +PAYG: aggressive (full live-zone compression, CCR, tool injection, Phase 3 stabilization). OAuth: passthrough-prefer (live-zone lossless only, no auto-`cache_control`, no auto-`prompt_cache_key`, no `X-Forwarded-*`). Subscription: stealth-prefer (everything OAuth does PLUS preserve `accept-encoding`, never inject `X-Headroom-*` upstream, never mutate `User-Agent`). + +**Implementation:** Phase F PR-F1, PR-F2. + +--- + +## 2.3 The compressor module layout (post-Phase-B) + +``` +crates/headroom-core/src/ +├── lib.rs # public surface +├── tokenizer/ # KEEP (HF + tiktoken impls) +│ ├── mod.rs +│ ├── hf_impl.rs +│ ├── tiktoken_impl.rs +│ ├── estimator.rs +│ └── registry.rs +├── ccr.rs # KEEP, hardened (persistent backend) +├── signals/ # KEEP — drives live-zone consumers +│ ├── mod.rs +│ ├── line_importance.rs +│ ├── keyword_detector.rs +│ └── tiered.rs +├── transforms/ # the compressors +│ ├── mod.rs +│ ├── safety.rs # MOVED from context/safety.rs (Phase B) +│ ├── live_zone.rs # NEW — live-zone block dispatcher (Phase B) +│ ├── content_detector.rs # KEEP +│ ├── detection.rs # KEEP +│ ├── magika_detector.rs # KEEP +│ ├── unidiff_detector.rs # KEEP +│ ├── adaptive_sizer.rs # KEEP +│ ├── anchor_selector.rs # KEEP +│ ├── tag_protector.rs # KEEP +│ ├── log_compressor.rs # KEEP +│ ├── search_compressor.rs # KEEP +│ ├── diff_compressor.rs # KEEP +│ ├── kompress_compressor.rs # NEW — Phase H Rust port via `ort` crate +│ ├── smart_crusher/ # KEEP (25 files, correctly scoped) +│ └── pipeline/ # SHRUNK — only the live-zone orchestrator +│ ├── mod.rs +│ ├── orchestrator.rs # rewrite to live-zone-only +│ ├── traits.rs # LosslessTransform / LossyTransform +│ └── offloads/ # KEEP — JSON, log, search, diff offloads +└── auth_mode.rs # NEW — Phase F (classify_auth_mode helper) + +# DELETED in Phase B: +# context/ ← except safety.rs which moved +# scoring/ +# relevance/ +``` + +``` +crates/headroom-proxy/src/ +├── lib.rs +├── main.rs +├── config.rs +├── error.rs +├── proxy.rs # Phase A: pure passthrough on /v1/messages + # Phase C: + /v1/chat/completions, /v1/responses +├── headers.rs # Phase F: conditional X-Forwarded-* +├── websocket.rs # Phase C: WS Codex flow +├── sse/ # NEW — Phase C +│ ├── mod.rs +│ ├── parser.rs # byte-level state machine +│ ├── anthropic.rs # 4-event dance + delta types +│ ├── openai_chat.rs # tool_call accumulation +│ └── openai_responses.rs # output items + reasoning summary +├── compression/ +│ ├── mod.rs # routing by path × auth_mode +│ ├── live_zone_anthropic.rs # NEW (Phase B) +│ ├── live_zone_openai.rs # NEW (Phase C) +│ ├── tool_def_normalize.rs # NEW (Phase E) +│ ├── cache_control.rs # NEW (Phase E) +│ └── model_limits.rs # KEEP +├── bedrock/ # NEW — Phase D +│ ├── mod.rs +│ ├── sigv4.rs +│ ├── invoke.rs +│ └── eventstream.rs +├── vertex/ # NEW — Phase D +│ ├── mod.rs +│ ├── adc.rs +│ └── stream_raw_predict.rs +└── observability/ # NEW — Phase G + ├── mod.rs + ├── prometheus.rs + ├── cache_hit_rate.rs + └── compression_ratio.rs + +# DELETED: +# compression/icm.rs ← Phase A PR-A1 +# compression/anthropic.rs ← Phase A PR-A1 (replaced with live_zone_anthropic.rs in Phase B) +``` + +--- + +## 2.4 The auth-mode policy matrix (Phase F) + +| Policy aspect | PAYG | OAuth | Subscription | +|---|---|---|---| +| Live-zone compression | aggressive | lossless-only | lossless-only | +| CCR enabled | yes | yes | yes (long-session) | +| Tool def alpha-sort | yes | yes | yes | +| JSON Schema key sort | yes | yes | yes | +| Auto `cache_control` placement | yes | NO (could void scope) | NO | +| Auto `prompt_cache_key` injection | yes (OpenAI) | NO | NO | +| `anthropic-beta` mutation | NO | NO | NO | +| `X-Headroom-*` upstream | NO | NO | NO | +| `X-Forwarded-*` upstream | yes | yes | NO | +| `User-Agent` rewrite | NO | NO | NO | +| `accept-encoding` strip | OK | OK | NO (preserve) | +| Lossy compressors (LLMLingua) | OK | NO | NO | +| Memory injection | live-zone tail | live-zone tail (gated) | live-zone tail (gated) | +| TOIN aggregation key | (mode, model) | (mode, model) | (mode, model) | +| `Authorization` log redaction | first 12 chars | first 12 chars | first 12 chars | + +--- + +## 2.5 Preserved primitives detail + +### TOIN (post-Phase-B-PR-B5) + +```rust +// Strict observation-only. +pub trait Telemetry { + fn record_compression( + &self, + auth_mode: AuthMode, + model: ModelFamily, + structure_hash: StructureHash, + outcome: CompressionOutcome, + ); + // No request-time hint API. Period. +} + +// Recommendations published between deploys via: +// $ cargo run -p headroom-toin-publish -- --auth-mode payg --model claude-3-7-sonnet +// Output: recommendations.toml committed to repo, loaded by compressor at startup. +``` + +### CCR (post-Phase-B-PR-B7) + +```rust +pub trait CcrStore: Send + Sync { + fn put(&self, hash: ContentHash, original: Bytes, ttl: Duration) -> Result<()>; + fn get(&self, hash: ContentHash) -> Result>; + fn purge_expired(&self) -> usize; +} + +pub struct SqliteCcrStore { ... } // primary backend +pub struct RedisCcrStore { ... } // optional, for multi-worker + +// `ccr_retrieve` tool registered on every request for sessions that ever did CCR. +// Marker injection format: `<>` appended to compressed block content. +// Markers are deterministic (hash is content-addressed); replay-safe. +``` + +### Kompress-base (post-Phase-H-PR-H4 Rust port) + +```rust +// Plain-text §8.6 compressor. Used only as a last resort, only on live-zone +// user-message text exceeding 5KB. +pub struct KompressCompressor { + // ONNX runtime via `ort` crate. Model deterministic for fixed weights. + session: ort::Session, + threshold_bytes: usize, +} + +impl LossyTransform for KompressCompressor { ... } +``` + +--- + +## 2.6 What this architecture explicitly does NOT do + +- Does NOT drop messages from history. Ever. ICM is gone. +- Does NOT modify `system`, `tools`, or any old turn. +- Does NOT inject Headroom's own tools into customer prompts unless CCR has already fired in this session (and then always, never toggling). +- Does NOT consult TOIN at request time. Recommendations are loaded at startup only. +- Does NOT shell out to RTK from the proxy. RTK lives on the wrap-CLI side (project-decided 2026-05-01). +- Does NOT translate Anthropic ↔ OpenAI shapes. Each provider has its own native handler. Bedrock and Vertex have native envelopes (Phase D). +- Does NOT compress on `/v1/responses/compact` or `/v1/conversations` (different shapes; passthrough only). +- Does NOT rewrite request headers except to strip `x-headroom-*` from upstream-bound headers and add conditional `X-Forwarded-*` (PAYG/OAuth only). +- Does NOT add `User-Agent` headers. The customer's UA passes through verbatim. +- Does NOT compress images, base64 blobs, or audio (out of scope for this realignment). +- Does NOT modify `tool_use.input` JSON key order, `tool_calls.function.arguments` string contents, `phase` field, V4A patches, `local_shell_call.action.command` argv arrays, or any encrypted/redacted/compaction content. diff --git a/REALIGNMENT/03-phase-A-lockdown.md b/REALIGNMENT/03-phase-A-lockdown.md new file mode 100644 index 000000000..29653928f --- /dev/null +++ b/REALIGNMENT/03-phase-A-lockdown.md @@ -0,0 +1,420 @@ +# Phase A — Cache-Safety Lockdown + +**Goal:** Stop the cache-killer bleeding tonight. Each PR is small, low-risk, independently reversible. Zero new architecture; minimum viable fixes only. + +**Calendar:** 1 week. PR-A1 lands today; A2–A8 over the week. + +**Shape:** 8 PRs, each on its own branch, each in its own worktree. Sequential dependency only between A1→A4; the rest are parallelizable. + +--- + +## PR-A1 — Make `/v1/messages` compression a passthrough + +**Branch:** `realign-A1-icm-passthrough` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A1-icm-passthrough` +**Risk:** **LOW** (deletion + tests; no new logic) +**LOC:** -180 / +30 + +### Scope +Stop calling ICM from the Rust proxy on `/v1/messages`. The proxy becomes a pure byte-faithful passthrough on this endpoint. Zero compression value temporarily, but eliminates the C1+C2+C3+C4 cache-killer cluster (P0-3, P0-4, P0-5, P1-13). Compression returns in Phase B. + +### Files + +**Delete:** +- `crates/headroom-proxy/src/compression/icm.rs` + +**Modify:** +- `crates/headroom-proxy/src/compression/mod.rs` — remove `pub mod icm;`, remove ICM dispatch in `maybe_compress`. The `is_compressible_path` check still matches `/v1/messages` but `compress_anthropic_request` becomes a no-op stub returning `Outcome::NoCompression`. +- `crates/headroom-proxy/src/compression/anthropic.rs` — replace function body with `Ok(Outcome::NoCompression)`. Keep the function signature so callers compile; subsequent PRs in Phase B replace this with the live-zone block dispatcher. +- `crates/headroom-proxy/src/proxy.rs` — confirm the `Outcome::NoCompression` branch forwards original bytes (already does at line 296-298; just verify with the new test). + +**Tests added:** +- `crates/headroom-proxy/tests/integration_compression.rs::compression_on_message_passes_body_unchanged_sha256` — record a real Anthropic request body to a fixture; send through proxy; assert SHA-256 of upstream-received body equals SHA-256 of inbound body. + +**Tests deleted/updated:** +- Update `compression_on_short_body_passes_through` to assert SHA-256 byte-equality (not just `len()`). +- Update `compression_on_long_body_drops_messages` — rename to `compression_on_long_body_passes_through_in_phase_A`. The old assertion (fewer messages arrived) becomes the opposite (same messages arrive). + +### Acceptance criteria + +- `cargo test -p headroom-proxy` green. +- New SHA-256 round-trip test passes. +- The proxy still starts and serves `/healthz`. +- `make ci-precheck` green. + +### Blocked by + +None. Land first. + +### Blocks + +PR-A4 (cache_control honoring needs the ICM call site removed first to avoid conflict). +All Phase B PRs (which delete the surrounding code). + +### Rollback + +`git revert` the merge commit. ICM is restored. (Note: this also restores P0-3 and P0-4. Acceptable for ~hours during emergency rollback.) + +### Notes + +- The `compress_anthropic_request` function stays as a stub so Phase B has a single rewrite target. +- This PR does NOT delete ICM the module yet — `crates/headroom-core/src/context/manager.rs` still compiles. PR-B1 deletes the modules. Splitting keeps the diff scoped. + +--- + +## PR-A2 — Stop mutating the system prompt; route memory context to live zone + +**Branch:** `realign-A2-system-prompt-immutable` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A2-system-prompt-immutable` +**Risk:** **MEDIUM** (touches memory feature behavior) +**LOC:** -150 / +80 + +### Scope +Eliminate P0-1 and P2-23. The system prompt is never mutated; memory context is appended to the latest user message tail (live zone). Delete the cache_aligner rewrite path; keep the volatile-content detector for warnings only. + +### Files + +**Modify:** +- `headroom/proxy/server.py:1026-1071` — delete `_inject_system_context`. Memory context handling routes exclusively through `_append_context_to_latest_non_frozen_user_turn` (already exists at `handlers/anthropic.py:1117-1135` for cache mode; promote to default). +- `headroom/proxy/handlers/openai.py:1212` — same: delete `body["instructions"] = f"{existing_instructions}\n\n{memory_context}"`. Replace with append-to-latest-user-message-tail. +- `headroom/transforms/cache_aligner.py` — delete the rewrite path (lines 160-262). Keep the volatile-content detector and the `cache_aligner_warnings` callback that surfaces detected dynamic content (UUIDs, dates, tokens) to a customer-visible log line. +- `headroom/proxy/server.py:299` — `cache_aligner.enabled` flag stays default-False; document that turning it on now only affects warnings. + +**Tests added:** +- `tests/test_proxy_system_prompt_immutable.py::test_memory_enabled_does_not_mutate_system` — request with memory enabled; assert outbound system bytes equal inbound system bytes. +- `tests/test_proxy_system_prompt_immutable.py::test_memory_context_appears_in_user_tail` — same request; assert memory context appears in the last user message's tail. +- `tests/test_cache_aligner_detector_only.py::test_volatile_content_detected_warned_not_rewritten` — system prompt with UUID; assert detector emits warning log; assert system bytes unchanged. + +**Tests deleted/updated:** +- `tests/test_cache_aligner_rewrite_*.py` — delete; rewrite path is gone. + +### Acceptance criteria + +- All new tests pass. +- Existing memory tests still pass (the live-zone-tail append should produce equivalent semantics). +- No regression in `tests/test_proxy_anthropic_cache_stability.py`. + +### Blocked by + +None. Parallel with A1. + +### Blocks + +PR-B6 (memory subsystem refactor, which builds on this). + +### Rollback + +`git revert` the merge commit. Memory injection returns to system prompt. P0-1 returns. + +--- + +## PR-A3 — Switch Python forwarders to byte-faithful body forwarding + +**Branch:** `realign-A3-byte-faithful-forwarders` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A3-byte-faithful-forwarders` +**Risk:** **HIGH** (touches every outbound HTTP call in Python) +**LOC:** -200 / +250 + +### Scope +Eliminate P0-2 universally. Every Python forwarder switches from `httpx ... json=body` to `httpx ... content=raw_bytes`. When body was mutated by a transform, re-serialize once with `separators=(",", ":")`, `ensure_ascii=False`, and the original encoding. When unmutated, forward the original `await request.body()` verbatim. + +### Files + +**Modify:** +- `headroom/proxy/server.py:1073-1124` — `_retry_request`: track whether body was mutated; if not, forward `original_body_bytes`; if yes, re-serialize with the canonical settings. Switch `await self.http_client.post(url, json=body)` to `await self.http_client.post(url, content=outbound_bytes, headers={**headers, "content-type": "application/json"})`. +- `headroom/proxy/handlers/streaming.py:617-660` — same pattern in `_send_streaming_request`. +- `headroom/proxy/handlers/openai.py:2392-2410` — WS→HTTP fallback: same pattern. +- `headroom/proxy/handlers/batch.py:340-360` — batch endpoint: same pattern. +- `headroom/proxy/helpers.py` — add `serialize_body_canonical(body: dict) -> bytes` helper using `json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")`. + +**Tests added:** +- `tests/test_proxy_byte_faithful_forwarding.py::test_passthrough_no_mutation_byte_equal` — request with no compression / memory / transforms; assert SHA-256 of upstream-received body equals SHA-256 of client-sent body. +- `tests/test_proxy_byte_faithful_forwarding.py::test_compression_off_unicode_preserved` — request with `🔥` and CJK chars in user message; assert no `\uXXXX` escaping at upstream. +- `tests/test_proxy_byte_faithful_forwarding.py::test_compression_off_numeric_precision_preserved` — request with `temperature: 1.0` and `seed: 12345678901234567`; assert exact bytes. + +### Acceptance criteria + +- New byte-faithful tests pass. +- Existing test suite green. +- Manual smoke test: send a real request through the proxy with `tcpdump` or a recording mock; verify the bytes hitting upstream match a direct-to-Anthropic baseline. + +### Blocked by + +None. Parallel with A1, A2. + +### Blocks + +PR-A6 (memory tool injection refactor relies on the new mutation-tracking helper). + +### Rollback + +`git revert`. The httpx `json=` defaults return. + +### Notes + +- This is the highest-impact single PR for cache hit rate. Test coverage carefully. +- `httpx.AsyncClient` defaults set Content-Length from the bytes; verify no Transfer-Encoding chunked drift. +- The `accept-encoding` header strip stays for now; Phase F PR-F2 makes it conditional on auth mode. + +--- + +## PR-A4 — Honor customer `cache_control` markers in Rust; enable `arbitrary_precision`+`raw_value` + +**Branch:** `realign-A4-honor-cache-control` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A4-honor-cache-control` +**Risk:** **MEDIUM** (Rust-only; tightly scoped) +**LOC:** -30 / +200 + +### Scope +Eliminate P0-3 and P0-5 directly. In Rust, walk customer-set `cache_control` markers in `system`, `tools`, and `messages`; compute the effective `frozen_message_count`. Switch `serde_json` to `arbitrary_precision` + `raw_value` features. Use `&RawValue` for `messages[*]` so unmodified messages forward as exact byte copies. The `compress_anthropic_request` function is currently a no-op stub (per A1) — this PR adds the cache_control parser as preparation for Phase B. + +### Files + +**Modify:** +- `Cargo.toml:34` — add features: `serde_json = { version = "1", features = ["preserve_order", "arbitrary_precision", "raw_value"] }`. Run `cargo update -p serde_json`. +- `crates/headroom-proxy/src/compression/anthropic.rs` — add `pub fn compute_frozen_count(parsed: &serde_json::Value) -> usize` that walks `messages[*].content[*].cache_control`, `system[*].cache_control`, `tools[*].cache_control` and returns the highest message index whose content contains a marker. (Used by Phase B; currently called only by tests.) +- `crates/headroom-core/src/lib.rs` — re-export `compute_frozen_count` for use in Phase B. + +**Add:** +- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_marker_at_message_3_yields_frozen_count_3` +- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_in_system_blocks_yields_frozen_count_full_history` +- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_ttl_1h_before_5m_passes` +- `crates/headroom-proxy/tests/integration_cache_control.rs::cache_control_ttl_5m_before_1h_warns_and_passes` (we don't reject, but log per §2.19 ordering rule) + +### Acceptance criteria + +- `cargo build -p headroom-proxy` works with new features. +- `cargo test -p headroom-proxy` green. +- The `compute_frozen_count` returns 0 for a request with zero markers; returns N for a request with a marker on `messages[N]`. + +### Blocked by + +PR-A1 (the call site needs to be removed before this can land cleanly). + +### Blocks + +PR-B2 (live-zone block dispatcher uses `compute_frozen_count`). + +### Rollback + +`git revert`. The Cargo features stay (harmless). + +### Notes + +- `RawValue` is enabled but not yet consumed in this PR. Phase B PR-B2 wires it. +- Per guide §2.19, `1h` markers must precede `5m` markers; we log a warning when the ordering is reversed but don't reject (the customer's request, not ours to validate). + +--- + +## PR-A5 — Strip `x-headroom-*` from upstream-bound headers + +**Branch:** `realign-A5-strip-headroom-headers` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A5-strip-headroom-headers` +**Risk:** **LOW** +**LOC:** -10 / +50 + +### Scope +Eliminate P5-49. `dict(request.headers.items())` is captured unmodified and forwarded; this PR adds an explicit strip step before any upstream call. Reduces fingerprint surface for subscription detection. + +### Files + +**Modify:** +- `headroom/proxy/handlers/anthropic.py:526` — wrap `dict(request.headers.items())` with `_strip_internal_headers(headers)` (new helper). +- `headroom/proxy/handlers/openai.py:232-264` — same. +- `headroom/proxy/handlers/streaming.py:617` — same. +- `headroom/proxy/handlers/batch.py:340` — same. +- `headroom/proxy/handlers/gemini.py:31` — same. +- `headroom/proxy/helpers.py` — add `_strip_internal_headers` helper. Default strip list: `x-headroom-*` (case-insensitive prefix), plus a hardcoded set of internal flags. +- `crates/headroom-proxy/src/headers.rs` — add `strip_internal_headers` to the request-side filter. Document that response-side `X-Headroom-*` injection (which is fine) is unrelated. + +**Tests added:** +- `tests/test_header_isolation.py::test_x_headroom_bypass_not_forwarded` +- `tests/test_header_isolation.py::test_x_headroom_mode_not_forwarded` +- `tests/test_header_isolation.py::test_x_headroom_user_id_not_forwarded` +- `crates/headroom-proxy/tests/integration_headers.rs::x_headroom_request_headers_stripped` + +### Acceptance criteria + +- New tests pass. +- Existing client-driven `x-headroom-bypass: true` flow still works (proxy reads it; just doesn't forward). +- No legitimate header is stripped (whitelist `x-request-id`, `x-trace-id`, etc. by default — though they aren't `x-headroom-*` so they're untouched). + +### Blocked by + +None. Parallel. + +### Blocks + +PR-F2 (auth-mode policy uses this helper). + +### Rollback + +`git revert`. Headers leak again. Low operational risk. + +--- + +## PR-A6 — Pin `anthropic-beta` order; session-stickiness skeleton + +**Branch:** `realign-A6-anthropic-beta-stable` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A6-anthropic-beta-stable` +**Risk:** **MEDIUM** (touches memory injection beta-mutation) +**LOC:** -40 / +180 + +### Scope +Eliminate P5-50 and start P5-51. When the proxy mutates `anthropic-beta` (memory injection), the new comma-list is computed deterministically (sort tokens or preserve insertion order with new tokens appended). Add a per-session "betas seen so far" tracker so any beta seen in turn N is included in turn N+1 even if the client drops it. + +### Files + +**Modify:** +- `headroom/proxy/handlers/anthropic.py:1162-1168` — replace the ad-hoc concat with a helper `merge_anthropic_beta(client: str, headroom: list[str]) -> str` that splits client's value on `,`, lowercases each token, deduplicates, appends Headroom-required tokens (sorted within the appended group), and rejoins. +- `headroom/proxy/server.py` — extend `session_state` (already exists for memory) to track `betas_seen: set[str]` per session. Update on every request; merge into outbound `anthropic-beta` for follow-up requests. +- `headroom/proxy/helpers.py` — add `betas_seen_lock` and `update_session_betas` helpers. + +**Tests added:** +- `tests/test_anthropic_beta_session_sticky.py::test_beta_seen_turn_1_present_in_turn_2_even_if_client_drops` +- `tests/test_anthropic_beta_session_sticky.py::test_memory_injection_appends_deterministic_order` +- `tests/test_anthropic_beta_session_sticky.py::test_client_value_preserved_when_no_injection` + +### Acceptance criteria + +- New tests pass. +- Session ID is keyed off the existing session detection (per `headroom/proxy/handlers/anthropic.py:1417`). +- The "betas seen" set is bounded (LRU eviction at 1000 sessions). + +### Blocked by + +PR-A3 (relies on byte-faithful forwarder for header-bytes correctness; if A3 is rolled back, this still works but is less effective). + +### Blocks + +PR-A7 (memory tool session-stickiness uses the same session-state plumbing). + +### Rollback + +`git revert`. Beta header drift returns; functional but degraded cache safety. + +--- + +## PR-A7 — Memory tool injection session-sticky + +**Branch:** `realign-A7-memory-tool-sticky` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A7-memory-tool-sticky` +**Risk:** **MEDIUM** +**LOC:** -30 / +150 + +### Scope +Eliminate the rest of P0-6. Once memory injects a tool into `body["tools"]` for a session, every subsequent request in that session also injects the same tool (same name, same definition bytes). Toggling off mid-session is forbidden. + +### Files + +**Modify:** +- `headroom/proxy/memory_tool_adapter.py:625-657` — make injection session-state-aware. The session-state object grows a `memory_tools_injected: bool` and `memory_tools_definition_bytes: bytes` (golden form). On every request: if previously injected, inject again with byte-equal definition. +- `headroom/proxy/memory_handler.py:389-398` — same: native-tool path becomes session-sticky. +- `headroom/proxy/handlers/anthropic.py:1147-1171` — read session state; either inject all (if previously injected or memory enabled this turn) or none. + +**Tests added:** +- `tests/test_memory_tool_session_sticky.py::test_injection_in_turn_1_repeats_in_turn_2` +- `tests/test_memory_tool_session_sticky.py::test_byte_equal_tool_definition_across_turns` +- `tests/test_memory_tool_session_sticky.py::test_memory_disabled_after_inject_still_injects` + +### Acceptance criteria + +- New tests pass. +- The injected `memory_*` tool definitions are byte-stable across deploys (snapshot test pins the bytes). + +### Blocked by + +PR-A6. + +### Blocks + +PR-B6 (memory subsystem refactor). + +### Rollback + +`git revert`. Toggling returns. P0-6 returns. + +--- + +## PR-A8 — Hotfix Python wire-format bugs; add SHA-256 round-trip test + +**Branch:** `realign-A8-python-wire-hotfix` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-A8-python-wire-hotfix` +**Risk:** **MEDIUM** +**LOC:** -100 / +400 + +### Scope +Catch-all for the Python wire-format bugs that should be fixed before Phase H deletes the Python proxy. Specifically: +- P1-8: SSE byte-level decoding in `streaming.py` and `ccr/response_handler.py`. +- P1-9: Add `thinking_delta`, `signature_delta`, `citations_delta` arms to `_parse_sse_to_response`. +- P0-7 / P4-44: Preserve `phase` field in `responses_converter.py`; fix multi-text-part rebuild. +- P5-57 / P5-59: Capture upstream `request-id` in logs; fix body-size-cap status code (400 → 413). +- P4-47: Add a warning log line when `responses_converter.py:99` hits an unknown item type. +- P6-63: New SHA-256 byte-faithful round-trip test on a recorded production payload. + +### Files + +**Modify:** +- `headroom/proxy/handlers/streaming.py:213-298` — rewrite `_parse_sse_to_response` to handle all delta types per guide §5.1. Add index-keyed block map. Bytes-level SSE buffer. +- `headroom/proxy/handlers/streaming.py:58, 772` — switch `chunk.decode("utf-8", errors="ignore")` to a bytes-buffer + decode-after-`\n\n` pattern. +- `headroom/ccr/response_handler.py:665-686` — same pattern. +- `headroom/proxy/responses_converter.py:94, 235` — preserve `phase` explicitly. Fix multi-text-part rebuild: rebuild parts by index, replacing each part's text in place. +- `headroom/proxy/responses_converter.py:99` — add `logger.warning(f"unknown responses item type: {item.get('type')}")`. +- `crates/headroom-proxy/src/proxy.rs:355-358` — capture upstream `request-id` (Anthropic) and `x-request-id` (OpenAI) into the tracing field. +- `crates/headroom-proxy/src/proxy.rs:243-263` — return 413 on body-too-large; return 400 only on actual parse error. + +**Add:** +- `tests/fixtures/anthropic_messages_request_real.json` — recorded production-shaped payload (sanitized). +- `tests/test_proxy_byte_faithful_round_trip.py::test_sha256_round_trip_no_compression` — boot proxy; send fixture; assert SHA-256 byte-equal at upstream mock. +- `tests/test_proxy_responses_phase_preservation.py::test_codex_phase_commentary_preserved` +- `tests/test_proxy_responses_phase_preservation.py::test_codex_phase_final_answer_preserved` +- `tests/test_sse_thinking_blocks.py::test_thinking_delta_accumulated` +- `tests/test_sse_thinking_blocks.py::test_signature_delta_preserved` +- `tests/test_sse_thinking_blocks.py::test_citations_delta_accumulated` +- `tests/test_sse_utf8_split.py::test_emoji_split_across_chunks_preserved` +- `crates/headroom-proxy/tests/integration_request_id.rs::upstream_request_id_captured` + +### Acceptance criteria + +- All new tests pass. +- Pre-existing test suite green. +- Manual streaming smoke test with thinking blocks + signatures. + +### Blocked by + +None. Parallel with A2-A7. + +### Blocks + +None directly; Phase C builds on the Rust SSE work but doesn't depend on this Python fix. + +### Rollback + +`git revert`. Wire-format bugs return; subsequent Phase C will re-fix in Rust anyway. + +### Notes + +- This is a "hotfix the Python proxy enough to be safe until Phase H deletes it" PR. Not investing in pretty Python here — just safety. +- The recorded fixture in `tests/fixtures/anthropic_messages_request_real.json` should include: thinking + signature blocks, tool_use with non-trivial JSON input, mixed-key schemas, non-ASCII content, large numbers, `cache_control` markers in messages and system. + +--- + +## Phase A acceptance summary + +After all 8 PRs land: + +- ✅ ICM no longer drops messages from cache hot zone +- ✅ Customer `cache_control` markers honored in Rust +- ✅ System prompt never mutated +- ✅ Memory context routes to live-zone tail +- ✅ Memory tool injection session-sticky +- ✅ `anthropic-beta` mutation deterministic + session-sticky +- ✅ Python forwarders byte-faithful +- ✅ `x-headroom-*` stripped from upstream +- ✅ Numeric precision preserved (RawValue + arbitrary_precision) +- ✅ SSE thinking/signature/citations deltas handled +- ✅ Codex `phase` preserved +- ✅ Upstream request-id captured +- ✅ SHA-256 byte-faithful round-trip test gating CI + +**Phase A retires P0-1 through P0-7 and P1-8, P1-9, P5-49, P5-50, P5-57, P5-59, P6-63.** diff --git a/REALIGNMENT/04-phase-B-live-zone.md b/REALIGNMENT/04-phase-B-live-zone.md new file mode 100644 index 000000000..aeb8610a1 --- /dev/null +++ b/REALIGNMENT/04-phase-B-live-zone.md @@ -0,0 +1,470 @@ +# Phase B — Live-Zone-Only Compression Engine + +**Goal:** Delete ~10 K LOC of architectural over-build (ICM, scoring, relevance, rolling-window, progressive-summarizer, tool-crusher); build the correct architecture: per-block compression on the live zone only, with type-aware dispatch, token validation, and CCR hardening. + +**Calendar:** 2 weeks. PR-B1 is the big delete (high-LOC, lower-risk-than-it-looks because the code was unreachable after Phase A). PR-B2..B7 build the replacement. + +**Shape:** 7 PRs. B1 is independent; B2..B5 depend on B1; B6 + B7 layer on B2. + +--- + +## PR-B1 — The big delete: retire ICM and its dependencies + +**Branch:** `realign-B1-delete-icm-and-deps` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B1-delete-icm-and-deps` +**Risk:** **MEDIUM** (large diff, but most code became unreachable after Phase A PR-A1) +**LOC:** **-10,000 / +50** (the big retirement) + +### Scope +Delete the wrong-mental-model machinery wholesale. After Phase A PR-A1 made the proxy a passthrough on `/v1/messages`, none of this code is reached at runtime; this PR removes the source so future contributors can't re-wire it. + +### Files + +**Delete (Python):** +- `headroom/transforms/intelligent_context.py` (1077 LOC) +- `headroom/transforms/rolling_window.py` (395 LOC) +- `headroom/transforms/progressive_summarizer.py` (508 LOC) +- `headroom/transforms/scoring.py` (459 LOC) +- `headroom/transforms/tool_crusher.py` (338 LOC) + +**Delete (Rust):** +- `crates/headroom-core/src/context/manager.rs` +- `crates/headroom-core/src/context/config.rs` +- `crates/headroom-core/src/context/workspace.rs` +- `crates/headroom-core/src/context/candidate.rs` +- `crates/headroom-core/src/context/ccr_drop.rs` +- `crates/headroom-core/src/context/strategy/mod.rs` +- `crates/headroom-core/src/context/strategy/drop_by_score.rs` +- All of `crates/headroom-core/src/scoring/*.rs` (~1500 LOC) +- All of `crates/headroom-core/src/relevance/*.rs` (~1600 LOC) +- `crates/headroom-core/.fastembed_cache/` directory and its `bge-small-en-v1.5` ONNX artifacts (~50 MB) + +**Move:** +- `crates/headroom-core/src/context/safety.rs` → `crates/headroom-core/src/transforms/safety.rs`. Update all callers' `use` paths. The tool-pair atomicity logic is preserved verbatim (it's correct and live-zone code needs it). + +**Modify:** +- `crates/headroom-core/src/lib.rs` — remove `pub mod context;`, `pub mod scoring;`, `pub mod relevance;`. Add `pub use transforms::safety;`. +- `crates/headroom-core/src/context/mod.rs` — delete (empty after move). +- `crates/headroom-proxy/src/lib.rs` — no changes (already doesn't reach into deleted modules after PR-A1). +- `crates/headroom-py/src/lib.rs` — remove any PyO3 exports of `MessageScorer`, `IntelligentContextManager`, etc. (per agent reports, MessageScorer was exposed in PR #338/#343). +- `headroom/transforms/__init__.py` — remove imports of deleted modules. +- `headroom/proxy/handlers/anthropic.py` — remove all imports / call sites of `IntelligentContextManager`. (Agent C found these at multiple locations; track via `grep -n IntelligentContextManager headroom/`.) +- `headroom/proxy/server.py` — remove ICM import and instantiation. +- `Cargo.toml` workspace dependencies — drop `fastembed`, `tantivy`, `ort` (if only used by relevance), and any other deps that become orphaned. + +**Tests deleted:** +- All `tests/test_intelligent_context*.py` +- All `tests/test_rolling_window*.py` +- All `tests/test_progressive_summarizer*.py` +- All `tests/test_scoring*.py` +- All `tests/test_tool_crusher*.py` +- `crates/headroom-core/tests/scoring_*.rs`, `relevance_*.rs`, `context_*.rs` (other than safety) +- Parity comparator for `message_scorer` (PR #338/#343 work) — delete the comparator and the fixtures it consumed. +- Parity fixtures `tests/parity/fixtures/message_scorer/` (13 fixtures per Agent F report). + +### Acceptance criteria + +- `cargo build --workspace` green. +- `cargo test --workspace` green (after test deletions). +- `make ci-precheck` green. +- `pytest -x` green. +- Workspace builds **without** the fastembed cache dir. +- `git grep -i "IntelligentContextManager\|MessageScorer\|RollingWindow\|ProgressiveSummarizer\|ToolCrusher\|DropByScoreStrategy"` returns nothing in `crates/`, `headroom/`, `tests/` except comments referencing the deletion. + +### Blocked by + +PR-A1 (ICM call site must be removed first). + +### Blocks + +PR-B2 (live-zone block dispatcher fills the void). + +### Rollback + +`git revert`. ~10K LOC returns. Cache-killer bugs DO NOT return because Phase A PR-A1 already removed the call site (the deleted code is unreachable). Safe to revert. + +### Notes + +- **MessageScorer Rust port retirement:** PR #338 and #343 (April 2026) ported MessageScorer to Rust. That work becomes deletable here. Sunk cost stays sunk. The fixtures and the parity-harness scaffolding learnings carry forward to live-zone work. +- **`bge-small-en-v1.5` ONNX cache:** ~50 MB. Removing it is reversible (re-fetched on next fastembed init if anyone re-adds the dep). Document in CHANGELOG. +- **`anchor_selector.py`** — Agent G suspected it might be ICM-only. Check: `git grep AnchorSelector headroom/ crates/`. If only consumed by ICM/scoring/SmartCrusher, delete; if consumed by SmartCrusher's anchor logic, keep. Current Rust has `crates/headroom-core/src/transforms/anchor_selector.rs` which is consumed by SmartCrusher — keep that one, delete the Python one if ICM was its only caller. + +--- + +## PR-B2 — Live-zone block dispatcher in Rust + +**Branch:** `realign-B2-live-zone-dispatcher` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B2-live-zone-dispatcher` +**Risk:** **MEDIUM-HIGH** (new architecture; the central piece) +**LOC:** +800 + +### Scope +Build the new compressor: a function that takes an Anthropic `/v1/messages` body, identifies the live-zone blocks (latest user message tool_results, latest user message text, latest assistant tool_use is hot zone — exclude), and dispatches each to a type-aware compressor. Does NOT yet wire the type-aware compressors (PR-B3); does NOT yet validate tokens (PR-B4); does NOT yet inject CCR (PR-B7). PR-B2 lays the dispatching skeleton with no-op compressors; subsequent PRs fill them in. + +### Files + +**Add:** +- `crates/headroom-core/src/transforms/live_zone.rs` — the dispatcher. Public API: + ```rust + pub fn compress_live_zone( + body_raw: &serde_json::value::RawValue, + frozen_message_count: usize, + auth_mode: AuthMode, + ) -> Result; + + pub enum LiveZoneOutcome { + NoChange, + Modified { new_body: Box, manifest: CompressionManifest }, + } + ``` + Implementation skeleton: + 1. Parse `body` minimally (only `messages` field; leave the rest as `RawValue`). + 2. For each message at index `>= frozen_message_count`: + - Identify if it's the latest user message (live zone candidate). + - For each block in its content: + - If block type is `tool_result`, dispatch to a no-op compressor (filled in PR-B3). + - If block type is `text`, dispatch to text compressor (no-op for now). + - Otherwise (image, etc.), no-op. + 3. Reassemble the modified `messages` array, preserving unmodified messages as `RawValue` byte-copies. + 4. Reassemble the body, preserving the original envelope as `RawValue` byte-copies; the only modified bytes are within the messages array. + 5. Return `Modified` only if any block was actually mutated; otherwise `NoChange` and the caller forwards original bytes. + +**Modify:** +- `crates/headroom-proxy/src/compression/mod.rs` — add `pub mod live_zone_anthropic;` and route `/v1/messages` to it (replacing the no-op stub from PR-A1). +- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` (new) — calls `compress_live_zone` with `frozen_count = compute_frozen_count(parsed)` (from PR-A4). +- `crates/headroom-proxy/src/compression/anthropic.rs` — delete (replaced by `live_zone_anthropic.rs`). + +**Tests added:** +- `crates/headroom-core/tests/live_zone_skeleton.rs::dispatches_only_to_latest_user_message` +- `crates/headroom-core/tests/live_zone_skeleton.rs::respects_frozen_message_count` +- `crates/headroom-core/tests/live_zone_skeleton.rs::no_change_when_no_block_mutated_returns_original` +- `crates/headroom-core/tests/live_zone_skeleton.rs::modified_messages_byte_equal_outside_block` +- `crates/headroom-core/tests/live_zone_skeleton.rs::system_and_tools_byte_equal_always` +- `crates/headroom-proxy/tests/integration_live_zone.rs::end_to_end_live_zone_passthrough` + +### Acceptance criteria + +- `cargo test -p headroom-core` green. +- `cargo test -p headroom-proxy` green. +- All Phase A SHA-256 tests still pass (live-zone with no-op compressors is byte-identical). + +### Blocked by + +PR-B1 (deletion); PR-A4 (cache_control / RawValue features). + +### Blocks + +PR-B3, PR-B4, PR-B7. + +### Rollback + +`git revert`. Compression returns to passthrough (the Phase A state); no functional regression. + +### Notes + +- The `RawValue`-based approach is the correctness mechanism: bytes outside modified blocks are byte-copies, not parse-then-reserialize. +- `AuthMode` parameter is unused in B2 (always `Payg` from B2's perspective); Phase F PR-F2 wires the gate. + +--- + +## PR-B3 — Wire type-aware compressors into live-zone dispatcher + +**Branch:** `realign-B3-wire-type-aware-compressors` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B3-wire-type-aware-compressors` +**Risk:** **MEDIUM** (existing compressors are battle-tested) +**LOC:** +600 + +### Scope +Wire `SmartCrusher`, `LogCompressor`, `SearchCompressor`, `DiffCompressor`, `CodeCompressor` into the dispatcher. Per-block content-type detection drives dispatch. No token validation yet (PR-B4); no CCR hardening yet (PR-B7). + +### Files + +**Modify:** +- `crates/headroom-core/src/transforms/live_zone.rs` — replace no-op compressors with real dispatch: + ```rust + fn compress_block(block: &mut Block, content_type: ContentType) -> Result> { + match content_type { + ContentType::JsonArrayOfDicts => smart_crusher::crush(block), + ContentType::Logs => log_compressor::compress(block), + ContentType::SearchResults => search_compressor::compress(block), + ContentType::Diff => diff_compressor::compress(block), + ContentType::SourceCode => code_compressor::compress(block), + ContentType::PlainText => Ok(None), // PR-B4 adds Kompress; for now, leave untouched + ContentType::Image | ContentType::Unknown => Ok(None), + } + } + ``` +- `crates/headroom-core/src/transforms/content_detector.rs` — extend `ContentType` enum with the variants above. Use existing `Magika` + `unidiff-rs` + heuristic detectors. + +**Tests added:** +- `crates/headroom-core/tests/live_zone_dispatch.rs::json_tool_result_routes_to_smart_crusher` +- `crates/headroom-core/tests/live_zone_dispatch.rs::log_tool_result_routes_to_log_compressor` +- `crates/headroom-core/tests/live_zone_dispatch.rs::diff_tool_result_routes_to_diff_compressor` +- `crates/headroom-core/tests/live_zone_dispatch.rs::source_code_tool_result_routes_to_code_compressor` +- `crates/headroom-core/tests/live_zone_dispatch.rs::unknown_content_type_no_op` + +### Acceptance criteria + +- All new tests pass. +- Existing SmartCrusher / LogCompressor / DiffCompressor / SearchCompressor tests still pass (their code is unchanged; only the caller is new). +- A representative `/v1/messages` request with a 50KB JSON tool_result through the proxy results in measurable compression (>2× size reduction) and SHA-256-equal envelope outside the compressed block. + +### Blocked by + +PR-B2. + +### Blocks + +PR-B4 (token validation gate), PR-B7 (CCR injection). + +### Rollback + +`git revert`. Live-zone goes back to no-op compressors. + +--- + +## PR-B4 — Token validation gate with fallback; per-content-type byte thresholds + +**Branch:** `realign-B4-token-validation-gate` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B4-token-validation-gate` +**Risk:** **LOW** +**LOC:** +250 + +### Scope +Eliminate P3-33 and P3-34. After every per-block compression, run the tokenizer over `original` and `compressed`. If `compressed.tokens >= original.tokens`, fall back to original. Add per-content-type byte thresholds: code>2KB, JSON>1KB, logs>500B, plain text>5KB. Below threshold → no compression attempted. + +### Files + +**Modify:** +- `crates/headroom-core/src/transforms/live_zone.rs` — wrap each compressor call with: + ```rust + let original_tokens = tokenizer.count(&original_bytes)?; + let compressed_tokens = tokenizer.count(&compressed_bytes)?; + if compressed_tokens >= original_tokens { + metrics::compression_rejected_by_token_check(compressor_name); + return Ok(None); // fall back to original + } + ``` +- `crates/headroom-core/src/transforms/live_zone.rs::compress_block` — gate on byte threshold per content type: + ```rust + const THRESHOLDS: &[(ContentType, usize)] = &[ + (ContentType::SourceCode, 2048), + (ContentType::JsonArrayOfDicts, 1024), + (ContentType::Logs, 512), + (ContentType::PlainText, 5120), + (ContentType::Diff, 1024), + (ContentType::SearchResults, 1024), + ]; + if block.bytes_len() < threshold_for(content_type) { + return Ok(None); + } + ``` + +**Tests added:** +- `crates/headroom-core/tests/live_zone_thresholds.rs::below_threshold_no_compression_attempted` +- `crates/headroom-core/tests/live_zone_thresholds.rs::above_threshold_compression_attempted` +- `crates/headroom-core/tests/live_zone_token_validation.rs::compressed_more_tokens_falls_back` +- `crates/headroom-core/tests/live_zone_token_validation.rs::compressed_fewer_tokens_accepted` +- Property test: `proptest! { fn live_zone_compression_token_count_non_increasing(blocks in arb_blocks_strategy()) { ... } }` + +### Acceptance criteria + +- All tests pass. +- A pathological input (already-minified JSON, dense base64) falls back to original instead of inflating tokens. +- Prometheus emits `compression_rejected_by_token_check_total{strategy=...}` counter. + +### Blocked by + +PR-B3. + +### Blocks + +PR-B6, PR-B7. + +### Rollback + +`git revert`. Token validation removed; bytes-only gate returns. Slight regression risk on pathological inputs. + +--- + +## PR-B5 — TOIN observation-only refactor + +**Branch:** `realign-B5-toin-observation-only` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B5-toin-observation-only` +**Risk:** **MEDIUM** (TOIN is a preserved primitive; refactor must maintain its learning value) +**LOC:** -300 / +400 + +### Scope +Eliminate P2-27 and P5-56. Strip TOIN's request-time hint API; keep the recording API. Recommendations published between deploys via a CLI tool that aggregates and writes a TOML file the compressor loads at startup. Per-tenant aggregation key extended to `(auth_mode, model_family, structure_hash)`. + +### Files + +**Modify:** +- `headroom/telemetry/toin.py:853-927` — remove `get_recommendation()` and `CompressionHint`. Replace with a no-op stub that returns `None`; deprecation warning in docstring. +- `headroom/telemetry/toin.py:103` — `Pattern` adds `auth_mode: str`, `model_family: str` fields. +- `headroom/telemetry/toin.py:477, 496, 727, 729, 1248, 1256` — change aggregation key from `sig_hash` to `(auth_mode, model_family, sig_hash)` tuple. Update all dict-key uses. +- `headroom/telemetry/toin.py:1596` — keep `tenant_prefix` for storage but document it's now redundant with the aggregation key. +- `headroom/transforms/smart_crusher.py:446` — remove the `get_recommendation()` call site. SmartCrusher is now deterministic; TOIN observes outcomes only. +- New CLI: `headroom/cli/toin_publish.py` — aggregates the on-disk TOIN store and produces `recommendations.toml`. Run as part of the deploy pipeline. +- New: `crates/headroom-core/src/transforms/recommendations.rs` — loads `recommendations.toml` at startup. Provides API like `recommendations::get(auth_mode, model, structure_hash) -> Option`. Used to bias which compressor variants to try first (deterministic; no per-request mutation). + +**Tests added:** +- `tests/test_toin_observation_only.py::test_no_request_time_hint_api_exposed` +- `tests/test_toin_observation_only.py::test_aggregation_key_includes_auth_mode_and_model` +- `tests/test_toin_observation_only.py::test_record_does_not_alter_compression_decision` +- `tests/test_toin_publish.py::test_publish_command_writes_toml` +- Determinism property test: `proptest! { fn compressor_deterministic_under_toin(input in arb_input()) { let r1 = compress(input); let r2 = compress(input); assert_eq!(r1, r2); } }` + +### Acceptance criteria + +- All new tests pass. +- TOIN's `record_compression` call sites still work (recording is kept). +- Removing TOIN's recommendations.toml at startup makes compression behave as if TOIN had never observed anything (graceful degrade). + +### Blocked by + +PR-B4. + +### Blocks + +PR-F3 (auth-mode aggregation key requires the TOIN refactor). + +### Rollback + +`git revert`. Per-request hint API returns; non-determinism returns. + +### Notes + +- This PR preserves TOIN per user direction: the learning value is intact; the dangerous request-time mutation is gone. + +--- + +## PR-B6 — Memory subsystem refactor: live-zone tail injection only + +**Branch:** `realign-B6-memory-live-zone-tail` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B6-memory-live-zone-tail` +**Risk:** **MEDIUM-HIGH** (touches memory feature semantics) +**LOC:** -400 / +300 + +### Scope +Eliminate P2-24. Memory retrieval moves out of the request lifecycle "auto-prepend" position. Two modes: +1. **Auto-tail mode** (default for now): retrieval runs at request entry; results appended to the latest user message tail (live zone). Same content always positions at the same place. Deterministic results for the same query. +2. **Tool mode** (preferred long-term): the model calls `memory_search` explicitly; retrieval runs in the tool execution path, not in the prompt-construction path. Memory is opt-in, not invisible. + +This PR ships auto-tail-mode as default; tool-mode is wired but off-by-default. + +### Files + +**Modify:** +- `headroom/proxy/memory_handler.py:498-510` — delete `_inject_to_system_or_instructions`. Replace with `_append_to_latest_user_tail`. +- `headroom/proxy/handlers/openai.py:535-540` — same. +- `headroom/proxy/handlers/anthropic.py:1117-1135` — promote the existing `_append_context_to_latest_non_frozen_user_turn` to be the default path. +- `headroom/proxy/server.py:1050-1058` — already deleted in PR-A2; verify nothing reintroduces it. +- `headroom/proxy/memory_handler.py` — add `MemoryMode` enum: `AutoTail | Tool`. Default `AutoTail`. `Tool` mode skips auto-injection entirely. + +**Tests added:** +- `tests/test_memory_auto_tail.py::test_memory_appears_in_latest_user_message_tail` +- `tests/test_memory_auto_tail.py::test_memory_does_not_modify_system_or_tools` +- `tests/test_memory_auto_tail.py::test_same_query_byte_identical_across_runs` +- `tests/test_memory_tool_mode.py::test_tool_mode_skips_auto_injection` + +### Acceptance criteria + +- All new tests pass. +- Existing memory feature tests pass (semantics preserved; position changes from system to user-tail). +- The bytes inserted are deterministic for the same query (no randomness in vector search results — verify or seed). + +### Blocked by + +PR-A2, PR-B4. + +### Blocks + +None (memory tool injection session-stickiness from PR-A7 stays). + +### Rollback + +`git revert`. Memory returns to auto-prepend. + +--- + +## PR-B7 — CCR hardening: persistent backend + always-on tool registration + +**Branch:** `realign-B7-ccr-hardening` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-B7-ccr-hardening` +**Risk:** **MEDIUM** +**LOC:** -150 / +600 + +### Scope +Eliminate P2-25, P2-26. Two changes: +1. **Persistent CCR backend.** `CcrStore` trait gets a `SqliteCcrStore` impl (default) and a `RedisCcrStore` impl (opt-in for multi-worker). The in-memory store stays for tests. RUST_DEV.md "Multi-worker deployment — CCR fragmentation" section gets updated. +2. **`ccr_retrieve` tool always-on.** Once a session has performed any CCR compression, the tool is registered in `body["tools"]` for every subsequent request. The session ID derives from the existing `session_tracker_store` plumbing. + +In Rust, the live-zone dispatcher writes `<>` markers into the compressed block content (side-channel) and stores the original bytes in the configured backend. + +### Files + +**Add:** +- `crates/headroom-core/src/ccr/backends/sqlite.rs` — SQLite-backed `CcrStore`. Schema: `ccr_entries(hash TEXT PRIMARY KEY, original BLOB, created_at INTEGER, ttl_seconds INTEGER)`. Auto-purge on read (`WHERE created_at + ttl_seconds > now`). +- `crates/headroom-core/src/ccr/backends/redis.rs` — Redis-backed `CcrStore`. `SETEX hash ttl_seconds original`. +- `crates/headroom-core/src/ccr/backends/mod.rs` — `pub trait CcrStore` (already exists at `ccr.rs`); `pub fn from_config(config: &CcrConfig) -> Box`. + +**Modify:** +- `crates/headroom-core/src/ccr.rs` — extract `InMemoryCcrStore` to its own file; rest stays. +- `crates/headroom-core/src/transforms/live_zone.rs` — when a compressor returns a `CompressionResult` with original bytes, store original bytes in CCR backend keyed by `BLAKE3(original_bytes)`. Append `<>` marker to compressed block content. +- `headroom/proxy/handlers/anthropic.py` — `inject_ccr_retrieve_tool`: always add the tool when `session.has_done_ccr` is true; never toggle off. +- `headroom/proxy/handlers/openai.py` — same for OpenAI Chat / Responses. +- `headroom/ccr/tool_injection.py:302-328` — change `if has_compressed_content:` to `if session.has_done_ccr:`. +- `RUST_DEV.md` — update "Multi-worker deployment — CCR fragmentation" section: with `SqliteCcrStore` + sticky-session not required; with `RedisCcrStore` no stickiness needed at all. + +**Tests added:** +- `crates/headroom-core/tests/ccr_backends.rs::sqlite_round_trip` +- `crates/headroom-core/tests/ccr_backends.rs::sqlite_ttl_purge` +- `crates/headroom-core/tests/ccr_backends.rs::redis_round_trip` (gated behind `cfg(feature = "redis")`) +- `crates/headroom-core/tests/ccr_backends.rs::backend_swap_byte_equal_keys` +- `tests/test_ccr_tool_always_on.py::test_tool_registered_on_every_request_after_first_ccr` +- `tests/test_ccr_tool_always_on.py::test_tool_not_registered_if_session_never_did_ccr` +- `tests/test_ccr_tool_always_on.py::test_tool_definition_byte_stable` + +### Acceptance criteria + +- All new tests pass. +- `RUST_DEV.md` reflects the new multi-worker story. +- A simulated proxy restart (kill + restart with `SqliteCcrStore`) can still resolve CCR markers from before the restart. +- Tool definition bytes are byte-stable (snapshot test pins them). + +### Blocked by + +PR-B2, PR-B3, PR-B4. + +### Blocks + +None. + +### Rollback + +`git revert`. In-memory-only CCR returns; tool-list flip returns. Operations stays — just less safe. + +### Notes + +- The `<>` marker format is unchanged — existing markers from before this PR (in any cached prefix) still work. +- The session ID for "has done CCR" is the existing `session_id` from `session_tracker_store`; no new persistence needed. + +--- + +## Phase B acceptance summary + +After all 7 PRs land: + +- ✅ ICM + RollingWindow + ProgressiveSummarizer + scoring + relevance + ToolCrusher deleted (~10K LOC retired) +- ✅ Live-zone block dispatcher operational +- ✅ Type-aware compressors wired (SmartCrusher, LogCompressor, SearchCompressor, DiffCompressor, CodeCompressor) +- ✅ Token validation gate with per-type byte thresholds and fallback +- ✅ TOIN observation-only with per-tenant aggregation key +- ✅ Memory routes to live-zone tail (no system mutation) +- ✅ CCR persistent backend + always-on tool registration +- ✅ MessageScorer Rust port (PR #338, #343) retired + +**Phase B retires P0-4, P1-13, P2-18 through P2-27, P3-33, P3-34, P5-56, P6-70.** + +After Phase B, Headroom's compression value is **back online** — and now it's correct. diff --git a/REALIGNMENT/05-phase-C-rust-proxy.md b/REALIGNMENT/05-phase-C-rust-proxy.md new file mode 100644 index 000000000..b25bbc270 --- /dev/null +++ b/REALIGNMENT/05-phase-C-rust-proxy.md @@ -0,0 +1,329 @@ +# Phase C — Rust Proxy Paths + +**Goal:** Port the remaining proxy surfaces to Rust. After Phase C, the Rust proxy handles `/v1/messages`, `/v1/chat/completions`, `/v1/responses` (HTTP + streaming), with a byte-level SSE state machine that handles every wire-format quirk the guide enumerates. + +**Calendar:** 3 weeks. Mostly sequential (each PR builds on the SSE parser). + +**Shape:** 5 PRs. + +--- + +## PR-C1 — Byte-level SSE parser with full state machine + +**Branch:** `realign-C1-rust-sse-parser` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-C1-rust-sse-parser` +**Risk:** **HIGH** (foundational; many wire-format quirks; UTF-8 split-byte handling) +**LOC:** +1500 + +### Scope +Eliminate P1-8, P1-9, P1-14, P1-15, P1-17, P4-48. Build the byte-level SSE parser in `crates/headroom-proxy/src/sse/`. Three parsers (one per provider × API), all sharing a common framing layer. Per-stream state (no module-level state). Models the streaming state machines from guide §5 exactly. + +### Files + +**Add:** +- `crates/headroom-proxy/src/sse/mod.rs` — module re-exports. +- `crates/headroom-proxy/src/sse/framing.rs` — byte-level framing. Reads `bytes::Bytes` chunks, accumulates into a `BytesMut` buffer, finds `\n\n` event terminators in bytes (not strings), yields complete events as `(event_name: Option, data: Bytes)`. Decodes UTF-8 per complete event, never per chunk. Handles `: ping` keepalives (skip silently). Handles `[DONE]` literal. +- `crates/headroom-proxy/src/sse/anthropic.rs` — Anthropic stream state machine per guide §5.1: + ```rust + pub struct AnthropicStreamState { + pub message_id: Option, + pub model: Option, + pub blocks: HashMap, // keyed by index + pub current_block_index: Option, + pub stop_reason: Option, + pub usage: UsageBuilder, + pub status: StreamStatus, + } + + pub struct BlockState { + pub block_type: String, + pub text_buffer: String, + pub partial_json: String, + pub signature: Option, + pub citations: Vec, + pub metadata: serde_json::Value, + pub complete: bool, + } + + impl AnthropicStreamState { + pub fn apply(&mut self, event: SseEvent) -> Result<()>; + } + ``` + Handlers for `message_start`, `content_block_start`, `content_block_delta` (switching on `delta.type`: `text_delta` / `thinking_delta` / `input_json_delta` / `citations_delta` / `signature_delta`), `content_block_stop`, `message_delta`, `message_stop`, `error`, `ping`. +- `crates/headroom-proxy/src/sse/openai_chat.rs` — OpenAI Chat Completions state machine per guide §5.2. `ChunkState`, `ChoiceState`, `ToolCallState`. Handles `[DONE]` and `stream_options.include_usage` final chunk. +- `crates/headroom-proxy/src/sse/openai_responses.rs` — OpenAI Responses state machine per guide §5.3. `ResponseState`, `ItemState` keyed by `id` (not position) for out-of-order completion. Handlers for `response.created`, `output_item.added/done`, `content_part.added/done`, `output_text.delta/done`, `function_call_arguments.delta/done`, `reasoning_summary.delta/done`, `response.completed/failed/incomplete`. + +**Modify:** +- `crates/headroom-proxy/src/proxy.rs` — when forwarding a streaming response, the state machine runs in parallel with the byte-passthrough (so client gets raw bytes immediately; state machine populates telemetry without blocking the stream). + +**Tests added:** +- `crates/headroom-proxy/tests/sse_framing.rs::utf8_split_emoji_across_chunks_preserved` +- `crates/headroom-proxy/tests/sse_framing.rs::single_newline_does_not_emit_event` +- `crates/headroom-proxy/tests/sse_framing.rs::double_newline_emits_event` +- `crates/headroom-proxy/tests/sse_framing.rs::ping_keepalive_skipped` +- `crates/headroom-proxy/tests/sse_framing.rs::done_sentinel_detected` +- `crates/headroom-proxy/tests/sse_framing.rs::trailing_data_after_done_tolerated` +- `crates/headroom-proxy/tests/sse_anthropic.rs::four_event_dance_text_block` +- `crates/headroom-proxy/tests/sse_anthropic.rs::thinking_delta_accumulated` +- `crates/headroom-proxy/tests/sse_anthropic.rs::signature_delta_preserved_byte_equal` +- `crates/headroom-proxy/tests/sse_anthropic.rs::input_json_delta_concatenated_parsed_at_stop` +- `crates/headroom-proxy/tests/sse_anthropic.rs::citations_delta_accumulated` +- `crates/headroom-proxy/tests/sse_anthropic.rs::message_delta_finalizes_stop_reason_and_output_tokens` +- `crates/headroom-proxy/tests/sse_anthropic.rs::mid_stream_error_event_handled` +- `crates/headroom-proxy/tests/sse_anthropic.rs::interleaved_blocks_by_index` +- `crates/headroom-proxy/tests/sse_openai_chat.rs::tool_call_id_and_name_only_first_chunk` +- `crates/headroom-proxy/tests/sse_openai_chat.rs::tool_call_arguments_concatenated` +- `crates/headroom-proxy/tests/sse_openai_chat.rs::usage_in_final_chunk_when_include_usage_set` +- `crates/headroom-proxy/tests/sse_openai_chat.rs::refusal_field_handled` +- `crates/headroom-proxy/tests/sse_openai_responses.rs::out_of_order_item_completion_by_id` +- `crates/headroom-proxy/tests/sse_openai_responses.rs::reasoning_summary_accumulated` +- `crates/headroom-proxy/tests/sse_openai_responses.rs::function_call_arguments_string_preserved` +- Property test: `proptest! { fn sse_parser_no_panic_on_arbitrary_bytes(bytes in any::>()) { let _ = parse(bytes); } }` + +### Acceptance criteria + +- All new tests pass. +- Property test: 100K random byte sequences never panic the parser. +- Real-traffic shadow test: feed a recorded production Anthropic stream through both the Rust parser and the Python parser; assert telemetry agrees on `usage` totals. + +### Blocked by + +PR-A1. + +### Blocks + +PR-C2, PR-C3, PR-C4. + +### Rollback + +`git revert`. SSE parsing returns to byte-passthrough (Phase A state). Telemetry less rich but no functional regression. + +--- + +## PR-C2 — `/v1/chat/completions` handler in Rust + +**Branch:** `realign-C2-rust-chat-completions` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-C2-rust-chat-completions` +**Risk:** **HIGH** (new endpoint surface) +**LOC:** +1200 + +### Scope +Add `/v1/chat/completions` to `crates/headroom-proxy`. Handles request-body shape, live-zone compression dispatch (assistant `tool_calls` and `tool` role messages — equivalents of Anthropic's tool_result), and the streaming state machine from PR-C1. Adds Phase E PR-E1/E2 tool-def normalization gate (no-op until Phase E). + +### Files + +**Add:** +- `crates/headroom-proxy/src/handlers/chat_completions.rs` — POST handler. + ```rust + async fn handle_chat_completions( + State(state): State, + headers: HeaderMap, + body: Bytes, + ) -> Result; + ``` +- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — OpenAI live-zone dispatcher. Live zone for Chat Completions: latest `tool` role message's `content`; latest `user` message's text content. Compress per type-aware dispatch (same compressors as Anthropic; reused). + +**Modify:** +- `crates/headroom-proxy/src/lib.rs` — route `/v1/chat/completions` (POST) to the new handler. +- `crates/headroom-proxy/src/compression/mod.rs` — add OpenAI Chat dispatch path. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_chat_completions.rs::passthrough_no_compression_byte_equal` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::tool_message_compressed` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::n_greater_than_one_passthrough` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::stream_options_include_usage_preserved` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::tool_choice_change_passthrough_no_mutation` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::refusal_field_in_response_handled` +- `crates/headroom-proxy/tests/integration_chat_completions.rs::streaming_tool_call_argument_accumulation` + +### Acceptance criteria + +- All new tests pass. +- A real Chat Completions request through the Rust proxy produces byte-equal upstream bytes when compression is off. +- Streaming tool_call accumulation works for the `delta.tool_calls[].function.arguments` pattern. + +### Blocked by + +PR-C1, PR-B3, PR-B4. + +### Blocks + +PR-C3, PR-H1. + +### Rollback + +`git revert`. `/v1/chat/completions` still flows through the Python proxy (Phase H hasn't deleted it yet). + +--- + +## PR-C3 — `/v1/responses` handler in Rust (HTTP) + +**Branch:** `realign-C3-rust-responses-http` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-C3-rust-responses-http` +**Risk:** **HIGH** +**LOC:** +1500 + +### Scope +Add `/v1/responses` HTTP handler. Item-shape passthrough preservation for every Responses item type (V4A patches, `local_shell_call.action.command` argv, Codex `phase`, `compaction`, MCP items, computer_use, `image_generation_call`, server-side tool results). Live-zone compression for `function_call_output`, `local_shell_call_output`, `apply_patch_call_output` (only when >2KB). + +### Files + +**Add:** +- `crates/headroom-proxy/src/handlers/responses.rs` — POST handler. +- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — Responses live-zone dispatcher. Live zone: latest `function_call_output.output`, latest `local_shell_call_output.output`, latest `apply_patch_call_output.output`, latest `user` message text content. +- `crates/headroom-proxy/src/responses_items.rs` — explicit per-item-type enum and passthrough rules: + ```rust + pub enum ResponseItem { + Message { phase: Option, .. }, + Reasoning { encrypted_content: Option, .. }, // passthrough only + FunctionCall { call_id: String, arguments: String, .. }, // arguments stays as string + LocalShellCall { command: Vec, .. }, // argv array preserved + ApplyPatchCall { operation: ApplyPatchOperation, .. }, // V4A diff verbatim + Compaction { encrypted_content: String, .. }, // passthrough only + McpCall { .. } | McpListTools { .. } | McpApprovalRequest { .. }, // passthrough + ComputerCall { .. } | ComputerCallOutput { .. }, + WebSearchCall { .. } | FileSearchCall { .. } | CodeInterpreterCall { .. }, + ImageGenerationCall { .. }, + ToolSearchCall { .. }, + CustomToolCall { .. }, + Unknown { type_: String, raw: Box }, // log warning; preserve verbatim + } + ``` + +**Modify:** +- `crates/headroom-proxy/src/lib.rs` — route `/v1/responses` (POST) to the new handler. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_responses.rs::v4a_patch_byte_equal_through_proxy` +- `crates/headroom-proxy/tests/integration_responses.rs::local_shell_call_command_argv_array_preserved` +- `crates/headroom-proxy/tests/integration_responses.rs::codex_phase_commentary_preserved` +- `crates/headroom-proxy/tests/integration_responses.rs::codex_phase_final_answer_preserved` +- `crates/headroom-proxy/tests/integration_responses.rs::compaction_item_byte_equal` +- `crates/headroom-proxy/tests/integration_responses.rs::reasoning_encrypted_content_byte_equal` +- `crates/headroom-proxy/tests/integration_responses.rs::function_call_arguments_string_preserved` +- `crates/headroom-proxy/tests/integration_responses.rs::call_id_referenced_not_id` +- `crates/headroom-proxy/tests/integration_responses.rs::apply_patch_output_below_2kb_no_compression` +- `crates/headroom-proxy/tests/integration_responses.rs::apply_patch_output_above_2kb_compressed` +- `crates/headroom-proxy/tests/integration_responses.rs::local_shell_output_compressed` +- `crates/headroom-proxy/tests/integration_responses.rs::mcp_tool_call_byte_equal` +- `crates/headroom-proxy/tests/integration_responses.rs::computer_call_byte_equal` +- `crates/headroom-proxy/tests/integration_responses.rs::image_generation_call_no_log_redaction_in_test_mode` +- `crates/headroom-proxy/tests/integration_responses.rs::unknown_item_type_logged_warning_byte_equal` + +### Acceptance criteria + +- All new tests pass. +- A representative Responses request with reasoning + function_call + local_shell + apply_patch + custom items round-trips byte-equal modulo compressed live-zone outputs. + +### Blocked by + +PR-C1, PR-C2. + +### Blocks + +PR-C4. + +### Rollback + +`git revert`. `/v1/responses` still flows through Python. + +--- + +## PR-C4 — `/v1/responses` streaming + Conversations API awareness + +**Branch:** `realign-C4-rust-responses-streaming` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-C4-rust-responses-streaming` +**Risk:** **MEDIUM-HIGH** +**LOC:** +800 + +### Scope +Streaming for `/v1/responses` using the SSE state machine from PR-C1. Plus first-class awareness of the Conversations API (P4-40) — when `conversation: {"id": "conv_..."}` is in the body, the local view is incomplete; tokenizer must adjust or skip compression decisions. + +### Files + +**Modify:** +- `crates/headroom-proxy/src/handlers/responses.rs` — when `Accept: text/event-stream`, route to streaming handler. The streaming handler runs the `OpenAIResponsesStreamState` machine in parallel with byte-passthrough. +- `crates/headroom-proxy/src/sse/openai_responses.rs` — add usage extraction from `response.completed`. + +**Add:** +- `crates/headroom-proxy/src/conversations.rs` — detect `conversation: {"id": "conv_..."}` in request body. When present, log a warning and disable live-zone compression for that request (until Phase 4 cross-request shared cache lands). Telemetry: `proxy_conversations_api_request_count_total`. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::reasoning_summary_streamed_correctly` +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::function_call_arguments_streamed_byte_equal` +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::out_of_order_items_handled_by_id` +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_completed_usage_captured` +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_failed_handled` +- `crates/headroom-proxy/tests/integration_responses_streaming.rs::response_incomplete_with_max_output_tokens_reason` +- `crates/headroom-proxy/tests/integration_conversations.rs::conversation_id_present_skips_compression_warns` + +### Acceptance criteria + +- All new tests pass. +- The Conversations API warning appears in logs at `INFO` level with a `conversation_id` field. + +### Blocked by + +PR-C3. + +### Blocks + +PR-H1. + +### Rollback + +`git revert`. Streaming Responses routes through Python. + +--- + +## PR-C5 — `responses_converter.py` retirement (Rust handles it natively) + +**Branch:** `realign-C5-retire-responses-converter` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-C5-retire-responses-converter` +**Risk:** **LOW** (cleanup; Rust handler from PR-C3/C4 covers this surface) +**LOC:** -267 / +20 + +### Scope +Delete `headroom/proxy/responses_converter.py` (the Anthropic↔OpenAI Responses↔Chat Completions converter that mishandled `phase`, multi-text-part rebuild, etc.). The Rust handler from PR-C3 handles `/v1/responses` natively without converting between shapes. After this PR lands, no Python code is on the `/v1/responses` request path. + +### Files + +**Delete:** +- `headroom/proxy/responses_converter.py` + +**Modify:** +- `headroom/proxy/handlers/openai.py` — remove imports of `responses_converter`. The compression dispatch path that called the converter to convert Responses items to Chat-Completions messages for compression is gone; Rust handles compression natively. +- `tests/test_responses_converter*.py` — delete all (Rust tests at `crates/headroom-proxy/tests/integration_responses.rs` cover the surface). + +### Acceptance criteria + +- `pytest -x` green. +- `git grep responses_converter headroom/` returns nothing. + +### Blocked by + +PR-C3, PR-C4. + +### Blocks + +PR-H1. + +### Rollback + +`git revert`. Python converter returns; Rust handler stays in place; both run side-by-side temporarily — but the Rust path is canonical. + +--- + +## Phase C acceptance summary + +After all 5 PRs land: + +- ✅ Byte-level SSE parser with full state machine (handles UTF-8 split, ping, [DONE], all delta types, mid-stream errors) +- ✅ `/v1/chat/completions` handled in Rust +- ✅ `/v1/responses` HTTP handled in Rust +- ✅ `/v1/responses` streaming handled in Rust (out-of-order items, all event types) +- ✅ Conversations API awareness (warns + skips compression) +- ✅ All Responses item types (V4A, local_shell, phase, compaction, MCP, computer, image_gen, etc.) preserved byte-equal +- ✅ `responses_converter.py` deleted + +**Phase C retires P1-8 through P1-12, P1-14 through P1-17, P4-40, P4-42 through P4-44, P4-47, P4-48, P0-7 (final), P5-51.** diff --git a/REALIGNMENT/06-phase-D-bedrock-vertex.md b/REALIGNMENT/06-phase-D-bedrock-vertex.md new file mode 100644 index 000000000..1b03e9ece --- /dev/null +++ b/REALIGNMENT/06-phase-D-bedrock-vertex.md @@ -0,0 +1,222 @@ +# Phase D — Bedrock & Vertex Native Envelopes + +**Goal:** Replace the fake LiteLLM-based Bedrock/Vertex paths (which lossy-convert Anthropic↔OpenAI shapes) with native handlers in the Rust proxy. After Phase D, Anthropic-on-Bedrock and Anthropic-on-Vertex preserve `thinking`, `redacted_thinking`, `document`, `search_result`, `image`, `server_tool_use`, `mcp_tool_use` blocks AND benefit from the live-zone compression engine. + +**Calendar:** 2 weeks. SigV4 + EventStream are the bulk of the work. + +**Shape:** 4 PRs. D1+D2+D3 are AWS Bedrock; D4 is GCP Vertex. + +--- + +## PR-D1 — Native Bedrock InvokeModel route (non-streaming) + +**Branch:** `realign-D1-bedrock-native-invoke` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-D1-bedrock-native-invoke` +**Risk:** **HIGH** (new auth + envelope surface) +**LOC:** +1500 + +### Scope +Eliminate part of P4-37 and P4-39. Add `POST /model/{model}/invoke` route to the Rust proxy. Recognizes the Bedrock envelope (`anthropic_version` body field, no `model` field, AWS SigV4 auth). Forwards the (possibly compressed) request to the Bedrock endpoint with re-signed SigV4. Live-zone compression runs the same as for direct Anthropic. + +### Files + +**Add:** +- `crates/headroom-proxy/src/bedrock/mod.rs` — module re-exports. +- `crates/headroom-proxy/src/bedrock/sigv4.rs` — AWS SigV4 signing. Use the `aws-sigv4` crate. Sign over the (possibly modified) request body bytes. Critical: sign **after** Headroom finishes mutating the body, so the signature matches what Bedrock receives. +- `crates/headroom-proxy/src/bedrock/invoke.rs` — POST handler for `/model/{model}/invoke`. Detects `anthropic.claude-*` model IDs; routes to live-zone compression for Anthropic shape; signs and forwards. +- `crates/headroom-proxy/src/bedrock/envelope.rs` — `BedrockEnvelope` struct: parses `{"anthropic_version": "...", ...rest_of_anthropic_body}`. Re-emits in Bedrock shape with `anthropic_version` preserved as the first key. + +**Modify:** +- `crates/headroom-proxy/src/lib.rs` — route `/model/{model}/invoke` and `/model/{model}/converse` (POST) to the new handler. +- `crates/headroom-proxy/src/config.rs` — add `--bedrock-region` flag (default `us-east-1`) and AWS credential config (uses `aws-config` crate's default chain). +- `Cargo.toml` workspace — add `aws-sigv4`, `aws-config`, `aws-credential-types`. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::native_envelope_round_trip_byte_equal` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::sigv4_signed_correctly_after_compression` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::thinking_block_preserved_through_bedrock` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::redacted_thinking_preserved` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::document_block_preserved` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::tool_result_array_with_image_preserved` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::stop_sequence_null_only_when_present` +- `crates/headroom-proxy/tests/integration_bedrock_invoke.rs::tool_use_input_byte_equal_preserves_key_order` + +### Acceptance criteria + +- All new tests pass. +- Manual test against a real Bedrock endpoint (developer's AWS account) succeeds. +- Existing fake Bedrock path (`headroom/backends/litellm.py`) still works in Python; this PR adds the Rust path alongside. + +### Blocked by + +PR-C1. + +### Blocks + +PR-D2, PR-D3, PR-H2. + +### Rollback + +`git revert`. Bedrock requests fall back to Python LiteLLM converter (the fake path). No regression for users who weren't using Rust Bedrock. + +### Notes + +- The SigV4 signing scope: `host`, `x-amz-date`, `x-amz-content-sha256` headers + canonical request body. Compute the body hash AFTER any compression mutations. +- `accept-encoding` is preserved end-to-end (PAYG/OAuth/subscription all preserve it for Bedrock — there's no legacy CLI to mimic; the Bedrock SDK negotiates compression natively). + +--- + +## PR-D2 — Bedrock streaming via binary EventStream + +**Branch:** `realign-D2-bedrock-event-stream` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-D2-bedrock-event-stream` +**Risk:** **HIGH** (binary protocol, not SSE) +**LOC:** +1100 + +### Scope +Add `POST /model/{model}/invoke-with-response-stream` route. Bedrock's streaming uses **binary EventStream** (vnd.amazon.eventstream content type), not SSE. Build a parser/forwarder for it. Translate to Anthropic SSE for Anthropic-shape responses (so the existing `AnthropicStreamState` from PR-C1 can run telemetry). + +### Files + +**Add:** +- `crates/headroom-proxy/src/bedrock/eventstream.rs` — EventStream binary parser. Format: 12-byte prelude (length + headers length + CRC32 of prelude), N bytes of headers, payload, 4-byte CRC32 of message. Parse incrementally; yield `EventStreamMessage { headers: HashMap, payload: Bytes }`. +- `crates/headroom-proxy/src/bedrock/eventstream_to_sse.rs` — for Anthropic-shape Bedrock responses, each `EventStreamMessage` whose `:event-type` header is `chunk` carries an Anthropic SSE event in its payload. Re-emit as SSE to the client. (Or pass through as EventStream — choose based on the `Accept` header from the client.) +- `crates/headroom-proxy/src/bedrock/invoke_streaming.rs` — POST handler. + +**Modify:** +- `crates/headroom-proxy/src/lib.rs` — route `/model/{model}/invoke-with-response-stream`. +- `crates/headroom-proxy/src/sse/anthropic.rs` — accept events from EventStream-translated source. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::eventstream_parses_correctly` +- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::eventstream_translated_to_sse` +- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::usage_extracted_from_translated_stream` +- `crates/headroom-proxy/tests/integration_bedrock_streaming.rs::client_can_choose_eventstream_or_sse` +- Property test: `proptest! { fn eventstream_parser_no_panic(bytes in any::>()) { let _ = parse(bytes); } }` + +### Acceptance criteria + +- All tests pass. +- Manual test against real Bedrock streaming endpoint succeeds. + +### Blocked by + +PR-D1. + +### Blocks + +PR-H2. + +### Rollback + +`git revert`. Streaming Bedrock falls back to Python LiteLLM. + +--- + +## PR-D3 — Bedrock-side observability + auth-mode integration + +**Branch:** `realign-D3-bedrock-observability` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-D3-bedrock-observability` +**Risk:** **LOW** +**LOC:** +400 + +### Scope +Per-Bedrock-model metrics, region tagging, IAM role attribution, and integration with auth-mode policy (Bedrock IAM = "oauth" mode by default; passthrough-prefer compression). + +### Files + +**Modify:** +- `crates/headroom-proxy/src/bedrock/invoke.rs` — auth_mode classification: when an inbound request hits `/model/.../invoke`, classify as `AuthMode::OAuth` for compression policy. +- `crates/headroom-proxy/src/observability/prometheus.rs` — add `bedrock_invoke_count_total{model, region}`, `bedrock_invoke_latency_seconds`, `bedrock_eventstream_message_count_total`. + +**Add:** +- `docs/bedrock.md` — operator docs: how to configure AWS credentials, what models are supported (any `anthropic.claude-*`), what compression behavior to expect (live-zone-only, lossless preferred). + +**Tests added:** +- `crates/headroom-proxy/tests/integration_bedrock_authmode.rs::bedrock_classified_as_oauth` +- `crates/headroom-proxy/tests/integration_bedrock_authmode.rs::oauth_policy_passthrough_prefer` + +### Acceptance criteria + +- Tests pass. +- Prometheus scrape includes Bedrock metrics. + +### Blocked by + +PR-D2, PR-F1 (auth-mode helper). + +### Blocks + +PR-H2. + +### Rollback + +`git revert`. Loses Bedrock observability; functional path unchanged. + +--- + +## PR-D4 — Native Vertex publisher path + +**Branch:** `realign-D4-vertex-native` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-D4-vertex-native` +**Risk:** **HIGH** (new auth + envelope surface) +**LOC:** +1300 + +### Scope +Eliminate P4-38, P4-39 (Vertex parts). Add `POST /v1beta1/projects/{project}/locations/{loc}/publishers/anthropic/models/{model}:rawPredict` and `:streamRawPredict` routes. Vertex auth is GCP ADC (Application Default Credentials) → bearer token. Envelope: `anthropic_version` body field, no `model` field, GCP auth header. + +### Files + +**Add:** +- `crates/headroom-proxy/src/vertex/mod.rs` — module re-exports. +- `crates/headroom-proxy/src/vertex/adc.rs` — GCP ADC bearer token resolution. Use `gcp_auth` crate. +- `crates/headroom-proxy/src/vertex/raw_predict.rs` — POST handler. +- `crates/headroom-proxy/src/vertex/stream_raw_predict.rs` — streaming handler. Vertex uses SSE for streaming (unlike Bedrock); the existing `AnthropicStreamState` from PR-C1 works directly. + +**Modify:** +- `crates/headroom-proxy/src/lib.rs` — route Vertex paths. +- `Cargo.toml` workspace — add `gcp_auth`. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::native_envelope_round_trip_byte_equal` +- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::adc_bearer_token_signed_correctly` +- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::thinking_block_preserved` +- `crates/headroom-proxy/tests/integration_vertex_raw_predict.rs::stream_raw_predict_sse_handled` + +### Acceptance criteria + +- All tests pass. +- Manual test against a real Vertex endpoint succeeds. + +### Blocked by + +PR-C1, PR-D1 (envelope pattern). + +### Blocks + +PR-H2. + +### Rollback + +`git revert`. Vertex requests fall back to LiteLLM Python. No regression for non-Rust-Vertex users. + +--- + +## Phase D acceptance summary + +After all 4 PRs land: + +- ✅ Native Bedrock `/model/{model}/invoke` route in Rust +- ✅ Native Bedrock `/model/{model}/invoke-with-response-stream` (binary EventStream parsed and translated) +- ✅ SigV4 signing post-compression +- ✅ All Anthropic block types preserved through Bedrock (thinking, redacted_thinking, document, search_result, image, server_tool_use, mcp_tool_use) +- ✅ `stop_sequence: null` no longer hardcoded +- ✅ `tool_calls.function.arguments` preserved as string +- ✅ Native Vertex `:rawPredict` and `:streamRawPredict` routes +- ✅ ADC bearer token resolution +- ✅ Bedrock/Vertex classified as `AuthMode::OAuth` (passthrough-prefer compression) +- ✅ Per-Bedrock-model and per-Vertex-model Prometheus metrics + +**Phase D retires P4-37, P4-38, P4-39, P4-43.** Marketplace BYOC pitch (per project memory) becomes real. + +After Phase D, the LiteLLM Python converter is no longer on the request path for Bedrock/Vertex — Phase H deletes it. diff --git a/REALIGNMENT/07-phase-E-cache-stabilization.md b/REALIGNMENT/07-phase-E-cache-stabilization.md new file mode 100644 index 000000000..554928adc --- /dev/null +++ b/REALIGNMENT/07-phase-E-cache-stabilization.md @@ -0,0 +1,338 @@ +# Phase E — Phase 3 Cache Stabilization + +**Goal:** Add the cache-stabilization surface that today is **completely missing**: tool array deterministic sort, recursive JSON Schema key sort, auto `cache_control` placement (Anthropic), `prompt_cache_key` auto-injection (OpenAI), volatile-content detector with customer warning (no rewrite), cache-bust drift telemetry. These are guide §8.5, §9.11, §6.2, §4.17 implementations and the "Phase 3" of the guide's implementation checklist. + +**Calendar:** 1 week. + +**Shape:** 6 PRs. Mostly parallel; E3 + E4 should land paired (per-mode policy). + +--- + +## PR-E1 — Tool array deterministic sort (Rust) + +**Branch:** `realign-E1-tool-array-sort` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E1-tool-array-sort` +**Risk:** **LOW** +**LOC:** +200 + +### Scope +Eliminate P3-28. Sort `tools[]` alphabetically by name on the way out. Idempotent: re-sorting an already-sorted array is a no-op. Implementation matches Python's existing `_sort_tools_deterministically` (`headroom/proxy/handlers/anthropic.py:34-58`) — same sort key, same output bytes (modulo serialization). + +### Files + +**Add:** +- `crates/headroom-proxy/src/compression/tool_def_normalize.rs`: + ```rust + pub fn sort_tools_deterministically(tools: &mut Vec<&RawValue>) -> Result<()> { + // Sort key: tool["name"] string, fallback to MD5(serialized) for unnamed tools. + tools.sort_by_key(|t| { + let parsed: serde_json::Value = serde_json::from_str(t.get()).unwrap_or_default(); + parsed.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string() + }); + Ok(()) + } + ``` + +**Modify:** +- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — call `sort_tools_deterministically` on the request body's `tools` array before forwarding (only on PAYG; gated by Phase F PR-F2). +- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — same. +- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_tool_sort.rs::sort_alphabetic_by_name` +- `crates/headroom-proxy/tests/integration_tool_sort.rs::idempotent_resort_no_change` +- `crates/headroom-proxy/tests/integration_tool_sort.rs::byte_stable_across_runs` + +### Acceptance criteria + +- Tests pass. +- Output `tools[]` byte-equal between Rust and Python sort implementations. + +### Blocked by + +PR-B2. + +### Blocks + +PR-E2. + +### Rollback + +`git revert`. Rust path matches client's tool order (subject to the cache-bust risk). + +--- + +## PR-E2 — Recursive JSON Schema key sort + +**Branch:** `realign-E2-schema-key-sort` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E2-schema-key-sort` +**Risk:** **MEDIUM** (recursive sort over tool schemas; risk of breaking schema semantics if there's an `if`/`then`/`else` or `oneOf` ordering invariant — there isn't, but verify) +**LOC:** +300 + +### Scope +Eliminate P3-29. Recursively sort JSON Schema object keys in every tool's `input_schema`. This includes nested `properties`, `definitions`, `oneOf`, `anyOf`, `allOf`, `if/then/else`, `additionalProperties`, etc. + +### Files + +**Modify:** +- `crates/headroom-proxy/src/compression/tool_def_normalize.rs` — add `sort_schema_keys_recursive`. Walks every Object node; replaces with `IndexMap` rebuilt in alphabetic key order. Preserves Array order (JSON Schema arrays are ordered: `prefixItems`, `oneOf` alternatives, etc.). + +**Tests added:** +- `crates/headroom-proxy/tests/integration_schema_sort.rs::flat_schema_keys_sorted` +- `crates/headroom-proxy/tests/integration_schema_sort.rs::nested_properties_sorted` +- `crates/headroom-proxy/tests/integration_schema_sort.rs::oneof_array_order_preserved` +- `crates/headroom-proxy/tests/integration_schema_sort.rs::definitions_keys_sorted` +- `crates/headroom-proxy/tests/integration_schema_sort.rs::idempotent_resort` + +### Acceptance criteria + +- Tests pass. +- Snapshot test on a real production tool schema (e.g., Claude Code's `Read` tool) — pin the sorted bytes. + +### Blocked by + +PR-E1. + +### Blocks + +PR-E5. + +### Rollback + +`git revert`. Schema keys reflect customer ordering. + +--- + +## PR-E3 — Auto `cache_control` breakpoint placement (Anthropic) + +**Branch:** `realign-E3-cache-control-auto-place` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E3-cache-control-auto-place` +**Risk:** **MEDIUM-HIGH** (auto-adds bytes to client request — only on PAYG) +**LOC:** +400 + +### Scope +Eliminate P3-31. When PAYG mode is detected (Phase F PR-F1) and the customer has not set any `cache_control` markers, auto-place up to 4 ephemeral markers at: +1. End of system prompt (1 marker) +2. End of `tools[]` (1 marker) +3. After the last stable conversation history boundary (1 marker; configurable threshold for "stable") +4. Before the latest user message (1 marker) + +OAuth and subscription modes: never auto-place (could void scope). + +### Files + +**Add:** +- `crates/headroom-proxy/src/compression/cache_control.rs` — `pub fn auto_place_breakpoints(body: &mut serde_json::Value, auth_mode: AuthMode)`. Walks the structure; appends `cache_control: {type: "ephemeral"}` to the trailing block of system, tools, history, and current user message. + +**Modify:** +- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — call `auto_place_breakpoints` on PAYG only. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::payg_auto_places_4_markers` +- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::oauth_no_auto_placement` +- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::subscription_no_auto_placement` +- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::customer_set_markers_respected_no_addition` +- `crates/headroom-proxy/tests/integration_cache_control_auto.rs::ttl_ordering_correct_1h_before_5m` + +### Acceptance criteria + +- Tests pass. +- Customer requests with existing markers are unmodified. +- A representative PAYG request gets 4 markers in the right positions. + +### Blocked by + +PR-A4, PR-F1, PR-F2. + +### Blocks + +None. + +### Rollback + +`git revert`. Customers without their own `cache_control` markers don't get auto-placement; cache benefit smaller but correct. + +--- + +## PR-E4 — `prompt_cache_key` auto-injection (OpenAI) + +**Branch:** `realign-E4-prompt-cache-key-inject` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E4-prompt-cache-key-inject` +**Risk:** **MEDIUM** +**LOC:** +250 + +### Scope +Eliminate P3-30. For OpenAI Chat Completions and Responses requests on PAYG mode where the customer has not set `prompt_cache_key`, auto-inject one derived from a stable session hash. OAuth/subscription modes: never inject (the CLI may already populate this and Headroom must preserve byte-for-byte). + +### Files + +**Modify:** +- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — add `inject_prompt_cache_key` step. +- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same. + +**Add:** +- `crates/headroom-proxy/src/session.rs` — `pub fn derive_prompt_cache_key(session_id: &str, model: &str) -> String` — returns `{session_id}_{model_family}` (deterministic per session+model). + +**Tests added:** +- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::payg_auto_injects_when_absent` +- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::customer_value_preserved` +- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::oauth_no_injection` +- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::subscription_no_injection` +- `crates/headroom-proxy/tests/integration_prompt_cache_key.rs::same_session_same_key_deterministic` + +### Acceptance criteria + +- Tests pass. + +### Blocked by + +PR-F1, PR-F2. + +### Blocks + +None. + +### Rollback + +`git revert`. OpenAI cache routing less sticky on PAYG; functional. + +--- + +## PR-E5 — Volatile-content detector with customer warning (no rewrite) + +**Branch:** `realign-E5-volatile-detector` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E5-volatile-detector` +**Risk:** **LOW** +**LOC:** +400 + +### Scope +Eliminate P3-32. Detect dynamic content in the early prompt (timestamps, UUIDs, JWT tokens, build hashes, randomized IDs) and surface to the customer via a log line and a Prometheus metric. **Do not rewrite.** This is what Python's `cache_aligner.py` was *trying* to do correctly (the rewrite path is gone in Phase A PR-A2). + +### Files + +**Add:** +- `crates/headroom-core/src/transforms/volatile_detector.rs`: + ```rust + pub struct VolatileDetector { /* compiled regex set */ } + + impl VolatileDetector { + pub fn scan(&self, content: &str) -> Vec; + } + + pub struct VolatileFinding { + pub kind: VolatileKind, // Timestamp | Uuid | Jwt | BuildHash | ... + pub byte_offset: usize, + pub matched: String, + pub recommendation: String, // "Move to metadata" + } + ``` + Patterns: + - ISO 8601 timestamp regex + - UUID v4 regex + - JWT shape (`eyJ...`) + - Long hex strings >=32 chars (build hashes) + - Unix epoch timestamps within an order of magnitude of `now` + +**Modify:** +- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — run scanner on system prompt; if findings, log warning and increment metric. +- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — same on `instructions` field. +- `crates/headroom-proxy/src/compression/live_zone_responses.rs` — same. + +**Tests added:** +- `crates/headroom-core/tests/volatile_detector.rs::detects_iso_8601_timestamp` +- `crates/headroom-core/tests/volatile_detector.rs::detects_uuid_v4` +- `crates/headroom-core/tests/volatile_detector.rs::detects_jwt_shape` +- `crates/headroom-core/tests/volatile_detector.rs::detects_build_hash` +- `crates/headroom-core/tests/volatile_detector.rs::no_false_positives_on_normal_prose` +- `crates/headroom-proxy/tests/integration_volatile.rs::warning_logged_on_volatile_system_prompt` +- `crates/headroom-proxy/tests/integration_volatile.rs::system_prompt_bytes_unchanged` + +### Acceptance criteria + +- Tests pass. +- Detector runs in <1ms on a 4KB system prompt (don't slow the request path). + +### Blocked by + +PR-E2. + +### Blocks + +None. + +### Rollback + +`git revert`. Detector loses; no functional regression. + +--- + +## PR-E6 — Cache-bust drift detector telemetry + +**Branch:** `realign-E6-cache-bust-detector` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-E6-cache-bust-detector` +**Risk:** **LOW** +**LOC:** +500 + +### Scope +Eliminate P3-35. Hash the prefix (system + tools + first N stable messages) of every request. Track per-session prefix hashes. When the prefix hash changes between turns, increment a counter and log a `prefix_drift` event with which subsystem mutated (likely none after Phase A — but if it happens, we want to know). + +### Files + +**Add:** +- `crates/headroom-proxy/src/observability/prefix_drift.rs`: + ```rust + pub struct PrefixDriftDetector { + // Keyed by session_id; stores last-seen prefix hash + timestamp. + cache: Cache, + } + + impl PrefixDriftDetector { + pub fn check(&self, session_id: &str, body: &serde_json::Value) -> DriftCheck; + } + + pub enum DriftCheck { + FirstSeen, + Stable, + Drifted { previous_hash: PrefixHash, new_hash: PrefixHash, age: Duration }, + } + ``` + +**Modify:** +- `crates/headroom-proxy/src/observability/prometheus.rs` — add `prefix_drift_detected_total{provider, model}` counter. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_prefix_drift.rs::stable_prefix_no_drift` +- `crates/headroom-proxy/tests/integration_prefix_drift.rs::system_change_detected_as_drift` +- `crates/headroom-proxy/tests/integration_prefix_drift.rs::tools_reorder_detected` + +### Acceptance criteria + +- Tests pass. +- A canary request that mutates the system prompt mid-session triggers the counter. + +### Blocked by + +PR-B2. + +### Blocks + +None. + +### Rollback + +`git revert`. Loses observability; no functional regression. + +--- + +## Phase E acceptance summary + +After all 6 PRs land: + +- ✅ Tools alphabetically sorted (deterministic, idempotent) +- ✅ JSON Schema keys recursively sorted +- ✅ `cache_control` auto-placement on PAYG (4 markers) +- ✅ `prompt_cache_key` auto-injection on PAYG (OpenAI) +- ✅ Volatile-content detector + customer warning (no rewrite) +- ✅ Cache-bust drift telemetry per session + +**Phase E retires P3-28 through P3-32, P3-35.** diff --git a/REALIGNMENT/08-phase-F-auth-mode.md b/REALIGNMENT/08-phase-F-auth-mode.md new file mode 100644 index 000000000..c12e310a8 --- /dev/null +++ b/REALIGNMENT/08-phase-F-auth-mode.md @@ -0,0 +1,254 @@ +# Phase F — Auth-Mode Policy Gates + +**Goal:** Make auth mode a first-class policy axis. Detect (PAYG / OAuth / subscription) at request entry; gate compression behavior, header injection, and TOIN aggregation per mode. Stealth mode for subscription CLIs. + +**Calendar:** 1 week. + +**Shape:** 4 PRs. F1 first; F2 + F3 + F4 parallel after. + +Reference: `~/.claude/projects/-Users-tchopra-claude-projects-headroom/memory/project_auth_mode_compression_nuances.md`. + +--- + +## PR-F1 — `classify_auth_mode` helper + +**Branch:** `realign-F1-classify-auth-mode` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-F1-classify-auth-mode` +**Risk:** **LOW** +**LOC:** +500 + +### Scope +Single helper called at request entry returning `AuthMode = Payg | OAuth | Subscription`. Pure-function classification from headers (Authorization shape, OpenAI-Beta, anthropic-beta) and User-Agent prefix. + +### Files + +**Add:** +- `crates/headroom-core/src/auth_mode.rs`: + ```rust + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum AuthMode { Payg, OAuth, Subscription } + + pub fn classify(headers: &http::HeaderMap) -> AuthMode { + let ua = headers.get("user-agent").and_then(|h| h.to_str().ok()).unwrap_or("").to_lowercase(); + const SUBSCRIPTION_UA_PREFIXES: &[&str] = &[ + "claude-cli/", "claude-code/", "codex-cli/", "cursor/", + "claude-vscode/", "github-copilot/", "anthropic-cli/", "antigravity/", + ]; + if SUBSCRIPTION_UA_PREFIXES.iter().any(|p| ua.contains(p)) { + return AuthMode::Subscription; + } + + let auth = headers.get("authorization").and_then(|h| h.to_str().ok()).unwrap_or(""); + if auth.starts_with("Bearer ") { + let token = &auth[7..]; + if token.starts_with("sk-ant-api") || token.starts_with("sk-") { + return AuthMode::Payg; + } + if token.starts_with("sk-ant-oat-") || token.split('.').count() >= 3 { + // sk-ant-oat-* (Claude Pro OAuth) or JWT (Codex/Cursor OAuth) + return AuthMode::OAuth; + } + } + + // Bedrock / Vertex (no Authorization header from client; signed downstream) + if !auth.is_empty() == false + && headers.get("x-api-key").is_none() + && headers.get("x-goog-api-key").is_none() + { + return AuthMode::OAuth; + } + + // x-api-key present (Anthropic API key style) + if headers.contains_key("x-api-key") { + return AuthMode::Payg; + } + + AuthMode::Payg // default + } + ``` + +**Add (Python):** +- `headroom/proxy/auth_mode.py` — Python port of the same logic. Used in Python paths until Phase H deletes them. + +**Modify:** +- `crates/headroom-core/src/lib.rs` — `pub mod auth_mode;`. +- `crates/headroom-proxy/src/proxy.rs` — call `classify` at request entry; store in request extensions for downstream handlers. +- `headroom/proxy/handlers/anthropic.py` — call Python `classify_auth_mode(headers)` at request entry. +- `headroom/proxy/handlers/openai.py` — same. + +**Tests added:** +- `crates/headroom-core/tests/auth_mode.rs::api_key_classified_payg` +- `crates/headroom-core/tests/auth_mode.rs::oauth_jwt_classified_oauth` +- `crates/headroom-core/tests/auth_mode.rs::oauth_sk_ant_oat_classified_oauth` +- `crates/headroom-core/tests/auth_mode.rs::claude_code_ua_classified_subscription` +- `crates/headroom-core/tests/auth_mode.rs::cursor_ua_classified_subscription` +- `crates/headroom-core/tests/auth_mode.rs::no_auth_no_user_agent_default_payg` +- `crates/headroom-core/tests/auth_mode.rs::bedrock_no_auth_classified_oauth` +- Python equivalents in `tests/test_auth_mode.py`. + +### Acceptance criteria + +- Tests pass. +- Detection runs in <10us per call. +- Documented in `docs/auth-modes.md` with the detection rules and how to extend. + +### Blocked by + +None. + +### Blocks + +PR-F2, PR-F3, PR-F4. + +### Rollback + +`git revert`. All requests treated as PAYG (current behavior). + +--- + +## PR-F2 — Per-mode compression policy gates + +**Branch:** `realign-F2-per-mode-policy` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-F2-per-mode-policy` +**Risk:** **MEDIUM-HIGH** (changes compression behavior per request) +**LOC:** +600 + +### Scope +Wire `AuthMode` into every compression decision per the policy matrix in `02-architecture.md §2.4`. PAYG = aggressive (current default). OAuth = passthrough-prefer (no auto-`cache_control`, no auto-`prompt_cache_key`, no lossy compressors). Subscription = stealth (everything OAuth does + preserve `accept-encoding`, never strip; never inject `X-Headroom-*`; never mutate User-Agent). + +### Files + +**Modify:** +- `crates/headroom-proxy/src/compression/live_zone_anthropic.rs` — gate `auto_place_breakpoints` on `auth_mode == Payg`. +- `crates/headroom-proxy/src/compression/live_zone_openai.rs` — gate `inject_prompt_cache_key` on `auth_mode == Payg`. +- `crates/headroom-proxy/src/compression/live_zone.rs` — gate lossy compressors (Kompress text) on `auth_mode == Payg`. OAuth and Subscription get lossless-only compression. +- `crates/headroom-proxy/src/headers.rs:103-117` — `add_x_forwarded_headers` becomes `add_x_forwarded_headers_if(auth_mode)`. Skip on Subscription. +- `crates/headroom-proxy/src/proxy.rs` — `accept-encoding` strip becomes conditional on `auth_mode != Subscription`. +- `headroom/proxy/handlers/anthropic.py` — gate Python compression decisions identically. +- `headroom/proxy/handlers/openai.py` — same. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::payg_aggressive_compression` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_no_auto_cache_control` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_no_auto_prompt_cache_key` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::oauth_lossless_only` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_no_x_forwarded` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_preserves_accept_encoding` +- `crates/headroom-proxy/tests/integration_authmode_policy.rs::subscription_lossless_only` + +### Acceptance criteria + +- Tests pass. +- Manual smoke test: a real Claude Code session through the proxy produces no `X-Forwarded-*` upstream and preserves `accept-encoding`. + +### Blocked by + +PR-F1, PR-E3, PR-E4. + +### Blocks + +None. + +### Rollback + +`git revert`. All requests treated as PAYG. No functional regression for PAYG users; OAuth/Subscription users may see scope-rejection or revocation increase. + +--- + +## PR-F3 — TOIN per-tenant aggregation key + +**Branch:** `realign-F3-toin-per-tenant` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-F3-toin-per-tenant` +**Risk:** **MEDIUM** +**LOC:** -200 / +400 + +### Scope +Eliminate P5-56. Extend TOIN's aggregation key from `structure_hash` to `(auth_mode, model_family, structure_hash)`. Storage key prefix updated; in-memory dicts re-keyed. Existing observations from before this PR are preserved under a `legacy/` prefix and deprecated over the next 30 days. + +### Files + +**Modify:** +- `headroom/telemetry/toin.py:103` — `Pattern` adds `auth_mode: str = "unknown"`, `model_family: str = "unknown"`. (Already covered by Phase B PR-B5; this PR ensures the wiring lands.) +- `headroom/telemetry/toin.py:477, 496, 727, 729, 1248, 1256` — change aggregation key to tuple. +- `headroom/telemetry/toin.py` — migration helper that walks the legacy `structure_hash`-only store and copies entries under `("unknown", "unknown", structure_hash)` for graceful degrade. +- `headroom/telemetry/toin.py` — bumping aggregation key invalidates earlier recommendations; re-publish via the deploy CLI. +- `headroom/subscription/tracker.py:166` — replace `_current_token: str` (raw OAuth bearer storage) with `_current_token_id: str` (a one-way hash + last-4 chars for debugging). Polling code adapts to use the actual `Authorization` header per request rather than the stored copy. + +**Tests added:** +- `tests/test_toin_per_tenant.py::test_aggregation_key_includes_auth_mode_model` +- `tests/test_toin_per_tenant.py::test_legacy_observations_preserved_under_unknown` +- `tests/test_toin_per_tenant.py::test_publish_per_auth_mode_writes_separate_recommendations` +- `tests/test_subscription_tracker_token_hardening.py::test_raw_token_not_stored_in_memory` + +### Acceptance criteria + +- Tests pass. +- Recommendations file becomes structured `recommendations.toml` with sections per `(auth_mode, model_family)`. +- The subscription tracker token-leak risk closed. + +### Blocked by + +PR-B5, PR-F1. + +### Blocks + +None. + +### Rollback + +`git revert`. TOIN reverts to global aggregation. No functional break. + +--- + +## PR-F4 — `X-Forwarded-*` conditional in Rust path + +**Branch:** `realign-F4-x-forwarded-conditional` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-F4-x-forwarded-conditional` +**Risk:** **LOW** +**LOC:** +100 + +### Scope +Eliminate P5-53. The Rust proxy currently always adds `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Request-Id` to upstream-bound requests. Make this conditional: PAYG → add; OAuth → add; Subscription → skip (fingerprint risk). + +### Files + +**Modify:** +- `crates/headroom-proxy/src/headers.rs:103-117` — `add_x_forwarded_headers` takes an `AuthMode` parameter; no-ops on Subscription. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::payg_adds_xfwd` +- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::oauth_adds_xfwd` +- `crates/headroom-proxy/tests/integration_x_forwarded_authmode.rs::subscription_no_xfwd` + +### Acceptance criteria + +- Tests pass. + +### Blocked by + +PR-F1. + +### Blocks + +None. + +### Rollback + +`git revert`. Headers always added. Mild fingerprint regression for Subscription users. + +--- + +## Phase F acceptance summary + +After all 4 PRs land: + +- ✅ `classify_auth_mode` helper detects PAYG / OAuth / Subscription +- ✅ Per-mode compression policy gates (auto-cache_control, prompt_cache_key, lossy compressors) +- ✅ TOIN aggregation key per `(auth_mode, model_family, structure_hash)` +- ✅ Subscription tracker doesn't store raw OAuth bearer +- ✅ `X-Forwarded-*` skipped on Subscription mode +- ✅ `accept-encoding` preserved on Subscription mode + +**Phase F retires P5-52, P5-53, P5-54, P5-55, P5-56.** + +After Phase F, fingerprint risk for Subscription CLI users is dramatically reduced. diff --git a/REALIGNMENT/09-phase-G-rtk-observability.md b/REALIGNMENT/09-phase-G-rtk-observability.md new file mode 100644 index 000000000..4b89b7ef7 --- /dev/null +++ b/REALIGNMENT/09-phase-G-rtk-observability.md @@ -0,0 +1,194 @@ +# Phase G — RTK Breadth + Observability + +**Goal:** Extend RTK coverage to more wrap-CLI agents; close the dead `tokens_saved_rtk` data plane; add per-invocation RTK metrics; add the cache-hit-rate, compression-ratio, token-validation observability surface that's missing today. + +**Calendar:** 1 week. + +**Shape:** 3 PRs. + +**Decision context:** Per Agent F audit and 2026-05-01 user direction, **RTK stays on the wrap-CLI side, NOT the proxy side**. Proxy-side invocation is rejected because (a) cache hot zone risk on tool_result content compression, (b) parallel implementation with `crates/headroom-core/src/transforms/log_compressor.rs`, (c) RTK rewrites *commands* not *outputs* — different value proposition. "Integrate RTK with everything" reads as "extend wrap-CLI breadth + close the data plane + observability." + +--- + +## PR-G1 — Wrap CLI breadth: cline, continue, goose, openhands + +**Branch:** `realign-G1-wrap-more-agents` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-G1-wrap-more-agents` +**Risk:** **LOW** +**LOC:** +800 + +### Scope +Eliminate P5-62. Add `headroom wrap cline`, `headroom wrap continue`, `headroom wrap goose`, `headroom wrap openhands` (extending the existing pattern from `wrap claude` / `wrap codex` / `wrap aider` / `wrap copilot` / `wrap cursor`). Each wrap subcommand: +1. Ensures the RTK binary is installed (`_ensure_rtk_binary()`). +2. Injects the `` block into the agent's instruction file (AGENTS.md / .cursorrules / etc.). +3. Spawns the proxy (or attaches to a running one). +4. Launches the agent CLI with proxy env-var overrides. + +### Files + +**Add:** +- `headroom/cli/wrap/cline.py` — wrap implementation for Cline (agent that lives in VS Code; instruction file is `.clinerules`). +- `headroom/cli/wrap/continue_dev.py` — Continue agent (`.continue/config.json` configuration; system message injection). +- `headroom/cli/wrap/goose.py` — Goose agent (Block's CLI; `.goose/config.yaml`). +- `headroom/cli/wrap/openhands.py` — OpenHands (instruction injection via `OPENHANDS_INSTRUCTIONS` env var). + +**Modify:** +- `headroom/cli/wrap/__init__.py` — register new subcommands. +- `headroom/cli/main.py` — `headroom wrap --help` lists new agents. +- `e2e/wrap/run.py` — extend the e2e runner to exercise the new wrappers (each wrapper has a smoke test that asserts: binary installed, instruction injected, proxy started, dummy LLM call works). + +**Tests added:** +- `tests/test_cli/test_wrap_cline.py::test_wrap_cline_smoke` +- `tests/test_cli/test_wrap_continue.py::test_wrap_continue_smoke` +- `tests/test_cli/test_wrap_goose.py::test_wrap_goose_smoke` +- `tests/test_cli/test_wrap_openhands.py::test_wrap_openhands_smoke` +- `tests/test_cli/test_wrap_idempotent_inject.py::test_double_injection_no_duplicate_block` (for each new wrapper) + +### Acceptance criteria + +- Tests pass. +- Manual test: `headroom wrap cline -- claude-3-7-sonnet` launches a Cline session with the proxy in-front and RTK instructions in `.clinerules`. + +### Blocked by + +None. + +### Blocks + +None. + +### Rollback + +`git revert`. Existing wrappers continue working; new ones absent. + +### Notes + +- **Future agents to add later (not in this PR):** Roo Code, Devin-style CLIs, raw `gh copilot` standalone, gpt-engineer, sweep, smol-developer. Add as separate PRs as adoption justifies. + +--- + +## PR-G2 — Wire `tokens_saved_rtk` data plane + +**Branch:** `realign-G2-tokens-saved-rtk` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-G2-tokens-saved-rtk` +**Risk:** **LOW** +**LOC:** +200 + +### Scope +Eliminate P5-60. The `tokens_saved_rtk` field on `SubscriptionContribution` (`headroom/subscription/models.py:260`) exists but is never populated. Wire it: poll `rtk gain --format json` periodically (already done by `_get_rtk_stats` in `helpers.py:132`), diff the cumulative `tokens_saved` since last snapshot, and feed into `tracker.update_session_savings(tokens_saved_rtk=delta)`. + +### Files + +**Modify:** +- `headroom/subscription/tracker.py` — add `_last_rtk_tokens_saved: int = 0` state; on every `update_session_savings` call, fetch `_get_rtk_stats()`, compute `delta = current.tokens_saved - self._last_rtk_tokens_saved`, set `tokens_saved_rtk=delta`, update state. +- `headroom/proxy/helpers.py:132` — `_get_rtk_stats` returns `RtkStats { invocations: int, tokens_saved: int, last_run_at: datetime }`. Memoization stays at 5s. + +**Tests added:** +- `tests/test_subscription_tracker_rtk_wired.py::test_tokens_saved_rtk_populated_from_rtk_stats` +- `tests/test_subscription_tracker_rtk_wired.py::test_delta_computed_correctly_across_polls` +- `tests/test_subscription_tracker_rtk_wired.py::test_rtk_failure_zero_delta_no_throw` + +### Acceptance criteria + +- Tests pass. +- A wrap session with RTK invocations produces `tokens_saved_rtk > 0` after the session ends. + +### Blocked by + +None. + +### Blocks + +None. + +### Rollback + +`git revert`. `tokens_saved_rtk` returns to silent zero. + +--- + +## PR-G3 — Per-invocation RTK metrics + observability gaps + +**Branch:** `realign-G3-rtk-metrics-and-obs` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-G3-rtk-metrics-and-obs` +**Risk:** **LOW** +**LOC:** +600 + +### Scope +Eliminate P6-68, P6-69, P5-58, P4-41, P4-42, P4-45, and P5-61 (documentation). Add Prometheus metrics: +- `wrap_rtk_invocations_total{tool}` — derived from `rtk gain --format json` polling (`tool` label is the `git`, `ls`, `cargo`, etc. command). +- `wrap_rtk_tokens_saved_per_session` — histogram, populated at session end. +- `proxy_cache_hit_rate_per_session` — histogram, computed from `usage.cache_read_input_tokens / total_input_tokens` per session. +- `proxy_compression_ratio_by_strategy{strategy, content_type}` — histogram. +- `proxy_compression_rejected_by_token_check_total{strategy}` — counter (already in PR-B4; ensure it's exported here). +- `proxy_passthrough_bytes_modified_total{path}` — gauge that **must stay 0** outside compression-on path. Alarm if non-zero. +- `proxy_rate_limit_remaining_*` — extracted from upstream response headers. +- `proxy_service_tier_count_total{tier}` — counter for `service_tier` distribution. +- `proxy_response_status_count_total{status}` — `incomplete | failed | cancelled | completed | in_progress`. +- `proxy_image_generation_call_log_redacted_total` — counter for log redactions of multi-MB base64. + +Plus image base64 log redaction (P4-45) lands here. + +### Files + +**Modify:** +- `crates/headroom-proxy/src/observability/prometheus.rs` — add all new metrics. +- `crates/headroom-proxy/src/sse/anthropic.rs` — emit `proxy_cache_hit_rate_per_session` from `usage.cache_read_input_tokens / total_input_tokens` on `message_delta`. +- `crates/headroom-proxy/src/sse/openai_responses.rs` — emit on `response.completed`. +- `crates/headroom-proxy/src/sse/openai_chat.rs` — emit on final usage chunk. +- `crates/headroom-proxy/src/handlers/responses.rs` — extract and log `service_tier`. +- `crates/headroom-proxy/src/handlers/responses.rs` — log `incomplete_details.reason` when `status == incomplete`. +- `headroom/proxy/request_logger.py` — redact base64 strings >1024 bytes; replace with ``. +- `crates/headroom-proxy/src/observability/cache_hit_rate.rs` — new module. +- `crates/headroom-proxy/src/observability/compression_ratio.rs` — new module. + +**Add:** +- `docs/observability.md` — documents every metric, what it means, what an operator should do when it drifts. +- `docs/rtk-architecture.md` — explicitly documents the decision: RTK is wrap-CLI-only; proxy-side invocation is rejected. Includes the rationale (cache hot zone, parallel-impl with log_compressor, command-rewrite-vs-output-rewrite). Future contributors hit this doc before considering a proxy-side RTK call. + +**Tests added:** +- `crates/headroom-proxy/tests/integration_metrics.rs::cache_hit_rate_emitted_per_session` +- `crates/headroom-proxy/tests/integration_metrics.rs::compression_ratio_emitted_per_strategy` +- `crates/headroom-proxy/tests/integration_metrics.rs::passthrough_bytes_modified_zero_when_no_compression` +- `crates/headroom-proxy/tests/integration_metrics.rs::service_tier_logged` +- `crates/headroom-proxy/tests/integration_metrics.rs::incomplete_status_logged_with_reason` +- `tests/test_image_log_redaction.py::test_large_base64_truncated` + +### Acceptance criteria + +- All tests pass. +- Manual scrape of `/metrics` shows the new metric families. +- `docs/rtk-architecture.md` reviewed and approved. + +### Blocked by + +None. + +### Blocks + +None. + +### Rollback + +`git revert`. Loses observability; no functional regression. + +--- + +## Phase G acceptance summary + +After all 3 PRs land: + +- ✅ Wrap CLI coverage extends to cline, continue, goose, openhands +- ✅ `tokens_saved_rtk` field populated end-to-end +- ✅ Per-invocation RTK Prometheus metrics +- ✅ Per-session cache-hit-rate metric +- ✅ Per-block compression-ratio histogram +- ✅ Token-validation rejection counter +- ✅ Passthrough-bytes-modified gauge (alarm-able) +- ✅ Rate-limit headers observed and exported +- ✅ `service_tier` distribution metric +- ✅ Response status (`incomplete | failed | cancelled`) logged with reason +- ✅ Image base64 log redaction +- ✅ `docs/rtk-architecture.md` documents the keep-RTK-on-wrap-side decision + +**Phase G retires P4-41, P4-42, P4-45, P5-58, P5-60, P5-61, P5-62, P6-68, P6-69, P6-72.** diff --git a/REALIGNMENT/10-phase-H-python-retirement.md b/REALIGNMENT/10-phase-H-python-retirement.md new file mode 100644 index 000000000..c34733162 --- /dev/null +++ b/REALIGNMENT/10-phase-H-python-retirement.md @@ -0,0 +1,214 @@ +# Phase H — Python Proxy Retirement + +**Goal:** With Rust at full parity (Phases A–G), delete the Python proxy server, handlers, transforms, and supporting modules. Keep Python only where it's the right tool: CLI wrappers, RTK installer, evals, learn, memory writers, tokenizers (parity backstop). + +**Calendar:** 2 weeks. + +**Shape:** 3 PRs. H1 retires the request-path Python; H2 retires Bedrock/Vertex backend; H3 cleans up. + +**Pre-requisites:** +- Phase A–G complete. +- Real-traffic shadow test (Phase I) shows Rust ≥99.9% byte-equality vs Python on representative traffic. +- Cache-hit-rate parity with direct upstream confirmed (Phase G observability). +- All Bedrock/Vertex paths covered by native Rust handlers (Phase D). + +--- + +## PR-H1 — Retire Python proxy request path + +**Branch:** `realign-H1-retire-python-proxy-request-path` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-H1-retire-python-proxy-request-path` +**Risk:** **HIGH** (production-facing change; canary deploy mandatory) +**LOC:** **-15,000 / +500** + +### Scope +Delete the Python FastAPI server, all handlers, the responses converter (already gone in Phase C PR-C5), memory subsystem (replaced by live-zone tail injection from Phase A PR-A2 and Phase B PR-B6), semantic cache, batch handler, etc. The Rust proxy becomes the canonical request-path implementation. Operators run only `headroom-proxy` (Rust binary). + +### Files + +**Delete:** +- `headroom/proxy/server.py` (2864 LOC) +- `headroom/proxy/handlers/anthropic.py` (2423 LOC) +- `headroom/proxy/handlers/openai.py` (2742 LOC) +- `headroom/proxy/handlers/streaming.py` (1131 LOC) +- `headroom/proxy/handlers/gemini.py` (839 LOC) +- `headroom/proxy/handlers/batch.py` (1010 LOC) +- `headroom/proxy/responses_converter.py` — already deleted in PR-C5; verify gone. +- `headroom/proxy/memory_handler.py` (1756 LOC) +- `headroom/proxy/memory_tool_adapter.py` (1273 LOC) +- `headroom/proxy/semantic_cache.py` (142 LOC) +- `headroom/proxy/savings_tracker.py` (934 LOC) — re-implemented in Rust as part of `observability/`. +- `headroom/proxy/loopback_guard.py` (92 LOC) — Rust equivalent in `crates/headroom-proxy/src/handlers/debug.rs`. +- `headroom/proxy/ws_session_registry.py` (226 LOC) — Rust equivalent. +- `headroom/proxy/interceptors/` — all of this directory. +- `headroom/proxy/cost.py`, `helpers.py`, `rate_limiter.py`, `request_logger.py` — re-implemented in Rust as part of compression dispatch / observability. +- `headroom/proxy/prometheus_metrics.py` — re-implemented in Rust as `observability/prometheus.rs`. +- `headroom/proxy/extensions.py`, `models.py`, `modes.py`, `stage_timer.py`, `warmup.py`, `responses_converter.py`, `debug_introspection.py`. +- `headroom/transforms/cache_aligner.py` — already gutted in Phase A PR-A2; delete the remaining stub. + +**Move:** +- `headroom/proxy/loopback_guard.py` test logic → `crates/headroom-proxy/tests/integration_loopback_guard.rs`. + +**Modify:** +- `headroom/cli/proxy.py` — `headroom proxy start` now spawns the Rust binary (`./target/release/headroom-proxy`) instead of `uvicorn headroom.proxy.server:app`. +- `headroom/cli/wrap/*.py` — same: env-var setup remains, but `proxy_url` points at the Rust binary's listen address. +- `pyproject.toml` — remove `fastapi`, `uvicorn`, `pydantic`, etc. from runtime deps; keep them in dev/test deps for parity harness only. +- `Dockerfile` — drop the Python proxy server stage; the Rust binary is the only proxy. +- `docker-compose.yml` — same. +- `RUST_DEV.md` — promote the Rust proxy from "Phase 1 transparent reverse proxy" to "the proxy." +- All operator runbooks in `wiki/` and `docs/` — update to reference Rust binary. + +**Tests deleted:** +- `tests/test_proxy_*.py` — most of these (which test the Python proxy directly). Keep tests that exercise CLI wrappers, RTK, evals, learn, memory writers, tokenizers. +- Roughly 150 test files; keep ~40 that don't depend on the Python proxy. + +**Tests added:** +- `e2e/proxy_full/test_e2e_canary.py` — deploys the Rust binary in a container; runs a full conversation suite; asserts cache hit rate, compression value, no 5xx errors. Run pre-merge in CI. + +### Acceptance criteria + +- `pytest -x` green (after deletions). +- `cargo test --workspace` green. +- `make ci-precheck` green. +- E2E canary in CI passes. +- Manual test: `headroom proxy start` boots the Rust binary; `curl -s http://127.0.0.1:8787/healthz` returns OK. +- Operator deploys the new image to staging; cache hit rate ≥ Python baseline; no 5xx regressions in 24h. + +### Blocked by + +PR-A1 through PR-G3. + +### Blocks + +PR-H2. + +### Rollback + +`git revert` of just this PR restores the Python proxy. **Critical**: keep the previous container image around for at least 30 days so operators can pin to the pre-H1 image. Document the rollback path in `docs/operations/rollback.md`. + +### Notes + +- This is the largest single PR in the realignment. Coordinate with operations team. +- Do NOT delete in one giant commit; split into a series of smaller commits within the PR (one per module deletion) for git-blame friendliness. + +--- + +## PR-H2 — Retire LiteLLM Bedrock/Vertex backend + +**Branch:** `realign-H2-retire-litellm-backends` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-H2-retire-litellm-backends` +**Risk:** **MEDIUM** +**LOC:** **-3,000 / +100** + +### Scope +After Phase D PR-D1..D4 added native Bedrock/Vertex Rust paths, the LiteLLM Python converter is no longer on any request path. Delete it. + +### Files + +**Delete:** +- `headroom/backends/litellm.py` (~1500 LOC, the lossy converter) +- `headroom/backends/__init__.py` if the only contents was the LiteLLM backend. + +**Modify:** +- `pyproject.toml` — remove `litellm` from dependencies. (Saves ~50 MB of installed-deps size.) +- `headroom/providers/registry.py` — delete `litellm-bedrock`, `litellm-vertex` provider entries. +- `headroom/cli/wrap/*` — verify no wrap CLIs route to LiteLLM (they shouldn't; they go through the proxy). + +**Tests deleted:** +- `tests/test_backends_litellm*.py` + +### Acceptance criteria + +- `pytest -x` green. +- A real Bedrock request through the Rust proxy succeeds (already validated in Phase D PR-D1 manual test). + +### Blocked by + +PR-H1, PR-D1, PR-D2, PR-D4. + +### Blocks + +PR-H3. + +### Rollback + +`git revert`. Restores LiteLLM. The Rust native paths from Phase D stay in place; both run side-by-side temporarily. + +--- + +## PR-H3 — Final cleanup: orphaned modules, deps, docs + +**Branch:** `realign-H3-final-cleanup` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-H3-final-cleanup` +**Risk:** **LOW** +**LOC:** -2,000 / +500 + +### Scope +Sweep up everything orphaned by H1+H2: unused imports, dead test fixtures, stale docs, legacy CLI commands. Update all README / wiki / docs to reflect the Rust-only proxy. + +### Files + +**Modify:** +- `README.md` — operator-facing docs reflect the Rust binary. +- `wiki/` — refresh. +- `docs/` — refresh. +- `RUST_DEV.md` — final form (renamed to `DEV.md` since there's no longer a Python/Rust split). +- `headroom/__init__.py` — drop unused module imports. +- `pyproject.toml` — final dependency cleanup. +- `Cargo.toml` — final workspace cleanup. + +**Delete:** +- `tests/parity/fixtures/` — most of these are used by Python parity comparators that no longer have a Python side. Keep only the fixtures that still gate Rust-vs-Rust parity (which is: none, after Phase H — though future ML compressor variants may want them back). +- `crates/headroom-parity/` — the parity harness itself becomes irrelevant after Python side is gone. **Decision needed** (see `12-decisions-needed.md` Q3): keep parity-run as a "prior-version-vs-current-version" harness, or delete? + +**Add:** +- `CHANGELOG.md` entry: "**Breaking**: Python proxy retired. Operators must use the Rust binary `headroom-proxy`. See migration guide at `docs/operations/python-to-rust-migration.md`." +- `docs/operations/python-to-rust-migration.md` — operator migration guide. + +### Acceptance criteria + +- `git grep -i "uvicorn\|fastapi" headroom/` returns nothing in non-test code. +- `pyproject.toml` runtime deps are minimal. +- All docs build. + +### Blocked by + +PR-H1, PR-H2. + +### Blocks + +None. + +### Rollback + +`git revert`. Restores cleanup; previous PRs stay. + +--- + +## What survives in Python after Phase H + +| Module | Role | Reason | +|---|---|---| +| `headroom/cli/wrap/*.py` | Agent launchers | Off-path; orchestrates filesystem + subprocess. Python is the right tool. | +| `headroom/cli/{evals,init,install,learn,memory,perf,proxy,tools}.py` | CLI admin commands | Click-based; off-path. | +| `headroom/rtk/installer.py` | RTK binary downloader | Off-path; filesystem operations. | +| `headroom/providers/codex/install.py`, `claude/install.py` | Client config installation | Off-path; filesystem. | +| `headroom/evals/`, `learn/`, `memory/` writers | Research / batch tooling | Off-path; long-running batch. | +| `headroom/tokenizers/` | Parity backstop | Used only by parity harness if H3 keeps it. | +| `headroom/telemetry/toin.py` | TOIN learning loop | Off-path; observation-only after Phase B PR-B5. | +| `headroom/subscription/tracker.py`, `client.py` | Subscription usage poller | Off-path. | +| `headroom/copilot_auth.py` | Copilot OAuth refresh | Off-path; specific to Copilot integration. | + +## Phase H acceptance summary + +After all 3 PRs land: + +- ✅ Python proxy server retired +- ✅ All Python proxy handlers deleted +- ✅ Memory subsystem refactored or deleted +- ✅ LiteLLM backend retired +- ✅ Operators run only the Rust `headroom-proxy` binary +- ✅ ~20 K LOC of Python deleted +- ✅ Migration guide for operators + +**Phase H is the deletion. The OSS surface area shrinks dramatically; maintenance debt drops; behavior becomes consistent across deployments.** diff --git a/REALIGNMENT/11-phase-I-test-infra.md b/REALIGNMENT/11-phase-I-test-infra.md new file mode 100644 index 000000000..ff0443088 --- /dev/null +++ b/REALIGNMENT/11-phase-I-test-infra.md @@ -0,0 +1,385 @@ +# Phase I — Test Infrastructure (Continuous, Parallel) + +**Goal:** Build the test/CI surface that makes the realignment safe to land and stays safe afterward. This phase runs **in parallel** with all other phases — its PRs land alongside the corresponding feature work. + +**Calendar:** Continuous. Each test PR pairs with the feature PR it gates. + +**Shape:** ~10 PRs, mostly small, parallelizable. + +--- + +## PR-I1 — SHA-256 byte-faithful round-trip test on recorded production payload + +**Branch:** `realign-I1-sha256-round-trip` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I1-sha256-round-trip` +**Risk:** **LOW** +**LOC:** +400 + +### Scope +Eliminate P6-63. The single most important regression test for cache safety. Records a real Anthropic `/v1/messages` payload (sanitized of secrets), sends it through the proxy with compression off, asserts SHA-256 byte-equality at the upstream mock. + +### Files + +**Add:** +- `tests/fixtures/anthropic_messages_request_real.json` — sanitized real payload. Includes: + - `system` as a list of blocks with `cache_control` markers + - `tools[]` with non-trivial JSON Schema (nested properties, oneOf, definitions) + - `messages[]` with mixed block types: text, image, thinking + signature, tool_use with non-trivial input, tool_result with array content + image + - Non-ASCII characters (`🔥`, CJK) + - Numeric values: `temperature: 1.0`, large integers, scientific notation + - `cache_control` markers on `messages[*].content[*]` + - `null` and absent fields side-by-side +- `tests/fixtures/openai_chat_completions_real.json` — same shape for OpenAI Chat. +- `tests/fixtures/openai_responses_real.json` — same shape for Responses, includes V4A patch, local_shell_call, reasoning, compaction items. +- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_anthropic_messages_passthrough` +- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_anthropic_messages_compression_off_via_auth_mode` +- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_openai_chat` +- `crates/headroom-proxy/tests/integration_byte_faithful.rs::sha256_round_trip_openai_responses` +- `tests/test_python_byte_faithful.py::test_sha256_round_trip_anthropic_passthrough` — Python side, gates Phase H readiness. + +**Modify:** +- `Makefile` — `make test-byte-faithful` target that runs all of the above. +- `.github/workflows/rust.yml` — make `make test-byte-faithful` a per-PR gate. + +### Acceptance criteria + +- All tests pass after Phase A PR-A3, PR-A4 land. +- Test runs in <5 seconds. + +### Blocked by + +PR-A1. + +### Blocks + +PR-H1 (Phase H gating). + +--- + +## PR-I2 — SSE corner-case fixtures + fuzz tests + +**Branch:** `realign-I2-sse-corner-cases` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I2-sse-corner-cases` +**Risk:** **LOW** +**LOC:** +800 + +### Scope +Eliminate P6-66, P6-71. Record fixtures for every SSE corner case the audit identified. + +### Files + +**Add:** +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_thinking_with_signature.sse` +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_interleaved_blocks.sse` (synthetic; locks the index-keyed model) +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_input_json_delta_split_utf8.sse` (4-byte emoji split across chunks) +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_ping_mid_stream.sse` +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_error_mid_stream.sse` +- `crates/headroom-proxy/tests/fixtures/sse/openai_chat_tool_call_split.sse` +- `crates/headroom-proxy/tests/fixtures/sse/openai_chat_done_with_trailing_whitespace.sse` +- `crates/headroom-proxy/tests/fixtures/sse/openai_responses_out_of_order_done.sse` +- `crates/headroom-proxy/tests/fixtures/sse/openai_429_as_application_json.http` (HTTP error, not SSE) +- `crates/headroom-proxy/tests/fixtures/sse/anthropic_tcp_drop_before_message_stop.sse` +- `crates/headroom-proxy/tests/integration_sse_fixtures.rs` — runs every fixture against the parser; asserts expected state. +- Property test in `crates/headroom-proxy/tests/proptest_sse.rs::sse_parser_no_panic_on_arbitrary_bytes`. + +### Acceptance criteria + +- All fixtures parse correctly. +- Property test runs 10K random byte sequences without panic. + +### Blocked by + +PR-C1. + +### Blocks + +None. + +--- + +## PR-I3 — Property tests for compression invariants + +**Branch:** `realign-I3-compression-proptest` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I3-compression-proptest` +**Risk:** **LOW** +**LOC:** +500 + +### Scope +Property tests that exercise the realigned compressor invariants: +1. **Determinism**: `compress(input) == compress(input)` for any valid input. +2. **Idempotence**: `compress(compress(input).output) == compress(input).output` (compressing already-compressed content is a no-op). +3. **Token-non-increasing**: `tokens(output) <= tokens(input)` for any valid input — fallback ensures this. +4. **Position preservation**: For all valid block arrays, `len(compressed) == len(original)`; block types match per index; `tool_use_id` / `call_id` preserved. +5. **Frozen-prefix integrity**: For any `frozen_count`, messages `0..frozen_count` are byte-equal in input and output. + +### Files + +**Add:** +- `crates/headroom-core/tests/proptest_compression.rs` — proptest strategies for `Block`, `Message`, `RequestBody`. Five property tests above. +- `crates/headroom-core/tests/proptest_ccr.rs` — round-trip property test: `decompress(compress(content)) == content` for any content. + +### Acceptance criteria + +- All property tests pass with `cases = 1000`. + +### Blocked by + +PR-B4. + +### Blocks + +None. + +--- + +## PR-I4 — Real-traffic shadow test (Python vs Rust) + +**Branch:** `realign-I4-shadow-test` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I4-shadow-test` +**Risk:** **MEDIUM** +**LOC:** +1000 + +### Scope +Eliminate P6-67. A canary deployment runs the Python proxy and the Rust proxy side-by-side; for every request, both produce upstream-bound bytes; a comparator hashes both and reports SHA-256 mismatch percentage. Goal: 99.9% byte-equality before Phase H deletes Python. + +### Files + +**Add:** +- `e2e/shadow/runner.py` — splits incoming requests into "primary" (Python, response goes to client) and "shadow" (Rust, response discarded). Hashes upstream-bound bytes from both; reports per-request, per-endpoint, per-auth-mode mismatch rates. +- `e2e/shadow/dashboard.py` — Grafana dashboard JSON that visualizes the shadow comparison. +- `docs/operations/shadow-deploy.md` — operator guide for running the shadow test. + +### Acceptance criteria + +- Shadow test runs against a non-trivial corpus (10K requests) and reports. +- Mismatch rate <0.1% before declaring Phase H ready. + +### Blocked by + +PR-A1 through PR-G3. + +### Blocks + +PR-H1. + +--- + +## PR-I5 — Promote stub parity comparators to real + +**Branch:** `realign-I5-parity-stubs-to-real` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I5-parity-stubs-to-real` +**Risk:** **MEDIUM** +**LOC:** +800 + +### Scope +Eliminate P6-64. `crates/headroom-parity/src/lib.rs:172-174` stubs three comparators with `bail!()`: +- `ccr` (25 fixtures recorded; comparator is `Skipped`) +- `log_compressor` (20 fixtures recorded; comparator is `Skipped`) +- `cache_aligner` (20 fixtures recorded; comparator is `Skipped`) + +Build real comparators that exercise the Rust port against the recorded Python fixtures. + +### Files + +**Modify:** +- `crates/headroom-parity/src/lib.rs` — replace `stub_comparator!(CCRComparator, ...)` etc. with real impls. +- Add `CcrComparator`, `LogCompressorComparator`, `CacheAlignerComparator` modules. + +**Tests added:** +- Each comparator has a `harness_reports_match_for_real_fixture` test. + +### Acceptance criteria + +- All three comparators run against their recorded fixtures. +- Mismatch rate is 0% (parity locked). + +### Blocked by + +PR-B3 (LogCompressor live in proxy); PR-B7 (CCR hardening); PR-A2 (CacheAligner detector). + +### Blocks + +PR-I6. + +--- + +## PR-I6 — Make `make test-parity` a per-PR CI gate + +**Branch:** `realign-I6-parity-per-pr-gate` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I6-parity-per-pr-gate` +**Risk:** **LOW** +**LOC:** +50 + +### Scope +Eliminate P6-65. Today parity is a soft nightly with `continue-on-error: true`. Move it to per-PR with `Diff` failures blocking merge; `Skipped` allowed (so still-stubbed comparators don't block). + +### Files + +**Modify:** +- `.github/workflows/rust.yml:125-149` — move parity job from `cron` schedule to `pull_request` trigger. Remove `continue-on-error`. Set parity-run flags so `Skipped` is acceptable but `Diff` fails the build. +- `Makefile` — `test-parity` already exists; ensure it's invokable in CI. + +### Acceptance criteria + +- A purposely-broken Rust port that diverges from a recorded fixture fails CI on the next PR. + +### Blocked by + +PR-I5. + +### Blocks + +None. + +--- + +## PR-I7 — Cache hot zone non-mutation tests + +**Branch:** `realign-I7-cache-hot-zone-tests` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I7-cache-hot-zone-tests` +**Risk:** **LOW** +**LOC:** +600 + +### Scope +Test that nothing — compression, memory injection, tool registration — mutates the cache hot zone. + +### Files + +**Add:** +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::system_byte_equal_under_compression` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::tools_byte_equal_under_compression` (modulo Phase E sort + schema-key sort, which is deterministic — assert post-sort byte-equal) +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::frozen_messages_byte_equal_under_compression` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::reasoning_encrypted_content_byte_equal` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::thinking_signature_byte_equal` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::redacted_thinking_data_byte_equal` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::compaction_encrypted_content_byte_equal` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::v4a_patch_diff_byte_equal` +- `crates/headroom-proxy/tests/integration_cache_hot_zone.rs::local_shell_call_argv_array_preserved` + +### Acceptance criteria + +- All tests pass. + +### Blocked by + +PR-B2. + +### Blocks + +None. + +--- + +## PR-I8 — Tool-definition byte-stability snapshot tests + +**Branch:** `realign-I8-tool-def-snapshot` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I8-tool-def-snapshot` +**Risk:** **LOW** +**LOC:** +300 + +### Scope +For every tool definition Headroom auto-injects (`ccr_retrieve`, `memory_*`), pin the bytes via golden-file snapshot. Any change to a definition fails CI; a deliberate change requires updating the snapshot. This prevents accidental cache busts on Headroom deploys. + +### Files + +**Add:** +- `crates/headroom-core/tests/tool_def_byte_stability.rs::ccr_retrieve_definition_anthropic_byte_stable` +- `crates/headroom-core/tests/tool_def_byte_stability.rs::ccr_retrieve_definition_openai_byte_stable` +- `crates/headroom-core/tests/tool_def_byte_stability.rs::memory_save_definition_byte_stable` +- `crates/headroom-core/tests/tool_def_byte_stability.rs::memory_search_definition_byte_stable` +- Golden files under `crates/headroom-core/tests/golden/tool_defs/`. + +### Acceptance criteria + +- Tests pass. +- Renaming a field in a tool definition fails CI; updating the golden file fixes it. + +### Blocked by + +PR-B7. + +### Blocks + +None. + +--- + +## PR-I9 — Continuous cache-hit-rate alarm + +**Branch:** `realign-I9-cache-hit-rate-alarm` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I9-cache-hit-rate-alarm` +**Risk:** **LOW** +**LOC:** +200 + +### Scope +A Prometheus alarm rule that fires when the per-session cache hit rate (`proxy_cache_hit_rate_per_session`) drops below a baseline (90% of yesterday's rolling p50) for >15 minutes. Catches drift in production. + +### Files + +**Add:** +- `docs/operations/prometheus_rules.yaml` — alarm rule definition. +- `docs/operations/runbook.md` — what to do when the alarm fires. + +### Acceptance criteria + +- Rule passes `promtool check rules`. +- Runbook reviewed. + +### Blocked by + +PR-G3. + +### Blocks + +None. + +--- + +## PR-I10 — Replace fake RTK shim with real RTK in wrap E2E + +**Branch:** `realign-I10-real-rtk-in-e2e` +**Worktree:** `~/claude-projects/headroom-worktrees/realign-I10-real-rtk-in-e2e` +**Risk:** **LOW** +**LOC:** +200 + +### Scope +Eliminate P6-72. `e2e/wrap/run.py:250-267` has an `rtk` shim that just prints "rtk shim" and exits 0. Replace with real RTK invocation in CI, OR keep the shim but add an explicit assertion that the shim was used (so it doesn't silently mask a missing RTK install). + +### Files + +**Modify:** +- `e2e/wrap/run.py:250-267` — switch to real RTK download in CI; cache the binary. +- `.github/workflows/wrap-e2e.yml` — pin RTK version. + +### Acceptance criteria + +- E2E tests download real RTK and exercise its rewrite behavior. + +### Blocked by + +None. + +### Blocks + +None. + +--- + +## Phase I acceptance summary + +After all 10 PRs land: + +- ✅ SHA-256 byte-faithful round-trip test gates CI +- ✅ SSE corner-case fixtures + fuzz tests +- ✅ Property tests for compression invariants +- ✅ Real-traffic shadow test comparing Python vs Rust +- ✅ Stub parity comparators promoted to real +- ✅ `make test-parity` is a per-PR gate +- ✅ Cache hot zone non-mutation tests +- ✅ Tool-definition byte-stability snapshot tests +- ✅ Cache-hit-rate Prometheus alarm +- ✅ Real RTK in wrap E2E + +**Phase I retires P6-63 through P6-72.** + +After Phase I, regressing the realignment requires actively breaking tests — the cache safety properties become continuously enforced. diff --git a/REALIGNMENT/12-decisions-needed.md b/REALIGNMENT/12-decisions-needed.md new file mode 100644 index 000000000..757ad3520 --- /dev/null +++ b/REALIGNMENT/12-decisions-needed.md @@ -0,0 +1,196 @@ +# 12 — Decisions Needed + +Open questions the realignment can't resolve unilaterally. Greenlight or alternative each before the corresponding PR lands. + +--- + +## Q1. Phase A timing — land tonight or wait? + +**Recommendation:** Land **PR-A1 tonight**. It's a small diff (-180/+30) that eliminates the worst cache-killer cluster (P0-3, P0-4, P0-5 stop firing immediately). The proxy goes to passthrough on `/v1/messages`; compression returns in Phase B. Net positive because today's compression is actively destroying cache hit rate. + +PR-A2 through PR-A8 land over the rest of the week. + +**Alternative:** Hold all of Phase A until the synthesis is "perfect." Risk: cache hit rate stays poor. + +--- + +## Q2. ICM removal scope — Tier 1+2, or include Tier 3? + +**Recommendation: Tier 1 + Tier 2 in Phase B PR-B1** (~10K LOC). + +- **Tier 1** (ICM proper): `intelligent_context.py`, `manager.rs`, `icm.rs`, the proxy call site. +- **Tier 2** (subsystems whose only consumer is ICM): `RollingWindow`, `ProgressiveSummarizer`, `scoring.py`, `tool_crusher.py`, `MessageScorer`, all of `crates/headroom-core/src/scoring/` and `relevance/`, most of `context/` (keep `safety.rs`). +- **Tier 3** (separable cleanup): `CacheAligner` rewrite path is in Phase A PR-A2 (already scheduled). Memory `_inject_system_context` paths in Phase A PR-A2 + Phase B PR-B6 (already scheduled). + +So "Tier 1 + Tier 2" is the right scope for the Phase B big-delete PR; Tier 3 is already covered by Phase A and Phase B's other PRs. + +**Alternative:** Stop at Tier 1 (just ICM proper). Risk: ~6 K LOC of dead-but-still-imported scoring/relevance machinery; future contributors won't know it's dead. + +--- + +## Q3. MessageScorer Rust port — delete? + +**Recommendation: Delete.** + +The PR #338 / #343 port (April 2026) was investment in the wrong abstraction (per Agent G's audit: scoring's only consumer is `DropByScoreStrategy::try_fit`, which Phase B retires). Keeping it as a dead crate creates maintenance debt and confusion. Sunk cost stays sunk; the parity-harness scaffolding learnings carry forward to live-zone work where they actually matter. + +Folded into Phase B PR-B1. + +**Alternative:** Keep the crate around as off-path "in case scoring is needed later." Risk: dead-code review burden every PR. + +--- + +## Q4. Stage 3g (lossless-first compression pipeline, issue #315) — re-scope or close? + +**Context:** Per project memory `~/.claude/projects/-Users-tchopra-claude-projects-headroom/memory/project_lossless_first_pipeline.md`, Stage 3g was queued to formalize "lossless-then-lossy-then-CCR ordering as a `CompressionPipeline` orchestrator + `LosslessTransform`/`LossyTransform` traits." The plan assumed an ICM-style orchestrator over the messages array. + +**Recommendation:** **Re-scope** issue #315 to "live-zone-only pipeline orchestrator." The traits stay (`LosslessTransform`/`LossyTransform`); the scope changes from "history compactor" to "live-zone block dispatcher." This is what Phase B PR-B2 builds. Update issue #315's body to reflect the realignment. + +**Alternative:** Close issue #315 and treat Phase B PR-B2 as fulfilling its intent. Risk: history of the decision is lost. + +--- + +## Q5. Headroom Loop / AWS Marketplace BYOC — affected scope? + +**Context:** Per project memory `project_headroom_loop.md` (enterprise paid product) and `project_headroom_aws_marketplace.md` (BYOC CFN stack in customer VPC). Both depend on the OSS proxy. + +**Recommendation:** The realignment **strengthens** both: +- Headroom Loop's value proposition is "trace stream + enterprise compression policy"; Phase F's auth-mode policy is exactly the surface Loop wants to gate on. +- AWS Marketplace BYOC's pitch is "context compression in front of Bedrock"; Phase D's native Bedrock support makes that pitch real (today's LiteLLM-converted Bedrock path was fake; Phase D fixes it). + +No re-scoping needed; revisit after Phase D lands. + +**Alternative:** Pause Headroom Loop / Marketplace work until Phase D completes. Recommended if their roadmap conflicts with Phase D timing. + +--- + +## Q6. `make test-parity` per-PR gate — enable now or wait? + +**Recommendation:** Enable now (Phase I PR-I6) with the existing stubs. `Skipped` is permitted; `Diff` fails the build. As Phase I PR-I5 promotes stubs to real comparators, the per-PR gate gradually tightens. + +**Alternative:** Wait until all stubs are real. Risk: parity divergence merges silently for the next month. + +--- + +## Q7. Operator config switch — explicit `HEADROOM_PROXY_BACKEND` env var, or implicit? + +**Context:** During Phase H rollout, operators need a way to choose Python vs Rust proxy. + +**Recommendation:** Add `HEADROOM_PROXY_BACKEND={python|rust}` env var in Phase H PR-H1; default to `rust` once the canary in Phase I PR-I4 confirms ≥99.9% byte-equality. Keep the Python proxy alive in the codebase for 30 days post-Phase-H as an explicit rollback target. After 30 days of stable Rust operation, run Phase H PR-H2/H3 to delete Python. + +**Alternative:** Cut over implicitly (`headroom proxy start` always uses Rust after Phase H). Riskier; no clean rollback path. + +--- + +## Q8. Container image strategy — single binary or multi-stage? + +**Recommendation:** Single binary (`headroom-proxy` Rust). Container is `FROM scratch` or `FROM gcr.io/distroless/static`. Image size drops from ~500 MB (with Python + LiteLLM + ONNX models) to ~50 MB. + +**Alternative:** Multi-stage Docker with Rust binary + Python sidecar (for evals/learn/memory writers). Recommended only if those subsystems become production-relevant; today they're CLI tools. + +--- + +## Q9. RTK proxy-side invocation — ever revisit? + +**Recommendation:** **No, document the decision in `docs/rtk-architecture.md`** (Phase G PR-G3). The argument: +1. Cache hot zone risk: shell-out + buffer per tool result is correctness-fragile. +2. Parallel implementation: `crates/headroom-core/src/transforms/log_compressor.rs` covers post-hoc log/output compression; RTK rewrites *commands* (different value). +3. RTK itself is a third-party binary the team doesn't control; an upstream version change silently busts cache. + +If a future requirement emerges (e.g., "Headroom must compress shell output for users who don't run wrap"), reconsider with explicit cache-safety design. + +**Alternative:** Build proxy-side RTK as a feature-flagged opt-in. Recommended only if the wrap-CLI breadth (PR-G1) doesn't cover enough surface. + +--- + +## Q10. Bedrock/Vertex priority — parallel with proxy port (Phase D in calendar) or after Phase H? + +**Recommendation:** **Parallel.** Phase D blocks H2 (Python LiteLLM retirement) but not H1 (Python proxy retirement). Run Phase D and Phase C/E/F/G concurrently. + +**Alternative:** Sequential, Phase D after Phase H. Risk: Bedrock/Vertex users stay on the broken Python LiteLLM path for an extra month. + +--- + +## Q11. Memory subsystem — auto-tail mode default, or tool-only? + +**Recommendation:** Auto-tail mode default in Phase B PR-B6, with tool-only mode behind a flag. Migrate users to tool-only over the next 6 months once docs and tooling are mature. Auto-tail is byte-deterministic (per the cache-safety invariant) and matches existing UX. + +**Alternative:** Force tool-only immediately. Risk: breaks customers' existing memory-augmented prompts. + +--- + +## Q12. Parity harness post-Phase-H — keep or delete? + +**Context:** After Phase H deletes Python, `crates/headroom-parity/` no longer has a Python side to compare against. Per Phase H PR-H3, this is a decision point. + +**Recommendation:** **Repurpose**, don't delete. Rename to `crates/headroom-version-parity/` and use it to compare current-Rust-version vs previous-Rust-version on the recorded fixtures. Catches Rust-vs-Rust regressions during future ML compressor variants (e.g., when Kompress is ported to Rust via `ort`). + +**Alternative:** Delete entirely. Save ~2K LOC. Risk: no automated regression test for compressor changes. + +--- + +## Q13. Auth-mode UA detection list — which CLIs to recognize? + +**Phase F PR-F1 starts with this list:** +- `claude-cli/` (Anthropic CLI) +- `claude-code/` (Claude Code) +- `codex-cli/` (Codex CLI) +- `cursor/` (Cursor IDE) +- `claude-vscode/` +- `github-copilot/` +- `anthropic-cli/` +- `antigravity/` (Cloudcode Antigravity) + +**Recommendation:** Extend over time as new CLIs emerge. Alphabetic sort for determinism. Document in `docs/auth-modes.md`. + +**Alternative:** Start with a smaller list; expand reactively. Risk: subscription users mis-classified as PAYG and fingerprint-leaked. + +--- + +## Q14. The ICM removal blast radius — confirm acceptable + +**Counts:** +- Lines deleted (Python): ~3,300 +- Lines deleted (Rust): ~4,500 +- Files deleted: ~30 +- Tests deleted: ~50 +- PRs that recently merged but become wasted work: PR #338, PR #343 (MessageScorer Rust port) +- Project memory updates needed: 1 (the "53270 lines" content_router.py figure was wrong by 25× — already corrected in `MEMORY.md`). + +**Recommendation:** Acceptable. The cache-killer bugs cost more than the deleted code's hypothetical future value. + +--- + +## Q15. Calendar + capacity — sequential or parallel? + +**Sequential calendar:** ~13 weeks. One contributor working through phases A→I. +**Parallel calendar:** ~8 weeks with 2-3 contributors splitting along these natural boundaries: +- Lead: Phase A (lockdown), Phase B (live-zone), Phase H (retirement) — the critical path. +- Contributor 2: Phase C (Rust proxy paths), Phase D (Bedrock/Vertex). Self-contained. +- Contributor 3 (optional): Phase E (cache stabilization), Phase F (auth-mode), Phase G (RTK + obs), Phase I (test infra). Mostly independent. + +**Recommendation:** Parallel. The bug list is real and the cache hit rate is hemorrhaging in production today. + +--- + +## Quick answer template + +For decision sign-off, fill in this block: + +``` +Q1 (Phase A timing): [ ] tonight [ ] wait +Q2 (ICM scope): [ ] Tier 1+2 [ ] Tier 1 only [ ] all 3 tiers +Q3 (MessageScorer): [ ] delete [ ] keep +Q4 (issue #315): [ ] re-scope [ ] close +Q5 (Loop/Marketplace):[ ] proceed unchanged [ ] pause until D +Q6 (parity gate): [ ] enable now [ ] wait +Q7 (operator switch): [ ] env var w/ default rust [ ] implicit cutover +Q8 (container): [ ] single binary [ ] multi-stage +Q9 (RTK proxy-side): [ ] document never [ ] feature-flag for future +Q10 (Bedrock priority):[ ] parallel [ ] sequential after H +Q11 (memory mode): [ ] auto-tail default [ ] tool-only force +Q12 (parity harness): [ ] repurpose [ ] delete +Q13 (UA list): [ ] approve list [ ] revise: ___________ +Q14 (ICM blast radius): [ ] accept [ ] reduce scope +Q15 (calendar): [ ] parallel (2-3 contributors) [ ] sequential +``` diff --git a/REALIGNMENT/INDEX.md b/REALIGNMENT/INDEX.md new file mode 100644 index 000000000..74785e491 --- /dev/null +++ b/REALIGNMENT/INDEX.md @@ -0,0 +1,82 @@ +# Headroom Realignment — Index + +**Status:** Drafted 2026-05-01 from a 10-agent deep audit against `~/Downloads/llm-proxy-compression-guide.md`. +**Owner:** chopratejas +**Goal:** Move the entire codebase to Rust, preserve prefix cache, retain compression value, integrate RTK end-to-end, and gate compression policy by auth mode (PAYG / OAuth / subscription). + +## Read in this order + +1. [00-overview.md](./00-overview.md) — executive summary; the wrong mental model; what changes +2. [01-bug-list.md](./01-bug-list.md) — comprehensive ranked bug list with file:line and guide § +3. [02-architecture.md](./02-architecture.md) — the realigned target architecture +4. Phase docs (PR-by-PR, executable): + - [03-phase-A-lockdown.md](./03-phase-A-lockdown.md) — **start here**: stop-the-bleeding (8 PRs, ~1 week) + - [04-phase-B-live-zone.md](./04-phase-B-live-zone.md) — live-zone-only compression (7 PRs, ~2 weeks) + - [05-phase-C-rust-proxy.md](./05-phase-C-rust-proxy.md) — port handlers to Rust (5 PRs, ~3 weeks) + - [06-phase-D-bedrock-vertex.md](./06-phase-D-bedrock-vertex.md) — native envelopes (4 PRs, ~2 weeks) + - [07-phase-E-cache-stabilization.md](./07-phase-E-cache-stabilization.md) — Phase 3 stabilization (6 PRs, ~1 week) + - [08-phase-F-auth-mode.md](./08-phase-F-auth-mode.md) — auth-mode policy gates (4 PRs, ~1 week) + - [09-phase-G-rtk-observability.md](./09-phase-G-rtk-observability.md) — RTK breadth + metrics (3 PRs, ~1 week) + - [10-phase-H-python-retirement.md](./10-phase-H-python-retirement.md) — delete Python proxy (3 PRs, ~2 weeks) + - [11-phase-I-test-infra.md](./11-phase-I-test-infra.md) — test/CI gates (parallel) +5. [12-decisions-needed.md](./12-decisions-needed.md) — open questions + +## Conventions + +- **Branch name:** `realign--`. Example: `realign-A1-icm-passthrough`. +- **Worktree path:** `~/claude-projects/headroom-worktrees/realign--`. Use `git worktree add` so each PR is an isolated checkout. +- **Commit prefix:** `fix:` for Rust-migration phase commits (per project memory — `feat:` would inflate semantic-release version). +- **No `Co-Authored-By: Claude` trailer** (per project memory). +- **Pre-push gate:** `make ci-precheck` per project memory; never push without it. + +## Phase totals + +| Phase | PRs | LOC delta (est.) | Calendar (sequential) | +|---|---:|---:|---:| +| A — Lockdown | 8 | -200 / +400 | 1 week | +| B — Live-zone engine | 7 | **-10,000 / +1,500** | 2 weeks | +| C — Rust proxy paths | 5 | -2,000 / +5,000 | 3 weeks | +| D — Bedrock/Vertex native | 4 | -800 / +2,500 | 2 weeks | +| E — Cache stabilization | 6 | -100 / +900 | 1 week | +| F — Auth-mode policy | 4 | -50 / +600 | 1 week | +| G — RTK + observability | 3 | -50 / +400 | 1 week | +| H — Python retirement | 3 | **-15,000 / +200** | 2 weeks | +| I — Test infra | parallel | +2,000 | continuous | +| **Total** | **40** | **~-28,000 / +13,500** | **~13 weeks** sequential, **~8 weeks** parallel | + +## Cross-cutting invariants + +These never get violated by any PR: + +1. Bytes that the proxy doesn't intend to modify must arrive at upstream **byte-equal** (SHA-256) to bytes that arrived at the proxy. (§1.9) +2. The cache hot zone — system, tools, old turns, reasoning/thinking/redacted/compaction items — is never modified. (§10) +3. Compression is **append-only**: only the live zone (latest user message, latest tool/function/shell/patch outputs) is ever rewritten. (§6.4 + §10.3) +4. Compression is deterministic: same input bytes → same output bytes. (§7.1) +5. Tool definitions are **normalized** (sorted), never compressed. (§8.5) +6. `signature`, `encrypted_content`, `redacted_thinking.data`, `compaction.encrypted_content` are passthrough-only. (§2.7, §2.8, §4.3, §4.8, §10.1) +7. TOIN never alters request-time decisions; it observes and publishes recommendations between deploys. (§7.1, §11.17) +8. CCR markers and the `ccr_retrieve` tool are present **on every request** for a session that ever did CCR — never toggled. (§6.3 #2) +9. `Authorization` header is forwarded byte-faithfully and never logged or persisted unredacted. +10. Auth mode (PAYG / OAuth / subscription) gates compression policy; subscription mode runs in stealth (no `X-Headroom-*` upstream, no beta drift, no UA mutation, no `accept-encoding` strip). + +## Preserved primitives (per user direction) + +- **TOIN** — refactored to strict observation-only; per-tenant aggregation key. +- **CCR** — hardened with persistent backend + always-on tool registration. +- **Kompress-base** — stays as plain-text compressor (§8.6); Rust port via `ort` later. +- **ContentRouter** — the architecturally correct piece (~2150 LOC); ported to Rust as the live-zone block dispatcher. +- **Type-aware compressors** — SmartCrusher, Code, Log, Search, Diff (already in Rust); kept. +- **`signals/` Rust trait module** — keeps; drives live-zone consumers. +- **`tokenizer/` Rust** — keeps. +- **`safety.rs`** — tool-pair atomicity logic; moved to `transforms/safety.rs` after Phase B. + +## Retired (~25K LOC) + +- ICM (Python `intelligent_context.py`, Rust `context/manager.rs`) +- `RollingWindow`, `ProgressiveSummarizer`, `scoring.py`, `tool_crusher.py` (Python) +- `crates/headroom-core/src/scoring/`, `relevance/`, most of `context/` (Rust) +- `crates/headroom-proxy/src/compression/icm.rs` +- `headroom/transforms/cache_aligner.py` rewrite path (keep detector + warning) +- `headroom/proxy/server.py`, `handlers/anthropic.py`, `handlers/openai.py`, `handlers/streaming.py`, `handlers/gemini.py`, `responses_converter.py`, `memory_handler.py`, `memory_tool_adapter.py`, `semantic_cache.py`, `batch.py` — once Rust hits parity (Phase H) +- `headroom/backends/litellm.py` Bedrock/Vertex converter — replaced by native envelopes (Phase D) +- MessageScorer Rust port (PR #338, #343) — wasted work; deleted in Phase B diff --git a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json index 779a2a0ba..357127d88 100644 --- a/plugins/headroom-agent-hooks/.claude-plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.20.8", + "version": "0.20.11", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors", diff --git a/plugins/headroom-agent-hooks/.github/plugin/plugin.json b/plugins/headroom-agent-hooks/.github/plugin/plugin.json index 3c948b9aa..a5a432f11 100644 --- a/plugins/headroom-agent-hooks/.github/plugin/plugin.json +++ b/plugins/headroom-agent-hooks/.github/plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "headroom", - "version": "0.20.8", + "version": "0.20.11", "description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.", "author": { "name": "Headroom Contributors",