Skip to main content

tapes_capture/
envelope.rs

1//! The capture envelope.
2//!
3//! The `X-Tapes-*` request-header contract carries attribution and provenance
4//! from a capture transport — a client's just-in-time proxy, a long-lived
5//! daemon client, or the tapes gateway filter — into the tapes ingest server. It is the narrow Rust↔Go waist: metadata, not
6//! parsing, and it rarely changes.
7//!
8//! This module is the **producer** half. It turns a resolved session identity
9//! into the on-wire header set: percent-encoding, the 256-byte session-name
10//! cap, base64url metadata, and the 8 KiB total budget. The parsers on the
11//! other side (tapes-extproc's `ParseSessionEnvelope`, the tapes ingest reader)
12//! read that header set back into an envelope. Both halves table-test against
13//! one shared fixture corpus, vendored here under
14//! `vendor/tapes-envelope-fixtures/` — see that directory's `SOURCE.md` and the
15//! oracle in `envelope_fixtures.rs`. Drift between the halves is otherwise
16//! invisible until a captured session lands mis-attributed.
17//!
18//! Extracted from a daemon client's header layer, which was the sole producer
19//! before `tapesctl` existed. The behaviour is pinned by the corpus, so every
20//! capture path emits byte-identical envelopes by construction rather than by
21//! review.
22//!
23//! What is deliberately *not* here: a consumer's bespoke auth header and its
24//! injection helper. A client fronted by an authenticating edge carries a
25//! private header so that edge admits the request; it is that consumer's
26//! channel, not part of the tapes envelope, and it stays with the consumer.
27//! The generic RFC 7230 hop-by-hop knowledge every capture proxy needs does
28//! live here — see [`HOP_BY_HOP_HEADERS`] and [`is_hop_by_hop`].
29
30use crate::session::HarnessSession;
31use base64::Engine;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use http::{HeaderMap, HeaderName, HeaderValue};
34use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
35use snafu::{ResultExt, Snafu};
36use tracing::warn;
37
38/// Failure modes the envelope helpers can surface.
39#[derive(Debug, Snafu)]
40#[snafu(module, visibility(pub(crate)))]
41#[non_exhaustive]
42pub enum HeaderError {
43    /// The supplied string value is not a valid HTTP header value —
44    /// typically because it contains non-visible-ASCII bytes. Header
45    /// values must be visible ASCII per RFC 7230 §3.2.6.
46    #[snafu(display("header value is not valid HTTP-header bytes"))]
47    InvalidValue {
48        /// Underlying validation error from the `http` crate.
49        source: http::header::InvalidHeaderValue,
50    },
51}
52
53/// Hop-by-hop headers per RFC 7230 §6.1. These are scoped to a single
54/// connection and must not be forwarded across a proxy boundary. Listed
55/// in lower-case so case-insensitive comparison can use
56/// `eq_ignore_ascii_case` directly without renormalising the input.
57pub const HOP_BY_HOP_HEADERS: &[&str] = &[
58    "connection",
59    "keep-alive",
60    "proxy-authenticate",
61    "proxy-authorization",
62    "te",
63    "trailers",
64    "transfer-encoding",
65    "upgrade",
66];
67
68/// Common prefix for every capture-envelope request header.
69pub const HEADER_PREFIX: &str = "x-tapes-";
70
71// --- X-Tapes-* envelope headers -----------------------------------------
72//
73// A capture transport attaches these on every outbound LLM request. They
74// are scoped to the transport's private channel to ingest and are expected
75// to be stripped upstream before the request reaches the model provider.
76
77/// Identifies the harness — `claude`, `unknown`, or a future
78/// registered value. Required.
79pub const X_TAPES_HARNESS_ID: &str = "x-tapes-harness-id";
80
81/// Opaque harness-side session identifier. For Claude, the
82/// `sessionId` UUID from `~/.claude/sessions/<pid>.json`. Required
83/// when `harness_id != "unknown"`.
84pub const X_TAPES_HARNESS_SESSION_ID: &str = "x-tapes-harness-session-id";
85
86/// Harness version (e.g. claude `version` field). Optional.
87pub const X_TAPES_HARNESS_VERSION: &str = "x-tapes-harness-version";
88
89/// Harness working directory. Percent-encoded UTF-8 — common
90/// filesystems (macOS, Linux) allow non-ASCII path components, which
91/// RFC 7230 forbids in raw header values. Optional.
92pub const X_TAPES_CWD: &str = "x-tapes-cwd";
93
94/// User-given session name (`/name` in claude). Percent-encoded UTF-8,
95/// capped at 256 bytes raw. Optional.
96pub const X_TAPES_SESSION_NAME: &str = "x-tapes-session-name";
97
98/// Fork-parent's `harness_session_id`, when the capture client has
99/// recovered lineage from the transcript. Optional.
100pub const X_TAPES_PARENT_HARNESS_SESSION_ID: &str = "x-tapes-parent-harness-session-id";
101
102/// Base64url(no padding) of a JSON object holding the harness blob
103/// destined for `sessions.harness_metadata`. Capped at 4 KiB raw JSON;
104/// dropped first when the total `X-Tapes-*` byte budget (8 KiB) is
105/// exceeded.
106pub const X_TAPES_HARNESS_METADATA: &str = "x-tapes-harness-metadata";
107
108/// Sentinel harness-id used when the capture client couldn't attribute
109/// the request (cold race, unrecognised User-Agent, sandboxed harness).
110/// No `X-Tapes-Harness-Session-Id` is attached in this case.
111pub const HARNESS_ID_UNKNOWN: &str = "unknown";
112
113/// Harness-id attached by pi's capture extension — the one harness that stamps
114/// its own envelope from inside itself. The extension is
115/// `tapes_harnesses::plugin::PI_GATEWAY_EXTENSION`.
116pub const HARNESS_ID_PI: &str = "pi";
117
118/// Harness-id attached for Claude traffic (User-Agent `claude*`).
119pub const HARNESS_ID_CLAUDE: &str = "claude";
120
121/// Harness-id attached for Codex traffic.
122pub const HARNESS_ID_CODEX: &str = "codex";
123
124/// Harness-id for the Codex desktop app.
125///
126/// A distinct harness, not an alias of [`HARNESS_ID_CODEX`]: the app is a
127/// long-lived Codex host a consumer configures rather than launches, and its
128/// session identity arrives through lifecycle hook reports (see
129/// `tapes_harnesses::attribution::codex_app`) rather than through the peer-PID
130/// lanes.
131/// It shares Codex's wire protocol and rollout tree, so requests still carry
132/// the `thread-id`/`session-id` pair and transcripts still land under
133/// `$CODEX_HOME/sessions` — what differs is who answers "which session is
134/// this?", and keying captured sessions by a distinct id keeps that
135/// difference visible downstream.
136pub const HARNESS_ID_CODEX_APP: &str = "codex-app";
137
138/// Harness-id attached for opencode traffic.
139///
140/// opencode capture arrives with the standalone client; the constant lives
141/// here with the others so the harness registry has one place to take every id
142/// from, rather than the launch recipe spelling its own.
143pub const HARNESS_ID_OPENCODE: &str = "opencode";
144
145/// Client-side request capture cap, in wire bytes: the largest request body a
146/// capture client (tapesctl's proxy peek, any future standalone recorder)
147/// should retain for capture before degrading to forward-only.
148///
149/// 32 MiB matches the gateway side of the same contract — the Anthropic
150/// Messages request ceiling Paper commits to forwarding
151/// (`ProviderMaxRequestBytes` in tko, `MaxDecodedRequestBytes` in tapes
152/// ingest). Client capture sized below the gateway's would silently record
153/// less than the platform captures for the same traffic; sized above it, the
154/// extra bytes describe requests the provider rejects anyway. Capture-only:
155/// forwarding must never gate on this value.
156pub const REQUEST_CAPTURE_CAP: usize = 32 * 1024 * 1024;
157
158/// Maximum total budget across all `X-Tapes-*` headers.
159/// Metadata is dropped first when the budget is exceeded; the other
160/// headers are small (UUIDs and paths) and stay.
161pub const X_TAPES_TOTAL_BUDGET: usize = 8 * 1024;
162
163/// Maximum raw JSON size of the metadata blob before base64 encoding.
164/// Larger blobs cause the entire metadata header to be dropped
165/// silently — the producer enforces this locally rather than letting an
166/// oversize blob travel upstream.
167pub const X_TAPES_METADATA_RAW_CAP: usize = 4 * 1024;
168
169/// Maximum raw byte length of `X-Tapes-Session-Name` before
170/// percent-encoding. Names beyond this are truncated to the cap before
171/// encoding (silently — the cap exists to keep the header inside the
172/// total budget, not to validate user input).
173pub const X_TAPES_SESSION_NAME_CAP: usize = 256;
174
175/// Percent-encoding set for header values that may carry arbitrary
176/// UTF-8 (session name, working directory). RFC 7230 header values are
177/// visible ASCII; we escape everything outside that range and a small
178/// set of structural ASCII characters that could confuse header
179/// parsers.
180const UTF8_VALUE_ESCAPE: &AsciiSet = &CONTROLS.add(b' ').add(b'%').add(b'"').add(b'\\').add(0x7f);
181
182/// Claude Code's sub-thread header, in priority order — first present wins.
183///
184/// Claude Code stamps `x-claude-code-agent-id` on every call made from a
185/// subagent context (including its security-monitor checks) and omits it on the
186/// main thread, so presence alone is the signal.
187pub const CLAUDE_THREAD_ID_HEADERS: &[&str] = &["x-claude-code-agent-id"];
188
189/// Codex's thread id for one call: equal to [`CODEX_SESSION_ID_HEADER`] on a
190/// root turn, a distinct id on a spawned sub-thread's turn.
191///
192/// Also read — as an ordered first-present list rather than as a pair — by
193/// `tapes_harnesses::attribution::codex::session::CODEX_ROLLOUT_ID_HEADERS`, which
194/// answers a different question: *which rollout* a request belongs to. Both
195/// take their spelling from here so the two readings cannot drift apart.
196pub const CODEX_THREAD_ID_HEADER: &str = "thread-id";
197
198/// Codex's root session id, present on every Codex call.
199pub const CODEX_SESSION_ID_HEADER: &str = "session-id";
200
201/// The thread that spawned this call's thread — **one hop**, not the root.
202///
203/// Present only on a sub-thread's calls. At depth 1 it equals
204/// [`CODEX_SESSION_ID_HEADER`]; deeper, it names the immediate parent while the
205/// session header stays pinned to the root. That pairing is what makes a
206/// request self-describing enough to be joined against a rollout transcript's
207/// own `parent_thread_id` — see
208/// `tapes_harnesses::attribution::codex::request::CodexRequestIdentity`.
209pub const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
210
211/// A JSON restatement of the identity headers, plus the turn id.
212///
213/// Codex sends the same session/thread/parent/subagent-kind values here that it
214/// sends as individual headers, so the blob is a *corroborating* source rather
215/// than an authoritative one: where the two disagree the request's account of
216/// itself is not trustworthy at all (see `conflicting_metadata`). It is also
217/// the only carrier of the turn id.
218///
219/// The blob additionally carries the user's prompt and other conversation
220/// content. Parsing is therefore an allowlist, exactly as it is for the
221/// desktop app's lifecycle payloads: see
222/// `tapes_harnesses::attribution::codex_app`.
223pub const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
224
225/// The legacy, unstructured spelling of a sub-thread's kind.
226///
227/// Codex names the collaboration *transport* here (`collab_spawn`) while its
228/// structured metadata and the rollout transcript name the *thread source*
229/// (`thread_spawn`). The two are canonicalised to one vocabulary before
230/// comparison — see
231/// `tapes_harnesses::attribution::codex::request::canonical_subagent_kind` — so a
232/// request that says both things does not read as self-contradictory.
233pub const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
234
235/// How one harness's request headers name the sub-thread a call was made from.
236///
237/// The two shapes exist because harnesses disagree about what a header's
238/// *presence* means, and a single flat list of names cannot express both.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240#[non_exhaustive]
241pub enum HarnessThreadRule {
242    /// The harness stamps a dedicated header **only** on sub-thread calls, so
243    /// presence is the whole signal. Names are tried in order; the first one
244    /// present with a non-empty value wins.
245    FirstPresent(&'static [&'static str]),
246    /// The harness stamps both headers on **every** call, and the sub-thread
247    /// signal is their divergence rather than either one's presence.
248    ///
249    /// Both must be present and differ. Each guard earns its place:
250    ///
251    /// * An **equal** pair is a root turn. Codex sets `thread-id` ==
252    ///   `session-id` there, so a flat first-present entry for `thread-id`
253    ///   would stamp a thread id on every root turn — which is not cosmetic:
254    ///   downstream, a non-empty thread id routes the root spine off the main
255    ///   spine and silently degrades the session's derived status.
256    /// * A **lone** thread id, with no session id beside it, is not a
257    ///   recognised shape for this harness at all. Treating it as a sub-thread
258    ///   would risk that same misrouting on evidence the rule cannot confirm,
259    ///   so it resolves to nothing instead.
260    DivergentPair {
261        /// Header carrying this call's thread id.
262        thread: &'static str,
263        /// Header carrying the root session id the thread id is compared against.
264        session: &'static str,
265    },
266}
267
268impl HarnessThreadRule {
269    /// Apply this rule to a request's headers.
270    ///
271    /// `None` means "this rule recognises nothing here" — a main-thread call,
272    /// or a request belonging to a different harness.
273    #[must_use]
274    pub fn resolve<'h>(&self, headers: &'h HeaderMap) -> Option<&'h str> {
275        match *self {
276            Self::FirstPresent(names) => names.iter().find_map(|name| header_str(headers, name)),
277            Self::DivergentPair { thread, session } => {
278                let thread_id = header_str(headers, thread)?;
279                let session_id = header_str(headers, session)?;
280                (thread_id != session_id).then_some(thread_id)
281            }
282        }
283    }
284}
285
286/// Every harness's sub-thread rule, in the order [`thread_id`] tries them.
287///
288/// This is harness knowledge, so it lives here rather than in each capture
289/// client; the rest of a client's pipeline is harness-neutral and only ever
290/// sees the resolved thread id. The table mirrors tapes-extproc's `ThreadID` —
291/// the two must agree, since extproc reads these off the wire for exactly the
292/// same purpose. Add other harnesses' rules to both as they are identified.
293///
294/// Order is precedence, and it is only observable when one request carries
295/// evidence for two harnesses at once. Claude's dedicated header is the more
296/// specific signal, so it is tried first, matching extproc.
297pub const HARNESS_THREAD_ID_RULES: &[HarnessThreadRule] = &[
298    HarnessThreadRule::FirstPresent(CLAUDE_THREAD_ID_HEADERS),
299    HarnessThreadRule::DivergentPair {
300        thread: CODEX_THREAD_ID_HEADER,
301        session: CODEX_SESSION_ID_HEADER,
302    },
303];
304
305/// A header's value as a string, treating absent, non-ASCII and empty alike as
306/// "not stated". No trimming: the comparison in
307/// [`HarnessThreadRule::DivergentPair`] is against another raw header value,
308/// and extproc compares the bytes it received.
309fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
310    headers
311        .get(name)
312        .and_then(|value| value.to_str().ok())
313        .filter(|value| !value.is_empty())
314}
315
316/// Resolve the harness-native sub-thread id for a request.
317///
318/// Returns `None` for a main-thread call, or for a harness with no known
319/// mapping. The value is destined for the ingest turn's `meta.thread_id`; it is
320/// **not** an `X-Tapes-*` envelope header and is not stripped from the outbound
321/// request — the harness set it, and upstream may legitimately see it.
322#[must_use]
323pub fn thread_id(headers: &HeaderMap) -> Option<&str> {
324    HARNESS_THREAD_ID_RULES
325        .iter()
326        .find_map(|rule| rule.resolve(headers))
327}
328
329/// Returns true if `name` is in [`HOP_BY_HOP_HEADERS`] (case-insensitive).
330#[must_use]
331pub fn is_hop_by_hop(name: &str) -> bool {
332    HOP_BY_HOP_HEADERS
333        .iter()
334        .any(|h| h.eq_ignore_ascii_case(name))
335}
336
337/// Insert the full `X-Tapes-*` envelope for a resolved harness session.
338///
339/// Attaches `X-Tapes-Harness-Id` (the session's own
340/// [`HarnessSession::harness_id`]), `X-Tapes-Harness-Session-Id`,
341/// `X-Tapes-Harness-Version`, `X-Tapes-Cwd`, `X-Tapes-Session-Name`
342/// (percent-encoded UTF-8), optional `X-Tapes-Parent-Harness-Session-Id` when
343/// `parent_sid` is set, and `X-Tapes-Harness-Metadata` (base64url(JSON) of the
344/// session's metadata object) when the JSON fits the 4 KiB raw cap.
345///
346/// Budget enforcement: the total `X-Tapes-*` byte budget is 8 KiB.
347/// When the metadata header would push the running total over the cap,
348/// the metadata header is dropped silently and the rest proceeds. The
349/// session name is bounded to 256 bytes raw before percent-encoding so
350/// it can't dominate the budget.
351///
352/// All non-ASCII content arrives either as percent-encoded UTF-8
353/// (`X-Tapes-Session-Name`, `X-Tapes-Cwd`) or base64url
354/// (`X-Tapes-Harness-Metadata`). Any other field that happens to contain
355/// bytes invalid in an HTTP header value (CR/LF/NUL) is dropped — that
356/// header is omitted from the envelope rather than returning an error.
357/// The "mandatory headers" guarantee is satisfied by always emitting the
358/// `X-Tapes-Harness-Id` header.
359///
360/// # Errors
361///
362/// Returns [`HeaderError::InvalidValue`] only if the required harness-id
363/// value is not valid HTTP-header bytes. Unreachable in practice — the
364/// fallback path substitutes the ASCII `unknown` constant — but the
365/// signature keeps the failure visible to callers.
366pub fn inject_session_envelope(
367    headers: &mut HeaderMap,
368    session: &impl HarnessSession,
369    parent_sid: Option<&str>,
370) -> Result<(), HeaderError> {
371    inject_tapes_attribution(headers, TapesAttribution::from_session(session, parent_sid))
372}
373
374/// Insert the envelope for a request nobody could attribute.
375///
376/// Attaches only `X-Tapes-Harness-Id: unknown` — unless the inbound request
377/// already carries a complete envelope, which is preserved as-is because a
378/// harness that stamped its own identity from inside itself knows more than a
379/// failed lookup does. Any stale *partial* envelope is cleared first, so a
380/// half-stated identity never rides out alongside the sentinel.
381///
382/// The counterpart to [`inject_session_envelope`], and split from it rather
383/// than expressed as its `None` case: the two arms share no input. This one
384/// needs no session type at all, which is what lets a caller with nothing to
385/// say reach the sentinel path without naming a harness's session shape.
386///
387/// # Errors
388///
389/// Returns [`HeaderError::InvalidValue`] if the ASCII `unknown` constant is
390/// somehow not valid HTTP-header bytes. Unreachable; the signature keeps the
391/// failure visible.
392pub fn inject_unattributed_envelope(headers: &mut HeaderMap) -> Result<(), HeaderError> {
393    if has_complete_inbound_envelope(headers) {
394        return Ok(());
395    }
396    clear_tapes_headers(headers);
397    inject_tapes_attribution(headers, TapesAttribution::unknown())
398}
399
400/// Session-attribution envelope to serialize into `X-Tapes-*` headers.
401///
402/// Fields are public so a consumer can construct an attribution the named
403/// constructors below don't express — the fixture oracle does exactly that,
404/// because the shared corpus spans harnesses (`pi`) and field combinations
405/// production has no constructor for.
406pub struct TapesAttribution {
407    /// Harness identifier; [`HARNESS_ID_UNKNOWN`] selects the
408    /// single-header path.
409    pub harness_id: String,
410    /// Opaque harness-side session id.
411    pub session_id: Option<String>,
412    /// Harness version string.
413    pub version: Option<String>,
414    /// Harness working directory; percent-encoded on the wire.
415    pub cwd: Option<String>,
416    /// User-given session name; capped and percent-encoded on the wire.
417    pub name: Option<String>,
418    /// Fork-parent's harness session id.
419    pub parent_sid: Option<String>,
420    /// Free-form harness metadata; base64url(JSON) on the wire.
421    pub metadata: serde_json::Map<String, serde_json::Value>,
422}
423
424impl TapesAttribution {
425    /// The cold-race / unrecognised-harness fallback.
426    #[must_use]
427    pub fn unknown() -> Self {
428        Self {
429            harness_id: HARNESS_ID_UNKNOWN.to_owned(),
430            session_id: None,
431            version: None,
432            cwd: None,
433            name: None,
434            parent_sid: None,
435            metadata: serde_json::Map::new(),
436        }
437    }
438
439    /// Codex traffic whose session identity has not been resolved.
440    #[must_use]
441    pub fn codex() -> Self {
442        Self::codex_with_metadata(serde_json::Map::new())
443    }
444
445    /// Codex traffic with no resolved rollout, carrying request-derived
446    /// metadata (a proxy that saw useful request headers but could not
447    /// name a session still forwards what it learned).
448    #[must_use]
449    pub fn codex_with_metadata(metadata: serde_json::Map<String, serde_json::Value>) -> Self {
450        Self {
451            harness_id: HARNESS_ID_CODEX.to_owned(),
452            session_id: None,
453            version: None,
454            cwd: None,
455            name: None,
456            parent_sid: None,
457            metadata,
458        }
459    }
460
461    /// Codex traffic attributed to a resolved rollout session.
462    #[must_use]
463    pub fn codex_session(
464        session_id: &str,
465        cwd: Option<&str>,
466        cli_version: Option<&str>,
467        metadata: serde_json::Map<String, serde_json::Value>,
468    ) -> Self {
469        Self::codex_session_with_parent(session_id, None, cwd, cli_version, metadata)
470    }
471
472    /// Codex traffic attributed to a session id with optional resume/fork
473    /// parent lineage.
474    ///
475    /// `parent_sid` keeps the envelope's resume/fork meaning: it must name
476    /// a harness SESSION, never a sub-thread. A thread-spawn transcript's
477    /// `parent_thread_id` names a THREAD, and emitting it here would make a
478    /// consumer placeholder-insert a bogus session keyed by that thread id
479    /// — pass `None` for subagent rollouts.
480    #[must_use]
481    pub fn codex_session_with_parent(
482        session_id: &str,
483        parent_sid: Option<&str>,
484        cwd: Option<&str>,
485        cli_version: Option<&str>,
486        metadata: serde_json::Map<String, serde_json::Value>,
487    ) -> Self {
488        Self {
489            harness_id: HARNESS_ID_CODEX.to_owned(),
490            session_id: Some(session_id.to_owned()),
491            version: cli_version.map(str::to_owned),
492            cwd: cwd.map(str::to_owned),
493            name: None,
494            parent_sid: parent_sid.map(str::to_owned),
495            metadata,
496        }
497    }
498
499    /// Read an attribution back out of an envelope a harness stamped on
500    /// itself, for harnesses whose session identity comes from a managed
501    /// extension rather than from this crate's session watchers.
502    ///
503    /// `None` unless the headers carry a **complete** envelope: a harness id
504    /// that is present and is not the [`HARNESS_ID_UNKNOWN`] sentinel, plus a
505    /// non-blank session id. That completeness rule is the point of this
506    /// constructor. The same rule decides two things in different processes —
507    /// here, whether a capture client files a turn under an inbound envelope;
508    /// and in [`inject_unattributed_envelope`], whether the producer *preserves* an
509    /// inbound envelope instead of overwriting it with `unknown`. Two
510    /// spellings of it drift into a request whose headers say `pi` and whose
511    /// ingest row says `unknown`, so both callers come through here.
512    ///
513    /// Only the plain-text fields are read. `cwd`, session name, and metadata
514    /// are percent-encoded or base64url on the wire, and this module is the
515    /// envelope's *producer* half — the parsers live on the tapes side and
516    /// table-test against the same corpus. Decoding here would stand up a
517    /// second, drifting implementation of an encoder that is twenty lines
518    /// above, so those fields come back empty rather than guessed. Nothing is
519    /// lost today: the self-attributing harnesses stamp exactly the fields
520    /// this reads. A harness that starts sending the encoded ones wants the
521    /// decode half added here, once, not at each call site.
522    #[must_use]
523    pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
524        let harness_id =
525            envelope_field(headers, X_TAPES_HARNESS_ID).filter(|id| id != HARNESS_ID_UNKNOWN)?;
526        let session_id = envelope_field(headers, X_TAPES_HARNESS_SESSION_ID)?;
527        Some(Self {
528            harness_id,
529            session_id: Some(session_id),
530            version: envelope_field(headers, X_TAPES_HARNESS_VERSION),
531            cwd: None,
532            name: None,
533            parent_sid: envelope_field(headers, X_TAPES_PARENT_HARNESS_SESSION_ID),
534            metadata: serde_json::Map::new(),
535        })
536    }
537
538    /// Traffic attributed to a resolved harness session, with optional
539    /// recovered fork-parent lineage.
540    ///
541    /// Takes [`HarnessSession`] rather than any harness's own session type.
542    /// That is the whole reason this module can live beside the wire format
543    /// instead of beside the harness registry: the projection from "what the
544    /// harness published" to "what the envelope carries" is stated once, as a
545    /// requirement, and each harness satisfies it on its own side. A new
546    /// harness reaches this constructor by implementing the trait — nothing
547    /// here learns its name.
548    #[must_use]
549    pub fn from_session(session: &impl HarnessSession, parent_sid: Option<&str>) -> Self {
550        Self {
551            harness_id: session.harness_id().to_owned(),
552            session_id: Some(session.session_id().to_owned()),
553            version: session.version().map(str::to_owned),
554            cwd: session.cwd().map(str::to_owned),
555            name: session.name().map(str::to_owned),
556            parent_sid: parent_sid.map(str::to_owned),
557            metadata: session.metadata(),
558        }
559    }
560}
561
562/// Insert the `X-Tapes-*` envelope headers for an already-resolved
563/// attribution.
564///
565/// # Errors
566///
567/// Returns [`HeaderError::InvalidValue`] if the required harness-id value
568/// is not valid HTTP-header bytes. Unreachable in practice: the failure
569/// path below wipes the partial envelope and substitutes the ASCII
570/// `unknown` constant.
571pub fn inject_tapes_attribution(
572    headers: &mut HeaderMap,
573    attribution: TapesAttribution,
574) -> Result<(), HeaderError> {
575    // Unknown-harness path: one header, no further work. Always
576    // succeeds — the constant is ASCII.
577    if attribution.harness_id == HARNESS_ID_UNKNOWN {
578        let value = HeaderValue::from_static(HARNESS_ID_UNKNOWN);
579        headers.insert(HeaderName::from_static(X_TAPES_HARNESS_ID), value);
580        return Ok(());
581    }
582
583    let mut budget = X_TAPES_TOTAL_BUDGET;
584
585    // 1. Harness-Id (REQUIRED). This is the one mandatory header on
586    //    the known-harness arm. If insertion fails for any reason
587    //    (unreachable today — the value comes from a fixed set of
588    //    constants — but defensive against a future refactor that lets
589    //    the harness-id be dynamic), wipe any other `X-Tapes-*` header
590    //    we might have inserted and fall through to the unknown-harness
591    //    path. Better to attribute as `unknown` than to ship an
592    //    envelope that's missing the required header.
593    if let Err(err) = insert_required_ascii(
594        headers,
595        X_TAPES_HARNESS_ID,
596        &attribution.harness_id,
597        &mut budget,
598    ) {
599        warn!(
600            harness_id = %attribution.harness_id,
601            error = ?err,
602            "tapes-headers: required X-Tapes-Harness-Id insert failed; falling back to unknown",
603        );
604        clear_tapes_headers(headers);
605        let value = HeaderValue::from_static(HARNESS_ID_UNKNOWN);
606        headers.insert(HeaderName::from_static(X_TAPES_HARNESS_ID), value);
607        return Ok(());
608    }
609
610    // 2. Non-metadata headers next. Each tries to fit inside the
611    //    remaining budget and is silently dropped if the value is
612    //    invalid (e.g. internal CR/LF) or oversize — optional fields
613    //    drop rather than failing the request.
614    if let Some(session_id) = attribution.session_id.as_deref() {
615        try_insert_string(headers, X_TAPES_HARNESS_SESSION_ID, session_id, &mut budget);
616    }
617    if let Some(v) = attribution.version.as_deref() {
618        try_insert_string(headers, X_TAPES_HARNESS_VERSION, v, &mut budget);
619    }
620    if let Some(cwd) = attribution.cwd.as_deref() {
621        // Paths on macOS/Linux can contain non-ASCII bytes (Japanese
622        // home dirs, accented characters); raw `HeaderValue::from_str`
623        // would reject them and silently drop the header. Encode the
624        // same way as the session name so the upstream sees a stable
625        // ASCII form.
626        let encoded = utf8_percent_encode(cwd, UTF8_VALUE_ESCAPE).to_string();
627        try_insert_string(headers, X_TAPES_CWD, &encoded, &mut budget);
628    }
629    if let Some(name) = attribution.name.as_deref() {
630        try_insert_session_name(headers, name, &mut budget);
631    }
632    if let Some(parent) = attribution.parent_sid.as_deref() {
633        try_insert_string(
634            headers,
635            X_TAPES_PARENT_HARNESS_SESSION_ID,
636            parent,
637            &mut budget,
638        );
639    }
640
641    // 3. Metadata blob — LOWEST priority and the first thing dropped
642    //    when the envelope can't fit. By this point the non-metadata
643    //    headers have already consumed their share of the budget.
644    //    `try_insert_metadata` then checks the raw 4 KiB cap AND the
645    //    remaining total budget BEFORE the insert. No "insert then
646    //    remove" path: the encoded size is known up front from the
647    //    base64url-encoded buffer length, so the drop semantics are
648    //    stable regardless of any future reordering of the non-metadata
649    //    headers above.
650    try_insert_metadata(headers, attribution.metadata, &mut budget);
651
652    Ok(())
653}
654
655/// Returns true when the inbound request already has the minimum
656/// envelope tapes needs to group turns under a stable session. Used for
657/// harnesses whose session identity is supplied by a managed extension
658/// rather than by this crate's session watchers.
659///
660/// Delegates to [`TapesAttribution::from_headers`] so the rule has exactly one
661/// implementation — see that constructor for why a second spelling is a bug
662/// rather than a duplication.
663#[must_use]
664pub fn has_complete_inbound_envelope(headers: &HeaderMap) -> bool {
665    TapesAttribution::from_headers(headers).is_some()
666}
667
668/// One `X-Tapes-*` header's value, trimmed, treating absent, non-ASCII,
669/// and blank alike as "not stated".
670///
671/// Distinct from [`header_str`], which deliberately does not trim: that one
672/// feeds [`HarnessThreadRule::DivergentPair`], where the comparison is against
673/// another raw header value and must see the bytes that arrived.
674fn envelope_field(headers: &HeaderMap, name: &str) -> Option<String> {
675    headers
676        .get(name)
677        .and_then(|value| value.to_str().ok())
678        .map(str::trim)
679        .filter(|value| !value.is_empty())
680        .map(str::to_owned)
681}
682
683/// Remove every `X-Tapes-*` header in `headers` in-place. Called from
684/// the required-header failure path so we don't ship a partial
685/// envelope. Implemented as collect-then-remove because `HeaderMap`'s
686/// iteration borrows immutably; the allocation is bounded by the
687/// inserted-so-far count (at most ~7 entries).
688fn clear_tapes_headers(headers: &mut HeaderMap) {
689    let to_remove: Vec<HeaderName> = headers
690        .keys()
691        .filter(|n| n.as_str().to_ascii_lowercase().starts_with(HEADER_PREFIX))
692        .cloned()
693        .collect();
694    for name in to_remove {
695        headers.remove(&name);
696    }
697}
698
699/// Insert an ASCII-only header and decrement the budget. Returns an
700/// error only if the (impossible) `from_str` fails — used for the
701/// `X-Tapes-Harness-Id` header where the value is a known constant.
702fn insert_required_ascii(
703    headers: &mut HeaderMap,
704    name: &'static str,
705    value: &str,
706    budget: &mut usize,
707) -> Result<(), HeaderError> {
708    let val = HeaderValue::from_str(value).context(header_error::InvalidValueSnafu)?;
709    let cost = name.len() + value.len();
710    *budget = budget.saturating_sub(cost);
711    headers.insert(HeaderName::from_static(name), val);
712    Ok(())
713}
714
715/// Insert `value` under `name` if (a) it fits in the remaining
716/// budget and (b) it is a valid HTTP header value. Failure on either
717/// front silently drops the header — optional fields drop rather than
718/// erroring so a malformed value never fails the whole request.
719fn try_insert_string(headers: &mut HeaderMap, name: &'static str, value: &str, budget: &mut usize) {
720    let cost = name.len() + value.len();
721    if cost > *budget {
722        return;
723    }
724    let Ok(val) = HeaderValue::from_str(value) else {
725        return;
726    };
727    *budget -= cost;
728    headers.insert(HeaderName::from_static(name), val);
729}
730
731/// Percent-encode `name` (UTF-8 → ASCII), bounded to 256 raw bytes,
732/// and insert if it fits the budget. Encoding expansion is bounded
733/// (worst case ~3× for all-multibyte input); the budget check is on
734/// the encoded length so even a heavy expansion can't overrun.
735fn try_insert_session_name(headers: &mut HeaderMap, name: &str, budget: &mut usize) {
736    let raw = if name.len() > X_TAPES_SESSION_NAME_CAP {
737        // Truncate at a UTF-8 boundary at or below the cap so the
738        // percent-encoder never sees a split codepoint.
739        let mut end = X_TAPES_SESSION_NAME_CAP;
740        while end > 0 && !name.is_char_boundary(end) {
741            end -= 1;
742        }
743        &name[..end]
744    } else {
745        name
746    };
747    let encoded = utf8_percent_encode(raw, UTF8_VALUE_ESCAPE).to_string();
748    try_insert_string(headers, X_TAPES_SESSION_NAME, &encoded, budget);
749}
750
751/// Build the metadata JSON object (structured fields the producer cares
752/// about plus the harness's verbatim `extra` map), base64url-encode
753/// it, and insert if both the raw JSON fits the 4 KiB cap and the
754/// encoded header fits the remaining total budget. Silently dropped
755/// otherwise.
756fn try_insert_metadata(
757    headers: &mut HeaderMap,
758    obj: serde_json::Map<String, serde_json::Value>,
759    budget: &mut usize,
760) {
761    if obj.is_empty() {
762        return;
763    }
764    let Ok(raw) = serde_json::to_vec(&serde_json::Value::Object(obj)) else {
765        return;
766    };
767    if raw.len() > X_TAPES_METADATA_RAW_CAP {
768        return;
769    }
770    let encoded = URL_SAFE_NO_PAD.encode(&raw);
771    try_insert_string(headers, X_TAPES_HARNESS_METADATA, &encoded, budget);
772}
773
774// The vendored shared envelope fixtures: a public reader (under the
775// `envelope-fixtures` feature) plus this crate's own producer-side oracle over
776// them. Declared as a child module of `envelope` rather than as an integration
777// test so it can construct a `TapesAttribution` field-by-field — the corpus
778// covers harnesses and field combinations the named constructors don't express.
779//
780// Compiled for this crate's own tests regardless of the feature, so the oracle
781// runs on a bare `cargo test`.
782#[cfg(any(test, feature = "envelope-fixtures"))]
783#[path = "envelope_fixtures.rs"]
784pub mod fixtures;
785
786#[cfg(test)]
787#[allow(clippy::unwrap_used, clippy::expect_used)]
788mod tests {
789    use super::*;
790    use http::HeaderValue;
791
792    #[test]
793    fn hop_by_hop_list_matches_rfc7230() {
794        assert!(
795            is_hop_by_hop("Connection"),
796            "case-insensitive match for canonical-cased header"
797        );
798        assert!(is_hop_by_hop("transfer-encoding"), "lower-case input");
799        assert!(is_hop_by_hop("PROXY-AUTHENTICATE"), "upper-case input");
800        assert!(is_hop_by_hop("Keep-Alive"));
801        assert!(is_hop_by_hop("TE"));
802        assert!(is_hop_by_hop("Trailers"));
803        assert!(is_hop_by_hop("Upgrade"));
804
805        assert!(
806            !is_hop_by_hop("Content-Length"),
807            "end-to-end header is not hop-by-hop"
808        );
809        assert!(!is_hop_by_hop("Content-Type"));
810        assert!(!is_hop_by_hop("X-Paper-Auth"));
811    }
812
813    #[test]
814    fn request_capture_cap_matches_the_gateway_contract() {
815        // 32 MiB is the provider request ceiling the gateway commits to
816        // (tko ProviderMaxRequestBytes / tapes ingest MaxDecodedRequestBytes).
817        // A consumer wiring its peek cap to this constant captures exactly
818        // what the platform captures; if the contract retunes, this pin
819        // forces the change to be deliberate on the client side too.
820        assert_eq!(REQUEST_CAPTURE_CAP, 32 * 1024 * 1024);
821    }
822
823    #[test]
824    fn is_hop_by_hop_matches_every_listed_header_in_any_case() {
825        // `hop_by_hop_list_matches_rfc7230` above spot-checks three
826        // entries; this covers the whole list, so an entry added to
827        // HOP_BY_HOP_HEADERS in a form that defeats the comparison
828        // (stray whitespace, embedded upper-case) fails here rather
829        // than leaking a connection-scoped header across the proxy
830        // boundary at runtime.
831        //
832        // The match is `eq_ignore_ascii_case` against a lower-cased
833        // table, so every case permutation of a listed name must hit.
834        for name in HOP_BY_HOP_HEADERS {
835            let upper = name.to_ascii_uppercase();
836            // Title-Case-Each-Word, the form an HTTP stack most often
837            // presents (`Transfer-Encoding`, `Proxy-Authenticate`).
838            let title: String = name
839                .split('-')
840                .map(|seg| {
841                    let mut c = seg.chars();
842                    match c.next() {
843                        Some(first) => first.to_ascii_uppercase().to_string() + c.as_str(),
844                        None => String::new(),
845                    }
846                })
847                .collect::<Vec<_>>()
848                .join("-");
849
850            assert!(is_hop_by_hop(name), "lower-case `{name}` must match");
851            assert!(is_hop_by_hop(&upper), "upper-case `{upper}` must match");
852            assert!(is_hop_by_hop(&title), "title-case `{title}` must match");
853        }
854
855        // The table itself must stay lower-case: the comparison is
856        // case-insensitive, but a mixed-case entry would still be a
857        // latent trap for any caller that compares against the
858        // constant directly instead of going through `is_hop_by_hop`.
859        for name in HOP_BY_HOP_HEADERS {
860            assert_eq!(
861                *name,
862                name.to_ascii_lowercase(),
863                "HOP_BY_HOP_HEADERS entries are listed lower-case",
864            );
865        }
866    }
867
868    /// A harness session for header tests, modelled on the shape Claude
869    /// publishes — modelled fields that become metadata keys, plus a verbatim
870    /// `extra` passthrough — without importing any harness's own type. The
871    /// producer must work for whatever satisfies [`HarnessSession`]; testing
872    /// it through one harness's struct would let a change to that struct
873    /// masquerade as a change to the wire format.
874    struct SampleSession {
875        session_id: String,
876        cwd: Option<String>,
877        version: Option<String>,
878        peer_protocol: Option<i64>,
879        kind: Option<String>,
880        entrypoint: Option<String>,
881        name: Option<String>,
882        extra: serde_json::Map<String, serde_json::Value>,
883    }
884
885    impl HarnessSession for SampleSession {
886        fn harness_id(&self) -> &str {
887            HARNESS_ID_CLAUDE
888        }
889        fn session_id(&self) -> &str {
890            &self.session_id
891        }
892        fn version(&self) -> Option<&str> {
893            self.version.as_deref()
894        }
895        fn cwd(&self) -> Option<&str> {
896            self.cwd.as_deref()
897        }
898        fn name(&self) -> Option<&str> {
899            self.name.as_deref()
900        }
901        fn metadata(&self) -> serde_json::Map<String, serde_json::Value> {
902            let mut metadata = serde_json::Map::new();
903            if let Some(kind) = &self.kind {
904                metadata.insert("kind".to_owned(), serde_json::Value::String(kind.clone()));
905            }
906            if let Some(entrypoint) = &self.entrypoint {
907                metadata.insert(
908                    "entrypoint".to_owned(),
909                    serde_json::Value::String(entrypoint.clone()),
910                );
911            }
912            if let Some(pp) = self.peer_protocol {
913                metadata.insert(
914                    "peerProtocol".to_owned(),
915                    serde_json::Value::Number(pp.into()),
916                );
917            }
918            for (k, v) in &self.extra {
919                metadata.insert(k.clone(), v.clone());
920            }
921            metadata
922        }
923    }
924
925    /// Build a session with sensible defaults for header tests. Override what
926    /// the test cares about by mutation after the call.
927    fn sample_session() -> SampleSession {
928        SampleSession {
929            session_id: "eae77e15-c7d2-4883-b82e-251161f8eeb3".to_owned(),
930            cwd: Some("/Users/matt/code".to_owned()),
931            version: Some("2.1.145".to_owned()),
932            peer_protocol: Some(1),
933            kind: Some("interactive".to_owned()),
934            entrypoint: Some("cli".to_owned()),
935            name: Some("woo-names".to_owned()),
936            extra: serde_json::Map::new(),
937        }
938    }
939
940    #[test]
941    fn unattributed_envelope_is_the_unknown_sentinel_alone() {
942        // Cold-race / non-Claude callers (curl, health probes,
943        // unparsed metadata) land on the unknown-harness path:
944        // exactly one header, value `unknown`.
945        let mut headers = HeaderMap::new();
946        inject_unattributed_envelope(&mut headers).unwrap();
947
948        assert_eq!(
949            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
950            HARNESS_ID_UNKNOWN
951        );
952        // The unknown-harness path attaches nothing else.
953        assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
954        assert!(!headers.contains_key(X_TAPES_CWD));
955        assert!(!headers.contains_key(X_TAPES_SESSION_NAME));
956        assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
957    }
958
959    #[test]
960    fn unattributed_envelope_names_no_fork_parent() {
961        // The unknown path has no harness session to be a fork of, so it
962        // emits no lineage. This used to be stated by passing a `parent_sid`
963        // and requiring it to be ignored; the unattributed entry point no
964        // longer accepts one, which makes the property structural. The
965        // assertion stays because "structurally impossible" is a claim about
966        // today's signature, and the header is the thing that must be absent.
967        let mut headers = HeaderMap::new();
968        inject_unattributed_envelope(&mut headers).unwrap();
969        assert!(!headers.contains_key(X_TAPES_PARENT_HARNESS_SESSION_ID));
970    }
971
972    #[test]
973    fn inject_tapes_attribution_codex_without_session_id() {
974        let mut headers = HeaderMap::new();
975        inject_tapes_attribution(&mut headers, TapesAttribution::codex()).unwrap();
976
977        assert_eq!(
978            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
979            HARNESS_ID_CODEX
980        );
981        assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
982        assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
983    }
984
985    #[test]
986    fn inject_tapes_attribution_codex_with_session_metadata() {
987        let mut headers = HeaderMap::new();
988        let mut metadata = serde_json::Map::new();
989        metadata.insert(
990            "originator".to_owned(),
991            serde_json::Value::String("codex-tui".to_owned()),
992        );
993        inject_tapes_attribution(
994            &mut headers,
995            TapesAttribution::codex_session(
996                "019ecd8e-4281-7353-8a00-09df678443b1",
997                Some("/Users/matt/code"),
998                Some("0.139.0"),
999                metadata,
1000            ),
1001        )
1002        .unwrap();
1003
1004        assert_eq!(
1005            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1006            HARNESS_ID_CODEX
1007        );
1008        assert_eq!(
1009            headers
1010                .get(X_TAPES_HARNESS_SESSION_ID)
1011                .unwrap()
1012                .to_str()
1013                .unwrap(),
1014            "019ecd8e-4281-7353-8a00-09df678443b1"
1015        );
1016        assert_eq!(
1017            headers
1018                .get(X_TAPES_HARNESS_VERSION)
1019                .unwrap()
1020                .to_str()
1021                .unwrap(),
1022            "0.139.0"
1023        );
1024        let raw = URL_SAFE_NO_PAD
1025            .decode(
1026                headers
1027                    .get(X_TAPES_HARNESS_METADATA)
1028                    .unwrap()
1029                    .to_str()
1030                    .unwrap(),
1031            )
1032            .unwrap();
1033        let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1034        assert_eq!(json["originator"], "codex-tui");
1035    }
1036
1037    /// Ported from the extension shim these constructors replaced: the
1038    /// metadata-only and parent-lineage constructors fill exactly the
1039    /// fields their arguments name and nothing else.
1040    #[test]
1041    fn codex_constructors_fill_only_the_fields_their_arguments_name() {
1042        let mut metadata = serde_json::Map::new();
1043        metadata.insert("k".to_owned(), serde_json::Value::String("v".to_owned()));
1044
1045        let bare = TapesAttribution::codex_with_metadata(metadata.clone());
1046        assert_eq!(bare.harness_id, HARNESS_ID_CODEX);
1047        assert!(bare.session_id.is_none());
1048        assert!(bare.parent_sid.is_none());
1049        assert_eq!(bare.metadata, metadata);
1050
1051        let keyed = TapesAttribution::codex_session_with_parent(
1052            "sid-1",
1053            Some("parent-sid"),
1054            Some("/tmp/x"),
1055            Some("0.99.0"),
1056            metadata.clone(),
1057        );
1058        assert_eq!(keyed.harness_id, HARNESS_ID_CODEX);
1059        assert_eq!(keyed.session_id.as_deref(), Some("sid-1"));
1060        assert_eq!(keyed.parent_sid.as_deref(), Some("parent-sid"));
1061        assert_eq!(keyed.cwd.as_deref(), Some("/tmp/x"));
1062        assert_eq!(keyed.version.as_deref(), Some("0.99.0"));
1063        assert_eq!(keyed.metadata, metadata);
1064
1065        // The pre-existing constructors are the no-argument special
1066        // cases of the new ones and must stay behaviorally identical.
1067        let plain = TapesAttribution::codex();
1068        assert!(plain.session_id.is_none() && plain.metadata.is_empty());
1069        let sessioned =
1070            TapesAttribution::codex_session("sid-1", Some("/tmp/x"), Some("0.99.0"), metadata);
1071        assert!(sessioned.parent_sid.is_none());
1072        assert_eq!(sessioned.session_id.as_deref(), Some("sid-1"));
1073    }
1074
1075    #[test]
1076    fn unattributed_envelope_preserves_a_complete_inbound_one() {
1077        let mut headers = HeaderMap::new();
1078        headers.insert(X_TAPES_HARNESS_ID, HeaderValue::from_static(HARNESS_ID_PI));
1079        headers.insert(
1080            X_TAPES_HARNESS_SESSION_ID,
1081            HeaderValue::from_static("paper-pi-test-session"),
1082        );
1083
1084        inject_unattributed_envelope(&mut headers).unwrap();
1085
1086        assert_eq!(
1087            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1088            HARNESS_ID_PI
1089        );
1090        assert_eq!(
1091            headers
1092                .get(X_TAPES_HARNESS_SESSION_ID)
1093                .unwrap()
1094                .to_str()
1095                .unwrap(),
1096            "paper-pi-test-session"
1097        );
1098    }
1099
1100    #[test]
1101    fn unattributed_envelope_replaces_a_partial_inbound_one() {
1102        let mut headers = HeaderMap::new();
1103        headers.insert(X_TAPES_HARNESS_ID, HeaderValue::from_static(HARNESS_ID_PI));
1104
1105        inject_unattributed_envelope(&mut headers).unwrap();
1106
1107        assert_eq!(
1108            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1109            HARNESS_ID_UNKNOWN
1110        );
1111        assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1112    }
1113
1114    #[test]
1115    fn unattributed_envelope_clears_an_orphan_session_id() {
1116        let mut headers = HeaderMap::new();
1117        headers.insert(
1118            X_TAPES_HARNESS_SESSION_ID,
1119            HeaderValue::from_static("orphan-pi-session"),
1120        );
1121
1122        inject_unattributed_envelope(&mut headers).unwrap();
1123
1124        assert_eq!(
1125            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1126            HARNESS_ID_UNKNOWN
1127        );
1128        assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1129    }
1130
1131    #[test]
1132    fn session_envelope_is_well_formed() {
1133        // Happy path: full session, no parent. All structured fields
1134        // populated → all corresponding headers present with the
1135        // verbatim values, plus base64url-encoded metadata.
1136        let mut headers = HeaderMap::new();
1137        let session = sample_session();
1138        inject_session_envelope(&mut headers, &session, None).unwrap();
1139
1140        assert_eq!(
1141            headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1142            HARNESS_ID_CLAUDE
1143        );
1144        assert_eq!(
1145            headers
1146                .get(X_TAPES_HARNESS_SESSION_ID)
1147                .unwrap()
1148                .to_str()
1149                .unwrap(),
1150            session.session_id
1151        );
1152        assert_eq!(
1153            headers
1154                .get(X_TAPES_HARNESS_VERSION)
1155                .unwrap()
1156                .to_str()
1157                .unwrap(),
1158            "2.1.145"
1159        );
1160        assert_eq!(
1161            headers.get(X_TAPES_CWD).unwrap().to_str().unwrap(),
1162            "/Users/matt/code"
1163        );
1164        assert_eq!(
1165            headers.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap(),
1166            "woo-names"
1167        );
1168        assert!(!headers.contains_key(X_TAPES_PARENT_HARNESS_SESSION_ID));
1169
1170        // Metadata is base64url(no-pad) of the JSON {kind,
1171        // entrypoint, peerProtocol}. Decode and check structure
1172        // instead of comparing exact bytes so the assertion isn't
1173        // brittle to JSON key ordering.
1174        let encoded = headers
1175            .get(X_TAPES_HARNESS_METADATA)
1176            .unwrap()
1177            .to_str()
1178            .unwrap();
1179        let raw = URL_SAFE_NO_PAD.decode(encoded).unwrap();
1180        let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1181        assert_eq!(json["kind"], "interactive");
1182        assert_eq!(json["entrypoint"], "cli");
1183        assert_eq!(json["peerProtocol"], 1);
1184    }
1185
1186    #[test]
1187    fn session_envelope_attaches_parent_when_present() {
1188        let mut headers = HeaderMap::new();
1189        let session = sample_session();
1190        inject_session_envelope(&mut headers, &session, Some("parent-sid-uuid")).unwrap();
1191        assert_eq!(
1192            headers
1193                .get(X_TAPES_PARENT_HARNESS_SESSION_ID)
1194                .unwrap()
1195                .to_str()
1196                .unwrap(),
1197            "parent-sid-uuid"
1198        );
1199    }
1200
1201    #[test]
1202    fn session_envelope_omits_unset_optionals() {
1203        // None for an optional field means "harness didn't write
1204        // it". We omit the header rather than emitting a sentinel
1205        // empty value so absent and empty stay distinguishable
1206        // downstream.
1207        let mut headers = HeaderMap::new();
1208        let mut session = sample_session();
1209        session.cwd = None;
1210        session.version = None;
1211        session.name = None;
1212        // Metadata blob still has kind/entrypoint/peerProtocol so it
1213        // stays — we're testing the structured-string omissions.
1214        inject_session_envelope(&mut headers, &session, None).unwrap();
1215
1216        assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1217        assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1218        assert!(!headers.contains_key(X_TAPES_CWD));
1219        assert!(!headers.contains_key(X_TAPES_HARNESS_VERSION));
1220        assert!(!headers.contains_key(X_TAPES_SESSION_NAME));
1221    }
1222
1223    #[test]
1224    fn session_envelope_percent_encodes_unicode_session_name() {
1225        // The session name is the only header value that may carry
1226        // arbitrary UTF-8 (slash-command `/name` accepts anything
1227        // the user types). Encoded form must be ASCII per RFC 7230.
1228        let mut headers = HeaderMap::new();
1229        let mut session = sample_session();
1230        // A non-ASCII codepoint and a structural ASCII byte, so we
1231        // exercise the encoder's UTF-8 and ASCII-escape paths in one
1232        // shot.
1233        session.name = Some("name with space \"quotes\" café".to_owned());
1234        inject_session_envelope(&mut headers, &session, None).unwrap();
1235
1236        let v = headers
1237            .get(X_TAPES_SESSION_NAME)
1238            .unwrap()
1239            .to_str()
1240            .expect("encoded header is ASCII");
1241        // Space and " are percent-encoded; the e-acute encodes to
1242        // its UTF-8 bytes `%C3%A9`.
1243        assert!(v.contains("%20"), "space is percent-encoded: {v}");
1244        assert!(v.contains("%22"), "quote is percent-encoded: {v}");
1245        assert!(
1246            v.contains("%C3%A9"),
1247            "non-ASCII is UTF-8 percent-encoded: {v}"
1248        );
1249        assert!(v.is_ascii(), "encoded value must be pure ASCII");
1250    }
1251
1252    #[test]
1253    fn session_envelope_truncates_session_name_at_utf8_boundary() {
1254        // Names beyond X_TAPES_SESSION_NAME_CAP (256 B raw) are
1255        // truncated to the cap before encoding. Truncation must
1256        // happen at a UTF-8 codepoint boundary so the encoder
1257        // doesn't see a split codepoint.
1258        let mut headers = HeaderMap::new();
1259        let mut session = sample_session();
1260        // 100 copies of a 3-byte codepoint (Thai `ก` = 0xE0 0xB8 0x81)
1261        // = 300 raw bytes, which exceeds the 256-byte cap. 256 mod 3
1262        // == 1, so byte 256 is mid-codepoint — the truncation logic
1263        // MUST walk back to byte 255 to land on a boundary (the start
1264        // of the 86th codepoint at offset 255, which we then drop).
1265        // After truncation: 85 codepoints survive (255 bytes raw),
1266        // each percent-encoded to `%E0%B8%81` (9 ASCII bytes), so
1267        // encoded length is 85 × 9 = 765.
1268        session.name = Some("ก".repeat(100));
1269        inject_session_envelope(&mut headers, &session, None).unwrap();
1270
1271        // Header value is ASCII (percent-encoded). The function
1272        // must not have panicked on an invalid UTF-8 slice.
1273        let v = headers.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1274        assert!(v.is_ascii(), "encoded value is ASCII");
1275        assert_eq!(
1276            v.len(),
1277            85 * 9,
1278            "85 codepoints survive truncation (raw=255 ≤ cap=256)",
1279        );
1280    }
1281
1282    #[test]
1283    fn session_envelope_drops_oversize_metadata() {
1284        // The metadata blob is dropped (silently) when the raw
1285        // JSON exceeds X_TAPES_METADATA_RAW_CAP (4 KiB). The other
1286        // X-Tapes-* headers stay; only the metadata is omitted.
1287        let mut headers = HeaderMap::new();
1288        let mut session = sample_session();
1289        // 5 KiB of opaque content via the `extra` blob — this is
1290        // exactly the failure mode the cap defends against: a
1291        // future harness key whose value is too large.
1292        let huge: String = "x".repeat(5 * 1024);
1293        session
1294            .extra
1295            .insert("hugeKnob".to_owned(), serde_json::Value::String(huge));
1296
1297        inject_session_envelope(&mut headers, &session, None).unwrap();
1298
1299        assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1300        assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1301        assert!(headers.contains_key(X_TAPES_CWD));
1302        assert!(
1303            !headers.contains_key(X_TAPES_HARNESS_METADATA),
1304            "metadata blob dropped when raw JSON exceeds 4 KiB cap",
1305        );
1306    }
1307
1308    #[test]
1309    fn session_envelope_metadata_includes_extra_keys() {
1310        // Forward-compat: anything the harness writes that we don't
1311        // model explicitly flows through the `extra` map into the
1312        // metadata blob unchanged, so new keys travel upstream without
1313        // a capture-client release.
1314        let mut headers = HeaderMap::new();
1315        let mut session = sample_session();
1316        session.extra.insert(
1317            "futureKnob".to_owned(),
1318            serde_json::Value::String("preserved".to_owned()),
1319        );
1320        inject_session_envelope(&mut headers, &session, None).unwrap();
1321
1322        let encoded = headers
1323            .get(X_TAPES_HARNESS_METADATA)
1324            .unwrap()
1325            .to_str()
1326            .unwrap();
1327        let raw = URL_SAFE_NO_PAD.decode(encoded).unwrap();
1328        let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1329        assert_eq!(json["futureKnob"], "preserved");
1330        assert_eq!(json["kind"], "interactive");
1331    }
1332
1333    #[test]
1334    fn session_envelope_metadata_empty_when_no_blob_fields() {
1335        // No kind / entrypoint / peerProtocol / extra → the
1336        // metadata blob would be an empty JSON object. We omit the
1337        // header entirely instead of attaching `{}` (the extra
1338        // round-trip costs ~70 bytes for nothing).
1339        let mut headers = HeaderMap::new();
1340        let mut session = sample_session();
1341        session.kind = None;
1342        session.entrypoint = None;
1343        session.peer_protocol = None;
1344        session.extra.clear();
1345        inject_session_envelope(&mut headers, &session, None).unwrap();
1346
1347        assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
1348        // Other headers still present.
1349        assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1350        assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1351    }
1352
1353    #[test]
1354    fn session_envelope_escapes_control_bytes_in_cwd() {
1355        // Cwd is percent-encoded UTF-8 on the wire, so CR/LF/NUL
1356        // bytes — which RFC 7230 forbids in a raw header value, and
1357        // which would let an attacker inject a second header — are
1358        // escaped to `%0A` / `%0D` / `%00`. The header lands rather
1359        // than being silently dropped, and the encoded form is safe
1360        // for any HTTP intermediary to forward verbatim.
1361        let mut headers = HeaderMap::new();
1362        let mut session = sample_session();
1363        session.cwd = Some("/Users/matt\nwith-injection: yes".to_owned());
1364        inject_session_envelope(&mut headers, &session, None).unwrap();
1365
1366        let v = headers.get(X_TAPES_CWD).unwrap().to_str().unwrap();
1367        assert!(v.contains("%0A"), "newline is percent-encoded: {v}");
1368        assert!(
1369            !v.contains('\n'),
1370            "no raw CR/LF survives into the header value: {v}",
1371        );
1372        assert!(v.is_ascii(), "encoded value must be pure ASCII");
1373        assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1374    }
1375
1376    #[test]
1377    fn session_envelope_percent_encodes_unicode_cwd() {
1378        // Working directories on macOS/Linux can contain non-ASCII
1379        // bytes (Japanese home dirs, accented characters, emoji). A
1380        // raw `HeaderValue::from_str` only accepts visible ASCII, so
1381        // before this encoding was added the header was silently
1382        // dropped for these users. The encoded form is pure ASCII
1383        // and the upstream decoder percent-decodes it back.
1384        let mut headers = HeaderMap::new();
1385        let mut session = sample_session();
1386        session.cwd = Some("/Users/松本/code".to_owned());
1387        inject_session_envelope(&mut headers, &session, None).unwrap();
1388
1389        let v = headers.get(X_TAPES_CWD).unwrap().to_str().unwrap();
1390        assert!(v.is_ascii(), "encoded value must be pure ASCII");
1391        // 松 = U+677E = 0xE6 0x9D 0xBE in UTF-8; 本 = U+672C = 0xE6 0x9C 0xAC.
1392        assert!(v.contains("%E6%9D%BE"), "first codepoint encoded: {v}");
1393        assert!(v.contains("%E6%9C%AC"), "second codepoint encoded: {v}");
1394        // ASCII path segments survive verbatim.
1395        assert!(v.starts_with("/Users/"), "ASCII prefix preserved: {v}");
1396        assert!(v.ends_with("/code"), "ASCII suffix preserved: {v}");
1397    }
1398
1399    #[test]
1400    fn session_name_truncation_lands_at_or_below_cap_for_each_byte_offset() {
1401        // Pin the UTF-8 truncation behaviour across the cap
1402        // boundary. For each raw input length straddling the 256-byte
1403        // cap, the function must (a) not panic, (b) emit ASCII, and
1404        // (c) drop nothing more than needed to land on a codepoint
1405        // boundary at or below the cap.
1406        //
1407        // Two flavours of input:
1408        // * ASCII (every byte is a boundary): truncation lands
1409        //   exactly on the cap when raw > 256.
1410        // * 3-byte codepoint × n (Thai `ก` = 0xE0 0xB8 0x81): 256
1411        //   mod 3 == 1, so byte 256 is mid-codepoint. The truncator
1412        //   walks back to byte 255 (boundary), keeping 85
1413        //   codepoints when n_copies > 85.
1414
1415        // ASCII case: cap is trivially a boundary.
1416        for raw_len in [254usize, 255, 256, 257, 258, 299, 300, 301] {
1417            let ascii = "a".repeat(raw_len);
1418            let mut h = HeaderMap::new();
1419            let mut budget = X_TAPES_TOTAL_BUDGET;
1420            try_insert_session_name(&mut h, &ascii, &mut budget);
1421            let v = h.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1422            assert!(v.is_ascii(), "ascii input @ {raw_len} yields ASCII");
1423            // No escapable bytes in [a-z], so encoded == truncated raw.
1424            let expected = raw_len.min(X_TAPES_SESSION_NAME_CAP);
1425            assert_eq!(
1426                v.len(),
1427                expected,
1428                "ascii input @ {raw_len}: encoded length must equal min(raw, cap)",
1429            );
1430        }
1431
1432        // 3-byte codepoint case: walk-back kicks in once raw > cap.
1433        // n_copies × 3 raw bytes, then truncate to ≤ cap on a
1434        // boundary, then percent-encode (9 ASCII bytes per `ก`).
1435        for n_copies in [84usize, 85, 86, 87, 100] {
1436            let s = "ก".repeat(n_copies);
1437            let raw_len = s.len();
1438            assert_eq!(raw_len, n_copies * 3, "ก is 3 raw UTF-8 bytes");
1439            let mut h = HeaderMap::new();
1440            let mut budget = X_TAPES_TOTAL_BUDGET;
1441            try_insert_session_name(&mut h, &s, &mut budget);
1442            let v = h.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1443            assert!(v.is_ascii(), "utf8 input @ {raw_len} yields ASCII");
1444            // If raw ≤ cap: every codepoint survives.
1445            // If raw  > cap: walk back from 256 → 255 boundary →
1446            //   floor(255/3) = 85 codepoints survive.
1447            let kept = if raw_len <= X_TAPES_SESSION_NAME_CAP {
1448                n_copies
1449            } else {
1450                X_TAPES_SESSION_NAME_CAP / 3
1451            };
1452            assert_eq!(
1453                v.len(),
1454                kept * 9,
1455                "utf8 input ({n_copies} × ก, raw={raw_len}): \
1456                 encoded length matches kept codepoints",
1457            );
1458        }
1459    }
1460
1461    #[test]
1462    fn clear_tapes_headers_removes_all_envelope_headers() {
1463        // When the required-header insert fails we MUST wipe the
1464        // partial envelope before falling back to `unknown`. This test
1465        // pins the helper's behaviour: every `X-Tapes-*` header (any
1466        // case) is removed; unrelated headers are kept.
1467        let mut headers = HeaderMap::new();
1468        headers.insert(
1469            HeaderName::from_static(X_TAPES_HARNESS_ID),
1470            HeaderValue::from_static("claude"),
1471        );
1472        headers.insert(
1473            HeaderName::from_static(X_TAPES_HARNESS_SESSION_ID),
1474            HeaderValue::from_static("sid"),
1475        );
1476        headers.insert(
1477            HeaderName::from_static(X_TAPES_CWD),
1478            HeaderValue::from_static("/tmp"),
1479        );
1480        headers.insert(
1481            HeaderName::from_static(X_TAPES_HARNESS_METADATA),
1482            HeaderValue::from_static("payload"),
1483        );
1484        // An unrelated header survives the wipe — clear_tapes_headers
1485        // is scoped to the X-Tapes-* prefix only.
1486        headers.insert("authorization", HeaderValue::from_static("Bearer foo"));
1487
1488        clear_tapes_headers(&mut headers);
1489
1490        assert!(!headers.contains_key(X_TAPES_HARNESS_ID));
1491        assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1492        assert!(!headers.contains_key(X_TAPES_CWD));
1493        assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
1494        assert!(
1495            headers.contains_key("authorization"),
1496            "non-tapes headers are preserved"
1497        );
1498    }
1499
1500    // --- sub-thread resolution ------------------------------------------
1501    //
1502    // These mirror tapes-extproc's `TestThreadID` case for case. The ids are
1503    // the same captured wire evidence its table uses, so a divergence between
1504    // the two implementations shows up as one of these failing rather than as
1505    // a mis-shaped session weeks later.
1506
1507    const CODEX_ROOT: &str = "019f863d-0cd6-7ce2-b481-20abd683a14e";
1508    const CODEX_CHILD: &str = "019f8713-2213-75e3-be33-36fd2f8dd384";
1509    const CLAUDE_AGENT: &str = "agent-0a1b2c3d";
1510
1511    fn headers_from(pairs: &[(&'static str, &str)]) -> HeaderMap {
1512        let mut headers = HeaderMap::new();
1513        for (name, value) in pairs {
1514            headers.insert(
1515                HeaderName::from_static(name),
1516                HeaderValue::from_str(value).unwrap(),
1517            );
1518        }
1519        headers
1520    }
1521
1522    #[test]
1523    fn thread_id_reads_the_claude_subagent_header() {
1524        let headers = headers_from(&[("x-claude-code-agent-id", CLAUDE_AGENT)]);
1525        assert_eq!(thread_id(&headers), Some(CLAUDE_AGENT));
1526    }
1527
1528    #[test]
1529    fn thread_id_is_absent_on_a_main_thread_call() {
1530        // Claude Code omits the header entirely on the main thread, which is
1531        // what makes its presence a reliable subagent signal.
1532        let headers = headers_from(&[("content-type", "application/json")]);
1533        assert_eq!(thread_id(&headers), None);
1534    }
1535
1536    #[test]
1537    fn a_blank_thread_id_counts_as_absent() {
1538        let headers = headers_from(&[("x-claude-code-agent-id", "")]);
1539        assert_eq!(thread_id(&headers), None);
1540    }
1541
1542    #[test]
1543    fn a_codex_child_turn_resolves_to_its_thread_id() {
1544        let headers = headers_from(&[("session-id", CODEX_ROOT), ("thread-id", CODEX_CHILD)]);
1545        assert_eq!(thread_id(&headers), Some(CODEX_CHILD));
1546    }
1547
1548    /// The root guard, and the reason Codex cannot be expressed as a flat
1549    /// first-present entry: it stamps `thread-id` on *every* call, equal to
1550    /// `session-id` on a root turn. A flat entry would stamp a thread id on
1551    /// every root turn and misroute the root spine.
1552    #[test]
1553    fn a_codex_root_turn_has_no_thread_id() {
1554        let headers = headers_from(&[("session-id", CODEX_ROOT), ("thread-id", CODEX_ROOT)]);
1555        assert_eq!(thread_id(&headers), None);
1556    }
1557
1558    #[test]
1559    fn a_codex_session_id_alone_is_a_main_thread_call() {
1560        let headers = headers_from(&[("session-id", CODEX_ROOT)]);
1561        assert_eq!(thread_id(&headers), None);
1562    }
1563
1564    /// The second guard: a `thread-id` with no `session-id` beside it is not a
1565    /// recognised Codex shape, so the pair rule declines rather than guessing
1566    /// on half the evidence.
1567    #[test]
1568    fn a_lone_thread_id_is_not_a_codex_shape() {
1569        let headers = headers_from(&[("thread-id", CODEX_CHILD)]);
1570        assert_eq!(thread_id(&headers), None);
1571    }
1572
1573    /// Rule order is precedence. Only observable when one request carries
1574    /// evidence for two harnesses at once, which is exactly when a silent
1575    /// reordering would matter.
1576    #[test]
1577    fn the_claude_rule_wins_over_a_codex_shaped_pair() {
1578        let headers = headers_from(&[
1579            ("x-claude-code-agent-id", CLAUDE_AGENT),
1580            ("session-id", CODEX_ROOT),
1581            ("thread-id", CODEX_CHILD),
1582        ]);
1583        assert_eq!(thread_id(&headers), Some(CLAUDE_AGENT));
1584    }
1585
1586    /// A complete inbound envelope reads back with its plain-text fields, and
1587    /// the encoded ones stay empty rather than half-parsed.
1588    #[test]
1589    fn from_headers_reads_a_complete_inbound_envelope() {
1590        let headers = headers_from(&[
1591            (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1592            (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1593            (X_TAPES_HARNESS_VERSION, "1.2.3"),
1594            (X_TAPES_PARENT_HARNESS_SESSION_ID, "sess-0"),
1595            // Wire-encoded fields the producer half deliberately does not
1596            // decode back; see `from_headers`.
1597            (X_TAPES_CWD, "%2Ftmp%2Fwork"),
1598            (X_TAPES_HARNESS_METADATA, "e30"),
1599        ]);
1600
1601        let attribution = TapesAttribution::from_headers(&headers).expect("envelope is complete");
1602        assert_eq!(attribution.harness_id, HARNESS_ID_PI);
1603        assert_eq!(attribution.session_id.as_deref(), Some("sess-1"));
1604        assert_eq!(attribution.version.as_deref(), Some("1.2.3"));
1605        assert_eq!(attribution.parent_sid.as_deref(), Some("sess-0"));
1606        assert_eq!(attribution.cwd, None);
1607        assert_eq!(attribution.name, None);
1608        assert!(attribution.metadata.is_empty());
1609    }
1610
1611    /// Every shape the completeness rule rejects, in one place. Each is a
1612    /// request a consumer must file as unattributed rather than under a
1613    /// half-stated identity.
1614    #[test]
1615    fn from_headers_rejects_incomplete_envelopes() {
1616        let cases: &[(&str, Vec<(&'static str, &str)>)] = &[
1617            ("no headers at all", vec![]),
1618            (
1619                "harness id but no session id",
1620                vec![(X_TAPES_HARNESS_ID, HARNESS_ID_PI)],
1621            ),
1622            (
1623                "session id but no harness id",
1624                vec![(X_TAPES_HARNESS_SESSION_ID, "sess-1")],
1625            ),
1626            (
1627                "the unknown sentinel is not an identity",
1628                vec![
1629                    (X_TAPES_HARNESS_ID, HARNESS_ID_UNKNOWN),
1630                    (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1631                ],
1632            ),
1633            (
1634                "a blank harness id",
1635                vec![
1636                    (X_TAPES_HARNESS_ID, "   "),
1637                    (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1638                ],
1639            ),
1640            (
1641                "a blank session id",
1642                vec![
1643                    (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1644                    (X_TAPES_HARNESS_SESSION_ID, ""),
1645                ],
1646            ),
1647        ];
1648
1649        for (why, pairs) in cases {
1650            let headers = headers_from(pairs);
1651            assert!(
1652                TapesAttribution::from_headers(&headers).is_none(),
1653                "{why}: an incomplete envelope must not read back as an identity",
1654            );
1655        }
1656    }
1657
1658    /// Values are trimmed, so whitespace padding neither defeats the
1659    /// completeness rule nor rides into the attribution.
1660    #[test]
1661    fn from_headers_trims_envelope_values() {
1662        let headers = headers_from(&[
1663            (X_TAPES_HARNESS_ID, "  pi  "),
1664            (X_TAPES_HARNESS_SESSION_ID, "  sess-1  "),
1665        ]);
1666        let attribution = TapesAttribution::from_headers(&headers).expect("padding is not absence");
1667        assert_eq!(attribution.harness_id, HARNESS_ID_PI);
1668        assert_eq!(attribution.session_id.as_deref(), Some("sess-1"));
1669    }
1670
1671    /// The bug this hoist exists to prevent: the rule that decides whether the
1672    /// producer PRESERVES an inbound envelope and the rule that decides
1673    /// whether a consumer FILES a turn under one must be the same rule. If
1674    /// they ever diverge, a request's headers say `pi` while its ingest row
1675    /// says `unknown`.
1676    ///
1677    /// Asserted as agreement across the whole case table rather than by
1678    /// inspecting either implementation, so a future re-spelling of one side
1679    /// fails here.
1680    #[test]
1681    fn envelope_preservation_and_readback_apply_one_rule() {
1682        let cases: &[Vec<(&'static str, &str)>] = &[
1683            vec![],
1684            vec![(X_TAPES_HARNESS_ID, HARNESS_ID_PI)],
1685            vec![(X_TAPES_HARNESS_SESSION_ID, "sess-1")],
1686            vec![
1687                (X_TAPES_HARNESS_ID, HARNESS_ID_UNKNOWN),
1688                (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1689            ],
1690            vec![
1691                (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1692                (X_TAPES_HARNESS_SESSION_ID, ""),
1693            ],
1694            vec![
1695                (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1696                (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1697            ],
1698        ];
1699
1700        for pairs in cases {
1701            let mut headers = headers_from(pairs);
1702            let readable = TapesAttribution::from_headers(&headers).is_some();
1703            assert_eq!(
1704                has_complete_inbound_envelope(&headers),
1705                readable,
1706                "the predicate and the reader disagree about {pairs:?}",
1707            );
1708
1709            // And the producer must act on that same answer: a complete
1710            // envelope survives an unattributed injection untouched, an
1711            // incomplete one is replaced by the `unknown` sentinel.
1712            let before = headers.clone();
1713            inject_unattributed_envelope(&mut headers).unwrap();
1714            if readable {
1715                assert_eq!(
1716                    headers.get(X_TAPES_HARNESS_ID),
1717                    before.get(X_TAPES_HARNESS_ID),
1718                    "a complete envelope was overwritten: {pairs:?}",
1719                );
1720            } else {
1721                assert_eq!(
1722                    headers.get(X_TAPES_HARNESS_ID).unwrap(),
1723                    HARNESS_ID_UNKNOWN,
1724                    "an incomplete envelope was not replaced with the sentinel: {pairs:?}",
1725                );
1726            }
1727        }
1728    }
1729
1730    /// Exactly one rule is declared as a divergent pair, and it is the Codex
1731    /// one. The harness-side rollout-id lookup reads the same two spellings to
1732    /// answer a different question, and asserts agreement from its side —
1733    /// which is the only side that can, now that this module cannot name a
1734    /// harness's attribution lane.
1735    #[test]
1736    fn exactly_one_rule_is_a_divergent_pair() {
1737        let pairs: Vec<(&str, &str)> = HARNESS_THREAD_ID_RULES
1738            .iter()
1739            .filter_map(|rule| match rule {
1740                HarnessThreadRule::DivergentPair { thread, session } => Some((*thread, *session)),
1741                HarnessThreadRule::FirstPresent(_) => None,
1742            })
1743            .collect();
1744        assert_eq!(
1745            pairs,
1746            vec![(CODEX_THREAD_ID_HEADER, CODEX_SESSION_ID_HEADER)],
1747        );
1748    }
1749}