From 8a69fae5273e19e5db3e90dc92cdffe8b1a15b39 Mon Sep 17 00:00:00 2001 From: neutral-reconciler Date: Wed, 29 Jul 2026 23:57:49 +0000 Subject: [PATCH 1/5] Codex lineage reads the durable body client_metadata (#464) The Codex live projector derived a turn's thread, session and parent thread from request headers, three of whose names Codex has never emitted, while the authoritative ids sat unread in the request body. Read against Codex's source, Codex projects one `CodexResponsesMetadata` snapshot onto three surfaces per HTTP request. The flat body `client_metadata` map (a top-level field of `ResponsesApiRequest`) is built unconditionally and always carries `session_id` and `thread_id`; the `x-codex-turn-metadata` blob only rides along for request kinds that carry turn metadata; and `compatibility_headers` emits exactly four names. `thread-id`, `session-id` and `parent-thread-id`, the three bare names the projector read, are not among them, so they could never supply a right value and could supply a wrong one: any hop setting `thread-id` dictated `conversation_id`, the scope of the row's fallback `message_id`. - Lineage now resolves body-map-first, turn-metadata-blob-second, with the real `x-codex-parent-thread-id` header (previously misspelled) last. - A Codex-owned `client_metadata` map is itself a sufficient signal that an exchange is Codex, so the API-key route's generic `/v1/responses` no longer needs a Codex header to be recognized. - The three fictional header names are gone; the real ones are named constants. - `attributes.codex.lineage_source` records which surface stated the identity, so a future Codex version dropping one is queryable rather than a silent `conversation_id` drift. Nothing re-keys: the blob's `thread_id` and the body map's `thread_id` are the same field of the same snapshot, and the removed header names never matched real traffic, so `conversation_id` (and the `message_id` / `part_id` scoped on it, LLP 0030) is unchanged for every shape already recorded. Already-recorded rows are left alone; LLP 0143 states why no backfill. LLP 0143 is the decision doc. LLP 0083 and LLP 0141 carried the disproved premise that `x-codex-turn-metadata` is Codex Desktop behavior and that the subscription route states its session in a `session-id` header; both are corrected, and the two `codex-rollout-cwd` fixtures that rested on that header now use the shape Codex really sends. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 147 ++++++++++--- .../smoke/flows/gateway_codex_capture.js | 12 +- ...83-codex-live-cwd-from-rollout.decision.md | 26 ++- ...esktop-rides-the-codex-adapter.decision.md | 12 +- ...eage-from-body-client-metadata.decision.md | 158 ++++++++++++++ test/plugins/codex-exchange-projector.test.js | 206 ++++++++++++++++++ test/plugins/codex-rollout-cwd.test.js | 38 +++- 7 files changed, 548 insertions(+), 51 deletions(-) create mode 100644 llp/0143-codex-lineage-from-body-client-metadata.decision.md diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 496e77b9..6e5ffcba 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -64,10 +64,12 @@ export function createCodexExchangeProjector(opts = {}) { if (isOpenAiChatPath(path)) return true if (isOpenAiResponsesPath(path)) return true if (isCodexNamespacePath(path)) return true - // Codex Desktop tags requests with a `x-codex-turn-metadata` - // header even when the path looks generic, so accept the header - // as a sufficient match signal. - if (readHeader(input.request_headers, 'x-codex-turn-metadata')) return true + // Any Codex client tags a turn-metadata-carrying request with + // `x-codex-turn-metadata`, even when the path looks generic, so accept the + // header as a sufficient match signal. It is NOT a Desktop-only signal. + // @ref LLP 0143#real-header-names [constrained-by]: every Codex client + // emits it, so the match is client-independent. + if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true return false }, @@ -636,14 +638,33 @@ function firstPlainObject(...values) { // Codex header + workspace metadata // --------------------------------------------------------------------- +// The Codex-owned request headers this file may read. `compatibility_headers` in +// `codex-rs/core/src/responses_metadata.rs` builds exactly four names +// (`x-codex-window-id`, `x-codex-turn-metadata`, `x-codex-parent-thread-id`, +// `x-openai-subagent`), and `readHeader` matches a full name, so any other +// spelling can never match. The other names read in this file (`originator`, +// `user-agent`, `x-client-request-id`, and the response's `x-oai-request-id`) +// are real too, from the shared default client and `codex-api`. +// @ref LLP 0143#real-header-names [constrained-by]: named constants so a +// fictional header name cannot be reintroduced by a typo. +const X_CODEX_TURN_METADATA = 'x-codex-turn-metadata' +const X_CODEX_WINDOW_ID = 'x-codex-window-id' +const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' + /** * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path + * @param {Record} reqBody */ function resolveCodexContext(input, provider, path, reqBody) { - if (!isCodexExchange(input, provider, path)) return undefined - const metadata = readCodexTurnMetadata(input) + if (!isCodexExchange(input, provider, path, reqBody)) return undefined + // @ref LLP 0143#body-is-authority [implements]: the flat body map first, the + // turn-metadata blob second. Both are projections of one Codex snapshot, so + // they agree whenever both are present; the body is preferred because it is + // the only one present for every request kind. + const clientMetadata = readCodexClientMetadata(reqBody) + const metadata = readCodexTurnMetadata(input, clientMetadata) const userAgent = readHeader(input.request_headers, 'user-agent') const client = codexClientFromUserAgent(userAgent) const workspace = selectCodexWorkspace( @@ -655,21 +676,26 @@ function resolveCodexContext(input, provider, path, reqBody) { ? workspaceInfo.associated_remote_urls : undefined const thread_id = firstString( + readStringKey(clientMetadata, 'thread_id'), readStringKey(metadata, 'thread_id'), - readHeader(input.request_headers, 'thread-id'), ) const session_id = firstString( + readStringKey(clientMetadata, 'session_id'), readStringKey(metadata, 'session_id'), - readHeader(input.request_headers, 'session-id'), ) - const turn_id = readStringKey(metadata, 'turn_id') + const turn_id = firstString( + readStringKey(clientMetadata, 'turn_id'), + readStringKey(metadata, 'turn_id'), + ) const thread_source = readStringKey(metadata, 'thread_source') - // Subagent lineage: the parent thread that spawned this one. Codex puts - // it in the same turn-metadata blob as thread_id (set for subagent - // turns; absent on the root thread). + // Subagent lineage: the parent thread that spawned this one. Set for subagent + // turns, absent on a root thread. Codex projects it onto all three surfaces + // under two different spellings: `x-codex-parent-thread-id` in the body map + // and as a header, `parent_thread_id` inside the turn-metadata blob. const parent_thread_id = firstString( + readStringKey(clientMetadata, X_CODEX_PARENT_THREAD_ID), readStringKey(metadata, 'parent_thread_id'), - readHeader(input.request_headers, 'parent-thread-id'), + readHeader(input.request_headers, X_CODEX_PARENT_THREAD_ID), ) const originator = firstString( readHeader(input.request_headers, 'originator'), @@ -677,7 +703,15 @@ function resolveCodexContext(input, provider, path, reqBody) { ) const sandbox = readStringKey(metadata, 'sandbox') const turn_started_at_unix_ms = numberValue(readKey(metadata, 'turn_started_at_unix_ms')) - const window_id = readHeader(input.request_headers, 'x-codex-window-id') + const window_id = firstString( + readHeader(input.request_headers, X_CODEX_WINDOW_ID), + readStringKey(clientMetadata, X_CODEX_WINDOW_ID), + ) + // Which surface stated the identity this row is keyed on. Recorded so a + // future Codex version that stops sending one of them is visible in a query + // instead of showing up as a silent drift in `conversation_id`. + // @ref LLP 0143#lineage-source [implements]: make version drift queryable. + const lineage_source = lineageSource(clientMetadata, thread_id, session_id) // Strip any credential userinfo at ingress, before it reaches the first-class // `git_remote` field or the `attributes.codex.git_origin_url` mirror. // @ref LLP 0032#remote-redaction @@ -697,6 +731,7 @@ function resolveCodexContext(input, provider, path, reqBody) { setIfString(attributes, 'originator', originator) setIfString(attributes, 'window_id', window_id) setIfString(attributes, 'sandbox', sandbox) + setIfString(attributes, 'lineage_source', lineage_source) if (turn_started_at_unix_ms !== undefined) attributes.turn_started_at_unix_ms = turn_started_at_unix_ms setIfString(attributes, 'workspace', workspace?.path) setIfString(attributes, 'git_origin_url', git_origin_url) @@ -730,23 +765,81 @@ function resolveCodexContext(input, provider, path, reqBody) { * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path + * @param {unknown} reqBody */ -function isCodexExchange(input, provider, path) { +function isCodexExchange(input, provider, path, reqBody) { if (provider === 'chatgpt') return true if (isCodexNamespacePath(path)) return true - if (readHeader(input.request_headers, 'x-codex-turn-metadata')) return true - if (readHeader(input.request_headers, 'x-codex-window-id')) return true + if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true + if (readHeader(input.request_headers, X_CODEX_WINDOW_ID)) return true + // @ref LLP 0143#body-is-a-codex-signal [implements]: the API-key route posts a + // generic `/v1/responses` and may carry no Codex-namespaced header at all, so + // the body's Codex-owned `client_metadata` is itself a sufficient signal. + if (readCodexClientMetadata(reqBody)) return true const userAgent = readHeader(input.request_headers, 'user-agent') return codexClientFromUserAgent(userAgent).entrypoint !== undefined } -/** @param {AiGatewayExchangeInput} input */ -function readCodexTurnMetadata(input) { - const raw = readHeader(input.request_headers, 'x-codex-turn-metadata') +/** + * The request body's flat `client_metadata` map: the surface Codex fills for + * every Responses request kind, so the one lineage surface always present. + * + * Declines a map carrying no Codex-owned key, so a `client_metadata` an + * unrelated client happens to send cannot masquerade as Codex lineage. Codex + * writes `x-codex-installation-id`, `session_id`, `thread_id` and + * `x-codex-window-id` on every request, so either accepted signal below is + * enough on its own: an `x-codex-` prefixed key (Codex-exclusive), or the flat + * `session_id` + `thread_id` pair (the values actually read). + * @ref LLP 0143#body-is-authority: the always-present lineage surface. + * + * @param {unknown} reqBody + * @returns {Record | undefined} + */ +function readCodexClientMetadata(reqBody) { + const clientMetadata = readKey(reqBody, 'client_metadata') + if (!isPlainObject(clientMetadata)) return undefined + const hasCodexKey = Object.keys(clientMetadata) + .some((key) => key.toLowerCase().startsWith('x-codex-')) + const hasFlatIdentity = readStringKey(clientMetadata, 'thread_id') !== undefined && + readStringKey(clientMetadata, 'session_id') !== undefined + return hasCodexKey || hasFlatIdentity ? clientMetadata : undefined +} + +/** + * The turn-metadata blob. Codex transports it twice per HTTP request: as the + * `x-codex-turn-metadata` header, and as the same-named string entry of the + * body's `client_metadata` map. The header is read first so already-recorded + * rows keep their exact identity (@ref LLP 0143#row-identity); the body entry + * is the fallback for a hop that dropped the header. Absent entirely for + * request kinds Codex marks as carrying no turn metadata, which is why the flat + * body keys, not this blob, are the lineage authority. + * + * @param {AiGatewayExchangeInput} input + * @param {Record | undefined} clientMetadata + */ +function readCodexTurnMetadata(input, clientMetadata) { + const raw = readHeader(input.request_headers, X_CODEX_TURN_METADATA) + ?? readStringKey(clientMetadata, X_CODEX_TURN_METADATA) const parsed = parseMaybeJson(raw) return isPlainObject(parsed) ? parsed : undefined } +/** + * Name the surface that stated this row's identity, or `undefined` when no + * surface did (the row then keeps the gateway's content-hash fallback). + * + * @param {Record | undefined} clientMetadata + * @param {string | undefined} thread_id + * @param {string | undefined} session_id + * @returns {'body_client_metadata' | 'turn_metadata' | undefined} + */ +function lineageSource(clientMetadata, thread_id, session_id) { + if (!thread_id && !session_id) return undefined + const fromBody = readStringKey(clientMetadata, 'thread_id') + ?? readStringKey(clientMetadata, 'session_id') + return fromBody ? 'body_client_metadata' : 'turn_metadata' +} + /** * @param {string | undefined} userAgent * @returns {{ entrypoint?: string, version?: string }} @@ -789,14 +882,12 @@ function selectCodexWorkspace(metadata, cwd) { * @param {ReturnType} codexContext */ function resolveConversationId(reqBody, input, provider, path, codexContext) { - if (codexContext) { - const codexConversationId = firstString( - codexContext.thread_id, - readHeader(input.request_headers, 'thread-id'), - readHeader(input.request_headers, 'session-id'), - ) - if (codexConversationId) return codexConversationId - } + // @ref LLP 0143#real-header-names [implements]: the thread comes from the + // context's own resolution (body map, then turn-metadata blob) and nowhere + // else. The `thread-id` / `session-id` header names that used to be consulted + // here are names Codex never emits, so they could only ever have let a + // non-Codex hop dictate this row's identity. + if (codexContext?.thread_id) return codexContext.thread_id const sessionId = readMetadataSessionId(reqBody) if (sessionId) return sessionId const messages = Array.isArray(reqBody.messages) diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index f49e0c57..370fe558 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -120,18 +120,26 @@ export async function run({ harness, expect }) { const codexThreadId = `thread-${harness.devRunId}` const codexSessionId = `session-${harness.devRunId}` const codexTurnId = `turn-${harness.devRunId}` + // @ref LLP 0143#body-is-authority: Codex states its lineage in the body's flat + // `client_metadata` map on every request kind, so the fixture carries it there + // and NOT under the `thread-id` / `session-id` header names Codex never emits. const responsesBody = JSON.stringify({ model: 'gpt-5-codex', input: [{ role: 'user', content: [{ type: 'input_text', text: 'help refactor' }] }], stream: true, + client_metadata: { + 'x-codex-installation-id': `install-${harness.devRunId}`, + session_id: codexSessionId, + thread_id: codexThreadId, + turn_id: codexTurnId, + 'x-codex-window-id': `window-${harness.devRunId}`, + }, }) const responsesResp = await postJson( `${gatewayUrl}/backend-api/codex/responses`, harness.devRunId, responsesBody, { - 'thread-id': codexThreadId, - 'session-id': codexSessionId, 'x-client-request-id': `client-request-${harness.devRunId}`, originator: 'Codex Desktop', 'user-agent': 'Codex Desktop/0.133.0-alpha.1', diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index cd5c3143..2b55f049 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Gateway, Sources **Author:** Phil / Claude **Date:** 2026-07-07 -**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050 +**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0143 > The `@hypaware/codex` **live** exchange projector resolves an exchange's `cwd` > from the session's local rollout (`session_meta.cwd`) when the request carries @@ -28,10 +28,15 @@ failing **open**. That was a latent assumption. The **API-key** route (Responses API) happens to carry `cwd` in-band in `metadata`, so no enrichment was ever built. The **ChatGPT-subscription** route (`provider='chatgpt'`, `/backend-api/codex/*`) -has no such field, and `codex-tui` does not send the `x-codex-turn-metadata` -header on it (that is Codex Desktop behavior). So "cwd is always available at -projection time" was really "cwd is available when the client volunteers it" — -and for an entire first-class traffic class, it never did: +has no such field, and a turn whose request kind carries no turn metadata sends +no `x-codex-turn-metadata` and therefore no `workspaces`. (This paragraph +previously said `codex-tui` never sends that header and that it is Codex Desktop +behavior. That is false: Codex's `compatibility_headers` emits it for every turn +regardless of client. See +[LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#context).) +So "cwd is always available at projection time" was really "cwd is available when +the client volunteers it" - and for an entire first-class traffic class, it +often did not: - `.hypignore` was a silent **no-op** for subscription-mode Codex — the same gap class as raw-proxy/OTEL ([LLP 0049 §non-goals](./0049-hypignore-usage-policy.spec.md#non-goals)), @@ -58,10 +63,13 @@ Codex now has the symmetric fallback. - **In-band stays the fast path.** A fresh in-band `cwd` short-circuits before any filesystem work; the rollout is consulted **only** on a miss. -- **Keyed on the codex session id.** The live path already resolves it - (`session-id` header / turn metadata); the rollout filename embeds it, matched - via the `sessionIdFromPath` helper shared with the backfill. Only a real Codex - session has a rollout, so non-codex traffic never scans. +- **Keyed on the codex session id.** The live path already resolves it: the + body's `client_metadata.session_id`, else the turn-metadata blob + ([LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); + it was never a `session-id` header, a name Codex does not emit). The rollout + filename embeds it, matched via the `sessionIdFromPath` helper shared with the + backfill. Only a real Codex session has a rollout, so non-codex traffic never + scans. - **First line only, cached per session id.** The rollout is written at session start, so it exists before the first exchange projects (earlier and more reliably than Claude's sidecar, which has a known session-start race). Reading a diff --git a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index 30884e11..b86ddce0 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Sources, Onboarding **Author:** Kenny / Claude **Date:** 2026-07-28 -**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133 +**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0143 > Names a coverage fact that was already true in code and nowhere in the > product surface. Nothing about capture changes here; the picker copy, the @@ -21,10 +21,12 @@ routes, neither of which was named anywhere a user looks: setting and a `[model_providers.hypaware]` table into `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`). The Codex CLI and Codex Desktop read that same file, so one attach routes both through - the local gateway. The live exchange projector already treats the - `x-codex-turn-metadata` header as a sufficient match signal precisely - because it is Desktop that sends it - ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)). + the local gateway. The live exchange projector treats the + `x-codex-turn-metadata` header as a sufficient match signal because any + Codex client sends it on a turn that carries turn metadata - Desktop + included. (This bullet previously said Desktop is what sends it; that is + false, and nothing in the coverage claim depends on it. See + [LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) 2. **Backfill.** The rollout tree under `$CODEX_HOME/sessions/**` is written by both surfaces too. That half rests on the provider's long-standing assumption (`codex/src/backfill.js`) and on smoke fixtures that synthesize diff --git a/llp/0143-codex-lineage-from-body-client-metadata.decision.md b/llp/0143-codex-lineage-from-body-client-metadata.decision.md new file mode 100644 index 00000000..957df246 --- /dev/null +++ b/llp/0143-codex-lineage-from-body-client-metadata.decision.md @@ -0,0 +1,158 @@ +# LLP 0143: Codex lineage reads the body's `client_metadata`, not header names + +**Type:** Decision +**Status:** Active +**Systems:** Plugins, Sources, Gateway +**Author:** Claude +**Date:** 2026-07-29 +**Related:** LLP 0030, LLP 0049, LLP 0050, LLP 0066, LLP 0083, LLP 0141 + +> The Codex live projector derived a turn's thread, session and parent thread +> from request headers, three of whose names Codex has never emitted, while the +> authoritative ids sat unread in the request body. This names the precedence +> (body map, then turn-metadata blob, then the real compatibility headers), the +> header-name audit, and what happens to rows already recorded. + +## Context + +`resolveCodexContext` in +[`exchange-projector.js`](../hypaware-core/plugins-workspace/codex/src/exchange-projector.js) +resolved identity from the `x-codex-turn-metadata` header plus a set of bare +header names. Read against Codex's own source +(`codex-rs/core/src/responses_metadata.rs`, `codex-rs/core/src/client.rs`, +`codex-rs/codex-api/src/common.rs`), Codex projects one snapshot, +`CodexResponsesMetadata`, onto **three** surfaces per HTTP request: + +| surface | what it carries | when | +| --- | --- | --- | +| body `client_metadata` (flat `string -> string` map, a top-level field of `ResponsesApiRequest`) | `x-codex-installation-id`, `session_id`, `thread_id`, `x-codex-window-id` always; `turn_id`, `x-codex-parent-thread-id`, `parent_turn_id`, `x-openai-subagent` when set; and the whole turn-metadata blob under `x-codex-turn-metadata` | **every** request | +| `x-codex-turn-metadata` (header, and the same-named body entry) | the nested blob: `thread_source`, `sandbox`, `workspaces`, `turn_started_at_unix_ms`, `parent_thread_id`, `forked_from_thread_id` | only when the request kind carries turn metadata; its `session_id`/`thread_id` are omitted for the kinds Codex marks as having no turn identity | +| `compatibility_headers` | exactly `x-codex-window-id`, `x-codex-turn-metadata`, `x-codex-parent-thread-id`, `x-openai-subagent` | as above | + +Two consequences the projector was on the wrong side of: + +1. **The flat body map is the only surface present for every request.** Codex + builds it unconditionally in `client_metadata()`. HypAware never read the + body at all, so identity depended on a surface Codex may legitimately omit. +2. **Three of the header names read were fictional.** `thread-id`, + `session-id` and `parent-thread-id` are not names any Codex version emits; + `compatibility_headers` never produces them and the projector matches a full + header name. They could never supply a right value, and could supply a wrong + one: any hop or hand-rolled client that happened to set `thread-id` dictated + `conversation_id`, the value the row's fallback `message_id` is scoped on + ([LLP 0030](./0030-session-id-partition-key.decision.md)). `parent-thread-id` + was simply the wrong spelling of the real `x-codex-parent-thread-id`, so + header-route subagent lineage never resolved at all. + +The premise that `x-codex-turn-metadata` is a Codex Desktop signal +([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md#context), +[LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md)) is also +false: `compatibility_headers` emits it for every turn regardless of client. +Both docs are corrected alongside this one. + +This is the same defect class as the `.hypignore` cwd gap +([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)) one layer up: +"identity is available at projection time" was really "identity is available +when the client volunteers it on a version-specific surface". + +## Decision + +**The body's flat `client_metadata` map is the +lineage authority; the turn-metadata blob is the fallback.** `thread_id`, +`session_id`, `turn_id` and the parent thread resolve body-map-first, +blob-second. The two are projections of one snapshot, so they cannot disagree in +real traffic; the body wins because it is the surface that is always there. The +map is only trusted when it carries a Codex-owned key (an `x-codex-*` entry, or +both `session_id` and `thread_id`), so a `client_metadata` from an unrelated +client cannot masquerade as Codex lineage. + +The turn-metadata **blob** is still read from the header first and from the body +map second. Both spellings of the blob are byte-equal but for Code Mode tool +names, and header-first is what keeps already-recorded rows bit-identical +([#row-identity](#row-identity)). + +**A Codex-owned `client_metadata` map is +itself sufficient evidence that an exchange is Codex.** The API-key route posts +a generic `/v1/responses` and can carry no Codex-namespaced header, so gating +codex-context resolution on headers would have left the body unread exactly +where it matters most. + +**Only header names Codex actually emits are +read.** The audit of every header this file reads: + +| name read | real? | source | +| --- | --- | --- | +| `x-codex-turn-metadata` | yes | `compatibility_headers` | +| `x-codex-window-id` | yes | `compatibility_headers` | +| `x-codex-parent-thread-id` | yes | `compatibility_headers` (was misspelled `parent-thread-id`) | +| `originator` | yes | `add_originator_header` | +| `user-agent` | yes | the shared default client | +| `x-client-request-id` | yes | `codex-api`'s responses endpoint | +| `x-oai-request-id` (response) | yes | the service | +| ~~`thread-id`~~, ~~`session-id`~~, ~~`parent-thread-id`~~ | **no** | nothing emits them; removed | + +`x-openai-subagent` is real and still **unread**. Adopting it would change what +`is_sidechain` means (its values are Codex's subagent *kinds*: `review`, +`compact`, `memory_consolidation`, `collab_spawn`), which is a separate +decision from where lineage is read. `forked_from_thread_id`, likewise present +in the blob and unread, would need a column. + +**The surface that stated the identity is recorded** +as `attributes.codex.lineage_source` (`body_client_metadata` | +`turn_metadata`, absent when nothing stated one). A future Codex version that +stops filling a surface then shows up as a queryable shift rather than as a +silent drift in `conversation_id`. + +**Already-recorded rows are left alone. No backfill.** +Nothing re-keys, because for every shape HypAware already resolved an identity +from, the new precedence returns the same string: + +- The blob's `thread_id` and the body map's `thread_id` are the same field of + the same snapshot, so a row that resolved a thread from the blob resolves the + identical thread from the body. +- The three removed header names never matched real Codex traffic, so no + recorded Codex row was keyed on them. +- `conversation_id` therefore does not move for existing shapes, and neither do + the `message_id` / `part_id` values scoped on it + ([LLP 0030](./0030-session-id-partition-key.decision.md)). A test pins the + literal ids captured from the pre-change projector. + +What **does** change is coverage: turns that stated identity only in the body +were previously keyed on the gateway's content hash, and are now keyed on the +real thread. Those rows are not rewritten. They were never joinable to a thread +in the first place, so leaving them costs nothing a backfill would recover, and +a backfill would have to re-key rows the partition spec clusters on +(LLP 0030 §Breaking) for no query anyone can express today. If that history is +wanted, `hyp backfill codex` already re-imports the same conversations from the +rollout tree, keyed on the rollout's session id. + +## Consequences + +- `.hypignore` coverage improves for real traffic: the rollout-cwd fallback + ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)) is keyed on the + Codex session id, which on the subscription route previously had to come from + a header Codex never sends. It now comes from the body. +- Session opt-out ([LLP 0066](./0066-session-opt-out.spec.md)) keys on the same + stamped `session_id`, so it gains the same coverage. +- `parent_thread_id` and `is_sidechain` stop depending on the blob alone for the + parent id; `is_sidechain` still derives only from the blob's `thread_source`, + so a turn with no blob records a parent with `is_sidechain` unset. Tightening + that needs the `x-openai-subagent` decision above. +- The Codex-source facts here are a snapshot of an upstream HypAware does not + control. The mitigation is `lineage_source` plus the acceptance check in + [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md), not a pinned literal. + +## References + +- Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` + (`readCodexClientMetadata`, `readCodexTurnMetadata`, `resolveCodexContext`, + `resolveConversationId`, `isCodexExchange`). +- Tests: `test/plugins/codex-exchange-projector.test.js` (lineage surfaces), + `test/plugins/codex-rollout-cwd.test.js` (subscription-route fixtures). +- Fixture: `hypaware-core/smoke/flows/gateway_codex_capture.js`. +- [LLP 0030](./0030-session-id-partition-key.decision.md) - `session_id` is the + partition key; `conversation_id ?? session_id` is the fallback-hash scope. +- [LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md) - the cwd half of + the same "only when the client volunteers it" assumption. +- [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) - one + Codex adapter covers CLI and Desktop; its turn-metadata premise is corrected. diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index f08ea6cd..22117b69 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1109,6 +1109,212 @@ test('conversation_id falls back to a stable hash when no codex metadata or sess assert.equal(projection.conversation_id, repeat.conversation_id) }) +// --------------------------------------------------------------------- +// Lineage surfaces (LLP 0143) +// --------------------------------------------------------------------- + +// @ref LLP 0143#body-is-authority [tests]: the flat body `client_metadata` map +// is the only lineage surface Codex fills for every request kind, so a turn +// that carries no Codex header at all must still resolve its thread, session, +// turn and parent thread. +test('Codex lineage resolves from the durable body client_metadata when no lineage header is sent', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-body', + session_id: 'session-body', + thread_id: 'thread-body', + turn_id: 'turn-body', + 'x-codex-window-id': 'window-body', + 'x-codex-parent-thread-id': 'thread-body-parent', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-body') + assert.equal(projection.session_id, 'session-body') + assert.equal(projection.parent_thread_id, 'thread-body-parent') + assert.equal(projection.prompt_id, 'turn-body') + assert.equal(projection.attributes.codex.thread_id, 'thread-body') + assert.equal(projection.attributes.codex.session_id, 'session-body') + assert.equal(projection.attributes.codex.window_id, 'window-body') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0143#body-is-a-codex-signal [tests]: the API-key route posts to a +// generic `/v1/responses` with no Codex-namespaced header, so the body map is +// also what identifies the exchange as Codex at all. +test('body client_metadata alone identifies a Codex exchange on a generic responses path', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-api', + session_id: 'session-api', + thread_id: 'thread-api', + 'x-codex-window-id': 'window-api', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-api', + thread_id: 'thread-api', + thread_source: 'subagent', + parent_thread_id: 'thread-api-parent', + sandbox: 'workspace-write', + workspaces: { '/work/api': {} }, + }), + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.client_name, 'codex') + assert.equal(projection.conversation_id, 'thread-api') + assert.equal(projection.session_id, 'session-api') + // The turn-metadata blob also rides in the body map, so everything it + // carries (thread_source, sandbox, workspaces) resolves without a header. + assert.equal(projection.user_type, 'subagent') + assert.equal(projection.is_sidechain, true) + assert.equal(projection.parent_thread_id, 'thread-api-parent') + assert.equal(projection.permission_mode, 'workspace-write') + assert.equal(projection.cwd, '/work/api') +}) + +// @ref LLP 0143#real-header-names [tests]: the compatibility headers Codex +// really emits keep working, including `x-codex-parent-thread-id` (the name the +// projector previously got wrong). +test('Codex lineage resolves from the compatibility headers Codex actually sends', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-window-id': 'window-hdr', + 'x-codex-parent-thread-id': 'thread-hdr-parent', + 'x-openai-subagent': 'collab_spawn', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-hdr', + thread_id: 'thread-hdr', + turn_id: 'turn-hdr', + thread_source: 'subagent', + workspaces: { '/work/hdr': {} }, + }), + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-hdr') + assert.equal(projection.session_id, 'session-hdr') + assert.equal(projection.prompt_id, 'turn-hdr') + assert.equal(projection.is_sidechain, true) + assert.equal(projection.parent_thread_id, 'thread-hdr-parent') + assert.equal(projection.attributes.codex.window_id, 'window-hdr') + assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') +}) + +// @ref LLP 0143#real-header-names [tests]: `thread-id`, `session-id` and +// `parent-thread-id` are names Codex never emits. Reading them let an +// unrelated proxy hop or a hand-rolled client dictate `conversation_id`, which +// is the partition-adjacent row identity, so they must resolve to nothing. +test('a bare lineage header name Codex never sends resolves to nothing, not a wrong value', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'thread-id': 'phantom-thread', + 'session-id': 'phantom-session', + 'parent-thread-id': 'phantom-parent', + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.attributes.codex.thread_id, undefined) + assert.equal(projection.attributes.codex.session_id, undefined) + assert.equal(projection.parent_thread_id, undefined) + assert.equal(projection.attributes.codex.lineage_source, undefined) + // No lineage was stated, so the row keeps the content-hash fallback identity + // rather than adopting an id nothing in Codex produced. + assert.equal(projection.conversation_id.length, 16) + assert.notEqual(projection.conversation_id, 'phantom-thread') + assert.equal(projection.session_id, projection.conversation_id) +}) + +// @ref LLP 0143#body-is-authority [tests]: body and blob are two projections of +// one Codex snapshot and cannot disagree in real traffic; pin which one wins so +// the tie-break is a decision rather than an accident of argument order. +test('body client_metadata wins over the turn-metadata blob when the two disagree', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-blob', + thread_id: 'thread-blob', + workspaces: { '/work/blob': {} }, + }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-body', thread_id: 'thread-body' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-body') + assert.equal(projection.session_id, 'session-body') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0143#row-identity [tests]: already-recorded shapes must not re-key. +// These literals were captured from the pre-change projector, so a drift in +// `conversation_id` resolution for a shape HypAware already recorded shows up +// here as a changed `message_id` / `part_id`. +test('part_id and message_id stay byte-identical for the turn-metadata shape already recorded', async () => { + const projector = createCodexExchangeProjector() + const dispatcher = createAiGatewayMessageProjector({ + gatewayId: 'gw-test', + projectors: [{ ...projector, _seq: 0 }], + }) + const rows = /** @type {any[]} */ (await dispatcher.projectExchange(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-window-id': 'window-identity', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-identity', + thread_id: 'thread-identity', + turn_id: 'turn-identity', + thread_source: 'user', + workspaces: { '/w': {} }, + }), + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }))) + + assert.deepEqual( + rows.map((r) => ({ role: r.role, session_id: r.session_id, conversation_id: r.conversation_id, message_id: r.message_id, part_id: r.part_id })), + [ + { role: 'user', session_id: 'session-identity', conversation_id: 'thread-identity', message_id: 'e1a2ff876074693f', part_id: 'e1a2ff876074693f#0' }, + { role: 'assistant', session_id: 'session-identity', conversation_id: 'thread-identity', message_id: '179fd16763044acd', part_id: '179fd16763044acd#0' }, + ] + ) +}) + // --------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------- diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index bf8cb041..fe6a09c9 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -43,6 +43,23 @@ function fakeRolloutCwd(bySession) { // A realistic subscription-route session id: a UUID the rollout filename embeds. const SUBSCRIPTION_SESSION_ID = '019e60b5-1111-4222-8333-444455556666' +const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' + +/** + * The body's flat `client_metadata` map as Codex writes it on a turn that states + * its identity but no workspace: session and thread present, no cwd anywhere. + * That is the shape these tests need, because the rollout fallback only runs + * when the request states a session id and no in-band cwd. + * @ref LLP 0143#body-is-authority [tests]: keyed on the surface Codex really + * fills, not on a `session-id` header Codex never emits. + */ +function subscriptionClientMetadata() { + return { + 'x-codex-installation-id': 'install-sub', + session_id: SUBSCRIPTION_SESSION_ID, + thread_id: SUBSCRIPTION_THREAD_ID, + } +} // --------------------------------------------------------------------- // Regression (#257): the ChatGPT-subscription route carries no in-band cwd, so @@ -59,10 +76,14 @@ test('subscription-route Codex with no in-band cwd is .hypignore-dropped via the const projection = projector.project(exchange({ path: '/backend-api/codex/responses', provider: 'chatgpt', - // codex-tui does NOT send x-codex-turn-metadata on the subscription route; - // it does carry a session-id header, which the adapter already resolves. - request_headers: JSON.stringify({ 'session-id': SUBSCRIPTION_SESSION_ID }), - request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'secret work' }), + // A turn that states its session but no workspace: the flat body + // `client_metadata` map carries the session id, nothing carries a cwd. + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'secret work', + client_metadata: subscriptionClientMetadata(), + }), response_body: JSON.stringify({ output_text: 'ok' }), }), context()) // The rollout cwd (`/work/ignored/proj`) is covered by `/work/ignored/.hypignore`, @@ -78,8 +99,12 @@ test('subscription-route Codex records the rollout cwd on the row (live/backfill const projection = /** @type {any} */ (projector.project(exchange({ path: '/backend-api/codex/responses', provider: 'chatgpt', - request_headers: JSON.stringify({ 'session-id': SUBSCRIPTION_SESSION_ID }), - request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'hello' }), + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'hello', + client_metadata: subscriptionClientMetadata(), + }), response_body: JSON.stringify({ output_text: 'hi' }), }), context())) assert.ok(projection && projection !== USAGE_POLICY_DROP) @@ -102,7 +127,6 @@ test('an in-band cwd stays the fast path and short-circuits the rollout lookup', path: '/backend-api/codex/responses', provider: 'chatgpt', request_headers: JSON.stringify({ - 'session-id': SUBSCRIPTION_SESSION_ID, 'x-codex-turn-metadata': JSON.stringify({ session_id: SUBSCRIPTION_SESSION_ID, workspaces: { '/work/in-band': {} }, From 8a48414566c774a82af061786b630f1970f56d25 Mon Sep 17 00:00:00 2001 From: neutral-reconciler Date: Thu, 30 Jul 2026 00:00:13 +0000 Subject: [PATCH 2/5] Renumber LLP 0143 to 0144 to clear a duplicate number with PR #466 PR #466 (fix/issue-465) independently minted llp/0143-one-reader-for-codex-session-meta on its own branch at the same time this branch minted llp/0143-codex-lineage-from-body-client-metadata. Two documents cannot share a number, and duplicate LLP numbers are exactly the corpus defect issue #463 tracks, so this branch takes 0144 (deterministic tie-break: the lower PR number keeps the original). Purely a renumber: every @ref anchor, cross-link, and Related entry follows the move, and the document's own content is unchanged. Verified no residual "0143" reference remains on this branch and all six referenced anchors still resolve in the renamed document. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 16 ++++++++-------- .../smoke/flows/gateway_codex_capture.js | 2 +- llp/0083-codex-live-cwd-from-rollout.decision.md | 6 +++--- ...x-desktop-rides-the-codex-adapter.decision.md | 4 ++-- ...ineage-from-body-client-metadata.decision.md} | 2 +- test/plugins/codex-exchange-projector.test.js | 14 +++++++------- test/plugins/codex-rollout-cwd.test.js | 2 +- 7 files changed, 23 insertions(+), 23 deletions(-) rename llp/{0143-codex-lineage-from-body-client-metadata.decision.md => 0144-codex-lineage-from-body-client-metadata.decision.md} (99%) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 6e5ffcba..c7b4923c 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -67,7 +67,7 @@ export function createCodexExchangeProjector(opts = {}) { // Any Codex client tags a turn-metadata-carrying request with // `x-codex-turn-metadata`, even when the path looks generic, so accept the // header as a sufficient match signal. It is NOT a Desktop-only signal. - // @ref LLP 0143#real-header-names [constrained-by]: every Codex client + // @ref LLP 0144#real-header-names [constrained-by]: every Codex client // emits it, so the match is client-independent. if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true return false @@ -645,7 +645,7 @@ function firstPlainObject(...values) { // spelling can never match. The other names read in this file (`originator`, // `user-agent`, `x-client-request-id`, and the response's `x-oai-request-id`) // are real too, from the shared default client and `codex-api`. -// @ref LLP 0143#real-header-names [constrained-by]: named constants so a +// @ref LLP 0144#real-header-names [constrained-by]: named constants so a // fictional header name cannot be reintroduced by a typo. const X_CODEX_TURN_METADATA = 'x-codex-turn-metadata' const X_CODEX_WINDOW_ID = 'x-codex-window-id' @@ -659,7 +659,7 @@ const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' */ function resolveCodexContext(input, provider, path, reqBody) { if (!isCodexExchange(input, provider, path, reqBody)) return undefined - // @ref LLP 0143#body-is-authority [implements]: the flat body map first, the + // @ref LLP 0144#body-is-authority [implements]: the flat body map first, the // turn-metadata blob second. Both are projections of one Codex snapshot, so // they agree whenever both are present; the body is preferred because it is // the only one present for every request kind. @@ -710,7 +710,7 @@ function resolveCodexContext(input, provider, path, reqBody) { // Which surface stated the identity this row is keyed on. Recorded so a // future Codex version that stops sending one of them is visible in a query // instead of showing up as a silent drift in `conversation_id`. - // @ref LLP 0143#lineage-source [implements]: make version drift queryable. + // @ref LLP 0144#lineage-source [implements]: make version drift queryable. const lineage_source = lineageSource(clientMetadata, thread_id, session_id) // Strip any credential userinfo at ingress, before it reaches the first-class // `git_remote` field or the `attributes.codex.git_origin_url` mirror. @@ -772,7 +772,7 @@ function isCodexExchange(input, provider, path, reqBody) { if (isCodexNamespacePath(path)) return true if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true if (readHeader(input.request_headers, X_CODEX_WINDOW_ID)) return true - // @ref LLP 0143#body-is-a-codex-signal [implements]: the API-key route posts a + // @ref LLP 0144#body-is-a-codex-signal [implements]: the API-key route posts a // generic `/v1/responses` and may carry no Codex-namespaced header at all, so // the body's Codex-owned `client_metadata` is itself a sufficient signal. if (readCodexClientMetadata(reqBody)) return true @@ -790,7 +790,7 @@ function isCodexExchange(input, provider, path, reqBody) { * `x-codex-window-id` on every request, so either accepted signal below is * enough on its own: an `x-codex-` prefixed key (Codex-exclusive), or the flat * `session_id` + `thread_id` pair (the values actually read). - * @ref LLP 0143#body-is-authority: the always-present lineage surface. + * @ref LLP 0144#body-is-authority: the always-present lineage surface. * * @param {unknown} reqBody * @returns {Record | undefined} @@ -809,7 +809,7 @@ function readCodexClientMetadata(reqBody) { * The turn-metadata blob. Codex transports it twice per HTTP request: as the * `x-codex-turn-metadata` header, and as the same-named string entry of the * body's `client_metadata` map. The header is read first so already-recorded - * rows keep their exact identity (@ref LLP 0143#row-identity); the body entry + * rows keep their exact identity (@ref LLP 0144#row-identity); the body entry * is the fallback for a hop that dropped the header. Absent entirely for * request kinds Codex marks as carrying no turn metadata, which is why the flat * body keys, not this blob, are the lineage authority. @@ -882,7 +882,7 @@ function selectCodexWorkspace(metadata, cwd) { * @param {ReturnType} codexContext */ function resolveConversationId(reqBody, input, provider, path, codexContext) { - // @ref LLP 0143#real-header-names [implements]: the thread comes from the + // @ref LLP 0144#real-header-names [implements]: the thread comes from the // context's own resolution (body map, then turn-metadata blob) and nowhere // else. The `thread-id` / `session-id` header names that used to be consulted // here are names Codex never emits, so they could only ever have let a diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index 370fe558..254b2be9 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -120,7 +120,7 @@ export async function run({ harness, expect }) { const codexThreadId = `thread-${harness.devRunId}` const codexSessionId = `session-${harness.devRunId}` const codexTurnId = `turn-${harness.devRunId}` - // @ref LLP 0143#body-is-authority: Codex states its lineage in the body's flat + // @ref LLP 0144#body-is-authority: Codex states its lineage in the body's flat // `client_metadata` map on every request kind, so the fixture carries it there // and NOT under the `thread-id` / `session-id` header names Codex never emits. const responsesBody = JSON.stringify({ diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index 2b55f049..392e2d78 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Gateway, Sources **Author:** Phil / Claude **Date:** 2026-07-07 -**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0143 +**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0144 > The `@hypaware/codex` **live** exchange projector resolves an exchange's `cwd` > from the session's local rollout (`session_meta.cwd`) when the request carries @@ -33,7 +33,7 @@ no `x-codex-turn-metadata` and therefore no `workspaces`. (This paragraph previously said `codex-tui` never sends that header and that it is Codex Desktop behavior. That is false: Codex's `compatibility_headers` emits it for every turn regardless of client. See -[LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#context).) +[LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#context).) So "cwd is always available at projection time" was really "cwd is available when the client volunteers it" - and for an entire first-class traffic class, it often did not: @@ -65,7 +65,7 @@ Codex now has the symmetric fallback. filesystem work; the rollout is consulted **only** on a miss. - **Keyed on the codex session id.** The live path already resolves it: the body's `client_metadata.session_id`, else the turn-metadata blob - ([LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); + ([LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); it was never a `session-id` header, a name Codex does not emit). The rollout filename embeds it, matched via the `sessionIdFromPath` helper shared with the backfill. Only a real Codex session has a rollout, so non-codex traffic never diff --git a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index b86ddce0..a795b45e 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Sources, Onboarding **Author:** Kenny / Claude **Date:** 2026-07-28 -**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0143 +**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0144 > Names a coverage fact that was already true in code and nowhere in the > product surface. Nothing about capture changes here; the picker copy, the @@ -26,7 +26,7 @@ routes, neither of which was named anywhere a user looks: Codex client sends it on a turn that carries turn metadata - Desktop included. (This bullet previously said Desktop is what sends it; that is false, and nothing in the coverage claim depends on it. See - [LLP 0143](./0143-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) + [LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) 2. **Backfill.** The rollout tree under `$CODEX_HOME/sessions/**` is written by both surfaces too. That half rests on the provider's long-standing assumption (`codex/src/backfill.js`) and on smoke fixtures that synthesize diff --git a/llp/0143-codex-lineage-from-body-client-metadata.decision.md b/llp/0144-codex-lineage-from-body-client-metadata.decision.md similarity index 99% rename from llp/0143-codex-lineage-from-body-client-metadata.decision.md rename to llp/0144-codex-lineage-from-body-client-metadata.decision.md index 957df246..8c1a8281 100644 --- a/llp/0143-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0144-codex-lineage-from-body-client-metadata.decision.md @@ -1,4 +1,4 @@ -# LLP 0143: Codex lineage reads the body's `client_metadata`, not header names +# LLP 0144: Codex lineage reads the body's `client_metadata`, not header names **Type:** Decision **Status:** Active diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 22117b69..4782ebf6 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1110,10 +1110,10 @@ test('conversation_id falls back to a stable hash when no codex metadata or sess }) // --------------------------------------------------------------------- -// Lineage surfaces (LLP 0143) +// Lineage surfaces (LLP 0144) // --------------------------------------------------------------------- -// @ref LLP 0143#body-is-authority [tests]: the flat body `client_metadata` map +// @ref LLP 0144#body-is-authority [tests]: the flat body `client_metadata` map // is the only lineage surface Codex fills for every request kind, so a turn // that carries no Codex header at all must still resolve its thread, session, // turn and parent thread. @@ -1148,7 +1148,7 @@ test('Codex lineage resolves from the durable body client_metadata when no linea assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) -// @ref LLP 0143#body-is-a-codex-signal [tests]: the API-key route posts to a +// @ref LLP 0144#body-is-a-codex-signal [tests]: the API-key route posts to a // generic `/v1/responses` with no Codex-namespaced header, so the body map is // also what identifies the exchange as Codex at all. test('body client_metadata alone identifies a Codex exchange on a generic responses path', () => { @@ -1189,7 +1189,7 @@ test('body client_metadata alone identifies a Codex exchange on a generic respon assert.equal(projection.cwd, '/work/api') }) -// @ref LLP 0143#real-header-names [tests]: the compatibility headers Codex +// @ref LLP 0144#real-header-names [tests]: the compatibility headers Codex // really emits keep working, including `x-codex-parent-thread-id` (the name the // projector previously got wrong). test('Codex lineage resolves from the compatibility headers Codex actually sends', () => { @@ -1222,7 +1222,7 @@ test('Codex lineage resolves from the compatibility headers Codex actually sends assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') }) -// @ref LLP 0143#real-header-names [tests]: `thread-id`, `session-id` and +// @ref LLP 0144#real-header-names [tests]: `thread-id`, `session-id` and // `parent-thread-id` are names Codex never emits. Reading them let an // unrelated proxy hop or a hand-rolled client dictate `conversation_id`, which // is the partition-adjacent row identity, so they must resolve to nothing. @@ -1251,7 +1251,7 @@ test('a bare lineage header name Codex never sends resolves to nothing, not a wr assert.equal(projection.session_id, projection.conversation_id) }) -// @ref LLP 0143#body-is-authority [tests]: body and blob are two projections of +// @ref LLP 0144#body-is-authority [tests]: body and blob are two projections of // one Codex snapshot and cannot disagree in real traffic; pin which one wins so // the tie-break is a decision rather than an accident of argument order. test('body client_metadata wins over the turn-metadata blob when the two disagree', () => { @@ -1279,7 +1279,7 @@ test('body client_metadata wins over the turn-metadata blob when the two disagre assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) -// @ref LLP 0143#row-identity [tests]: already-recorded shapes must not re-key. +// @ref LLP 0144#row-identity [tests]: already-recorded shapes must not re-key. // These literals were captured from the pre-change projector, so a drift in // `conversation_id` resolution for a shape HypAware already recorded shows up // here as a changed `message_id` / `part_id`. diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index fe6a09c9..4982e9b8 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -50,7 +50,7 @@ const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' * its identity but no workspace: session and thread present, no cwd anywhere. * That is the shape these tests need, because the rollout fallback only runs * when the request states a session id and no in-band cwd. - * @ref LLP 0143#body-is-authority [tests]: keyed on the surface Codex really + * @ref LLP 0144#body-is-authority [tests]: keyed on the surface Codex really * fills, not on a `session-id` header Codex never emits. */ function subscriptionClientMetadata() { From 781a0ec5fb3f302e24217f1d7940084ace5f3fa6 Mon Sep 17 00:00:00 2001 From: neutral-reconciler Date: Thu, 30 Jul 2026 00:36:25 +0000 Subject: [PATCH 3/5] Codex lineage: record a body/blob disagreement instead of silently preferring one The body-map-first precedence rests on Codex projecting one metadata snapshot onto both the flat `client_metadata` map and the `x-codex-turn-metadata` blob, so that the two are equal whenever both are present. That is a claim about another program's internals which HypAware cannot verify, and the body-wins tie-break discarded the counter-evidence without trace: a row whose surfaces disagreed was indistinguishable from a row whose surfaces agreed. - `attributes.codex.lineage_conflict` now names the lineage fields the two surfaces state differently (`thread_id`, `session_id`, `turn_id`, `parent_thread_id`), absent when they agree or only one spoke. The row still keys on the body, so this adds a signal and moves no identity. - `lineage_source` now resolves in the same order as the values it describes (`thread_id` before `session_id`, body before blob). It previously answered "did the body state anything at all", which mislabelled a turn whose `thread_id` came from the blob while only its `session_id` came from the body as `body_client_metadata`, though `conversation_id` keys on `thread_id`. - LLP 0144 gains `#lineage-conflict` and states why the assumption gets a continuously checked signal rather than a one-time assertion. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 67 ++++++++++++-- ...eage-from-body-client-metadata.decision.md | 30 ++++++- test/plugins/codex-exchange-projector.test.js | 87 +++++++++++++++++++ 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index c7b4923c..9acfaa33 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -711,7 +711,11 @@ function resolveCodexContext(input, provider, path, reqBody) { // future Codex version that stops sending one of them is visible in a query // instead of showing up as a silent drift in `conversation_id`. // @ref LLP 0144#lineage-source [implements]: make version drift queryable. - const lineage_source = lineageSource(clientMetadata, thread_id, session_id) + const lineage_source = lineageSource(clientMetadata, metadata) + // The precedence above trusts the two surfaces to agree. Nothing here can + // verify that, so when they do not, say so on the row. + // @ref LLP 0144#lineage-conflict [implements]: the tie-break leaves evidence. + const lineage_conflict = lineageConflict(clientMetadata, metadata) // Strip any credential userinfo at ingress, before it reaches the first-class // `git_remote` field or the `attributes.codex.git_origin_url` mirror. // @ref LLP 0032#remote-redaction @@ -732,6 +736,7 @@ function resolveCodexContext(input, provider, path, reqBody) { setIfString(attributes, 'window_id', window_id) setIfString(attributes, 'sandbox', sandbox) setIfString(attributes, 'lineage_source', lineage_source) + setIfString(attributes, 'lineage_conflict', lineage_conflict) if (turn_started_at_unix_ms !== undefined) attributes.turn_started_at_unix_ms = turn_started_at_unix_ms setIfString(attributes, 'workspace', workspace?.path) setIfString(attributes, 'git_origin_url', git_origin_url) @@ -824,20 +829,66 @@ function readCodexTurnMetadata(input, clientMetadata) { return isPlainObject(parsed) ? parsed : undefined } +// The lineage fields that both surfaces carry, as each surface spells them: the +// flat body-map key first, the turn-metadata blob key second. +const LINEAGE_SPELLINGS = [ + ['thread_id', 'thread_id'], + ['session_id', 'session_id'], + ['turn_id', 'turn_id'], + [X_CODEX_PARENT_THREAD_ID, 'parent_thread_id'], +] + /** * Name the surface that stated this row's identity, or `undefined` when no * surface did (the row then keeps the gateway's content-hash fallback). * + * The checks walk `thread_id` before `session_id` and body before blob, which is + * the same order the values above resolve in, so the recorded name is the + * surface the identity actually came from. Answering from "did the body state + * anything at all" would mislabel a turn whose `thread_id` (what + * `conversation_id` keys on) came from the blob while only its `session_id` came + * from the body. + * * @param {Record | undefined} clientMetadata - * @param {string | undefined} thread_id - * @param {string | undefined} session_id + * @param {Record | undefined} metadata * @returns {'body_client_metadata' | 'turn_metadata' | undefined} */ -function lineageSource(clientMetadata, thread_id, session_id) { - if (!thread_id && !session_id) return undefined - const fromBody = readStringKey(clientMetadata, 'thread_id') - ?? readStringKey(clientMetadata, 'session_id') - return fromBody ? 'body_client_metadata' : 'turn_metadata' +function lineageSource(clientMetadata, metadata) { + for (const key of ['thread_id', 'session_id']) { + if (readStringKey(clientMetadata, key) !== undefined) return 'body_client_metadata' + if (readStringKey(metadata, key) !== undefined) return 'turn_metadata' + } + return undefined +} + +/** + * Name every lineage field the two surfaces state differently, comma-joined in + * turn-metadata spelling, or `undefined` when they agree or only one spoke. + * + * The precedence rests on Codex projecting one metadata snapshot onto both + * surfaces, so that a body value and a blob value for the same field are always + * equal. That is a claim about another program's internals which this code + * cannot check, and the body-wins tie-break would otherwise discard the + * counter-evidence without trace. Recording it makes a Codex version that began + * filling the two surfaces from different state a queryable fact rather than a + * silent preference. The row still keys on the body, so this adds a signal and + * changes no identity. + * @ref LLP 0144#lineage-conflict [implements]: an unverifiable agreement + * assumption gets a recorded signal. + * + * @param {Record | undefined} clientMetadata + * @param {Record | undefined} metadata + * @returns {string | undefined} + */ +function lineageConflict(clientMetadata, metadata) { + const disagreed = LINEAGE_SPELLINGS + .filter(([bodyKey, blobKey]) => { + const fromBody = readStringKey(clientMetadata, bodyKey) + const fromBlob = readStringKey(metadata, blobKey) + return fromBody !== undefined && fromBlob !== undefined && fromBody !== fromBlob + }) + .map(([, blobKey]) => blobKey) + return disagreed.length > 0 ? disagreed.join(',') : undefined } /** diff --git a/llp/0144-codex-lineage-from-body-client-metadata.decision.md b/llp/0144-codex-lineage-from-body-client-metadata.decision.md index 8c1a8281..e32582e3 100644 --- a/llp/0144-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0144-codex-lineage-from-body-client-metadata.decision.md @@ -101,7 +101,29 @@ in the blob and unread, would need a column. as `attributes.codex.lineage_source` (`body_client_metadata` | `turn_metadata`, absent when nothing stated one). A future Codex version that stops filling a surface then shows up as a queryable shift rather than as a -silent drift in `conversation_id`. +silent drift in `conversation_id`. It names the surface the identity actually +came from, resolved in the same order as the values themselves (`thread_id` +before `session_id`, body before blob), so a turn whose `thread_id` came from +the blob is not labelled `body_client_metadata` merely because its `session_id` +came from the body. + +**A disagreement between the two surfaces is +recorded, not just resolved.** [#body-is-authority](#body-is-authority) rests on +Codex projecting one snapshot onto both surfaces, so equal values whenever both +are present. HypAware cannot verify a claim about another program's internals, +and the body-wins tie-break would otherwise discard the counter-evidence without +trace: the row would look exactly like a row whose surfaces agreed. So when a +lineage field (`thread_id`, `session_id`, `turn_id`, `parent_thread_id`) is +stated differently by the body map and the turn-metadata blob, the disagreeing +field names are recorded as `attributes.codex.lineage_conflict`. + +The row still keys on the body, so this is a signal and not a second +precedence: nothing about row identity depends on it, and the attribute is +absent for every agreeing turn, which is all of them in traffic HypAware has +seen. Its value is diagnostic. A nonzero count for +`attributes.codex.lineage_conflict` is the evidence that would retire the +agreement assumption, and until one appears the assumption is being checked +continuously rather than asserted once in this document. **Already-recorded rows are left alone. No backfill.** Nothing re-keys, because for every shape HypAware already resolved an identity @@ -139,14 +161,16 @@ rollout tree, keyed on the rollout's session id. so a turn with no blob records a parent with `is_sidechain` unset. Tightening that needs the `x-openai-subagent` decision above. - The Codex-source facts here are a snapshot of an upstream HypAware does not - control. The mitigation is `lineage_source` plus the acceptance check in + control. The mitigation is `lineage_source` and `lineage_conflict` + ([#lineage-conflict](#lineage-conflict)) plus the acceptance check in [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md), not a pinned literal. ## References - Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` (`readCodexClientMetadata`, `readCodexTurnMetadata`, `resolveCodexContext`, - `resolveConversationId`, `isCodexExchange`). + `resolveConversationId`, `isCodexExchange`, `lineageSource`, + `lineageConflict`). - Tests: `test/plugins/codex-exchange-projector.test.js` (lineage surfaces), `test/plugins/codex-rollout-cwd.test.js` (subscription-route fixtures). - Fixture: `hypaware-core/smoke/flows/gateway_codex_capture.js`. diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 4782ebf6..2ec3fba0 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1277,6 +1277,93 @@ test('body client_metadata wins over the turn-metadata blob when the two disagre assert.equal(projection.conversation_id, 'thread-body') assert.equal(projection.session_id, 'session-body') assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') + // @ref LLP 0144#lineage-conflict [tests]: the tie-break leaves evidence, so + // the disagreement the body silently won is on the row and countable. + assert.equal(projection.attributes.codex.lineage_conflict, 'thread_id,session_id') +}) + +// @ref LLP 0144#lineage-conflict [tests]: the signal must be absent, not merely +// falsy, for the agreeing traffic that is every turn Codex is known to send, or +// a nonzero conflict count stops being evidence of anything. +test('agreeing lineage surfaces record no lineage_conflict', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-agree', + thread_id: 'thread-agree', + turn_id: 'turn-agree', + parent_thread_id: 'parent-agree', + }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + session_id: 'session-agree', + thread_id: 'thread-agree', + turn_id: 'turn-agree', + 'x-codex-parent-thread-id': 'parent-agree', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-agree') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) +}) + +// @ref LLP 0144#lineage-conflict [tests]: only a real disagreement counts. A +// field one surface omits is the normal per-request-kind shape, not a conflict. +test('a lineage field only one surface states is not a conflict', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ thread_source: 'user' }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-solo', thread_id: 'thread-solo' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-solo') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) +}) + +// @ref LLP 0144#lineage-source [tests]: the recorded name is the surface the +// identity came from. Here the body states only `session_id`, so `thread_id`, +// which is what `conversation_id` keys on, comes from the blob. +test('lineage_source names the surface the thread actually came from', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ thread_id: 'thread-from-blob' }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-mixed', + session_id: 'session-from-body', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-from-blob') + assert.equal(projection.attributes.codex.session_id, 'session-from-body') + assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) }) // @ref LLP 0144#row-identity [tests]: already-recorded shapes must not re-key. From fed35dad15cc8923ad826a9c6b1b06070a14da6f Mon Sep 17 00:00:00 2001 From: neutral-reconciler Date: Thu, 30 Jul 2026 01:08:43 +0000 Subject: [PATCH 4/5] Codex lineage: an ambiguous flat client_metadata pair is not evidence of Codex `readCodexClientMetadata` accepted a body `client_metadata` map on either of two signals: an `x-codex-*` prefixed key, or the flat `session_id` + `thread_id` pair. Only the first is Codex-exclusive. The second is a shape any agent framework may send, and the projector's matched path set includes the fully generic `/v1/responses` and `/v1/chat/completions`, so an unrelated client that posted that pair was stamped `client_name: 'codex'` and dictated the row's `conversation_id` and `session_id` (the partition key, LLP 0030). That is the same defect class as the fictional `thread-id` header this branch removed, reached through the body instead of a header, and in a capture product a misfiled client is a privacy question. The flat pair is now honoured only when the transport already identified the exchange as Codex independently of the body (`hasCodexTransportSignal`: the `chatgpt` upstream, the `/backend-api/codex/` namespace, an `x-codex-*` compatibility header, or a `codex`-prefixed user-agent product). Real Codex loses nothing: `client_metadata` carries `x-codex-installation-id` and `x-codex-window-id` on every request, so the strict branch alone covers all known Codex traffic, and the corroborated pair still covers a build that stopped writing them. A non-Codex client's row now comes out byte-identical to the same request with no `client_metadata` at all. `isCodexExchange` is replaced by `hasCodexTransportSignal` plus the body check at the single decision point in `resolveCodexContext`, so the corroboration flag cannot drift between the two callers. Also pins the assumption that keeps the outer `match` gate (path and turn-metadata header only) consistent with the body being a Codex signal: a test asserts every route Codex posts to passes the gate, so a body-only Codex request is never dropped before the body is read. LLP 0144#body-is-a-codex-signal and #body-is-authority are amended in the same commit. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 64 ++++++++--- ...eage-from-body-client-metadata.decision.md | 41 ++++++- test/plugins/codex-exchange-projector.test.js | 106 ++++++++++++++++++ 3 files changed, 192 insertions(+), 19 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 9acfaa33..6d287718 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -70,6 +70,14 @@ export function createCodexExchangeProjector(opts = {}) { // @ref LLP 0144#real-header-names [constrained-by]: every Codex client // emits it, so the match is client-independent. if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true + // NOTE this gate deliberately does not consult the body, while + // `resolveCodexContext` treats a Codex-owned `client_metadata` as a + // sufficient Codex signal. The two only stay consistent because the path + // set above covers every route Codex posts to, so a body-only Codex + // request is always matched here first. A test pins that: see + // `test/plugins/codex-exchange-projector.test.js` ("every route Codex + // posts to is matched..."). Widen the path set, do not start reading the + // body here, if Codex adds a route. return false }, @@ -658,12 +666,18 @@ const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' * @param {Record} reqBody */ function resolveCodexContext(input, provider, path, reqBody) { - if (!isCodexExchange(input, provider, path, reqBody)) return undefined + // @ref LLP 0144#body-is-a-codex-signal [implements]: a Codex-owned body map + // identifies the exchange on its own, so the API-key route's generic + // `/v1/responses` resolves with no Codex header at all. The transport signal is + // resolved first because it is also what corroborates the body's ambiguous + // flat identity pair (see `readCodexClientMetadata`). + const transportIsCodex = hasCodexTransportSignal(input, provider, path) // @ref LLP 0144#body-is-authority [implements]: the flat body map first, the // turn-metadata blob second. Both are projections of one Codex snapshot, so // they agree whenever both are present; the body is preferred because it is // the only one present for every request kind. - const clientMetadata = readCodexClientMetadata(reqBody) + const clientMetadata = readCodexClientMetadata(reqBody, transportIsCodex) + if (!transportIsCodex && clientMetadata === undefined) return undefined const metadata = readCodexTurnMetadata(input, clientMetadata) const userAgent = readHeader(input.request_headers, 'user-agent') const client = codexClientFromUserAgent(userAgent) @@ -767,20 +781,24 @@ function resolveCodexContext(input, provider, path, reqBody) { } /** + * Whether the transport alone identifies this exchange as Codex, before any part + * of the request body is consulted: the ChatGPT upstream, the Codex route + * namespace, a Codex-namespaced compatibility header, or a Codex user-agent + * product. Every one of these is a name only a Codex client produces. + * + * Kept separate from the body signal because it is also what corroborates a + * `client_metadata` map carrying no Codex-owned key of its own. + * @ref LLP 0144#body-is-a-codex-signal [implements] + * * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path - * @param {unknown} reqBody */ -function isCodexExchange(input, provider, path, reqBody) { +function hasCodexTransportSignal(input, provider, path) { if (provider === 'chatgpt') return true if (isCodexNamespacePath(path)) return true if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true if (readHeader(input.request_headers, X_CODEX_WINDOW_ID)) return true - // @ref LLP 0144#body-is-a-codex-signal [implements]: the API-key route posts a - // generic `/v1/responses` and may carry no Codex-namespaced header at all, so - // the body's Codex-owned `client_metadata` is itself a sufficient signal. - if (readCodexClientMetadata(reqBody)) return true const userAgent = readHeader(input.request_headers, 'user-agent') return codexClientFromUserAgent(userAgent).entrypoint !== undefined } @@ -790,24 +808,40 @@ function isCodexExchange(input, provider, path, reqBody) { * every Responses request kind, so the one lineage surface always present. * * Declines a map carrying no Codex-owned key, so a `client_metadata` an - * unrelated client happens to send cannot masquerade as Codex lineage. Codex - * writes `x-codex-installation-id`, `session_id`, `thread_id` and - * `x-codex-window-id` on every request, so either accepted signal below is - * enough on its own: an `x-codex-` prefixed key (Codex-exclusive), or the flat - * `session_id` + `thread_id` pair (the values actually read). + * unrelated client happens to send cannot masquerade as Codex lineage. An + * `x-codex-` prefixed key is Codex-exclusive and is accepted on its own; Codex + * writes `x-codex-installation-id` and `x-codex-window-id` into this map on + * every request, so that branch alone covers every request real Codex makes. + * + * The flat `session_id` + `thread_id` pair is NOT Codex-exclusive: those are + * ordinary names any agent framework may put in a `client_metadata` map, and the + * matched path set includes the generic `/v1/responses` and + * `/v1/chat/completions`. Honouring the pair on its own would therefore let an + * unrelated client be stamped `client_name: 'codex'` and dictate this row's + * `conversation_id` and `session_id`, which is the same defect class as the + * fictional `thread-id` header this document removed, only through the body. So + * the pair is trusted only once `corroborated` says the transport already + * identified the exchange as Codex, where it adds lineage detail to a client + * that is already known rather than naming the client. * @ref LLP 0144#body-is-authority: the always-present lineage surface. + * @ref LLP 0144#body-is-a-codex-signal [constrained-by]: which keys of the map + * are evidence of Codex, and which only carry detail. * * @param {unknown} reqBody + * @param {boolean} corroborated Whether the transport (upstream, route, Codex + * header, or Codex user-agent) already identified this exchange as Codex. * @returns {Record | undefined} */ -function readCodexClientMetadata(reqBody) { +function readCodexClientMetadata(reqBody, corroborated) { const clientMetadata = readKey(reqBody, 'client_metadata') if (!isPlainObject(clientMetadata)) return undefined const hasCodexKey = Object.keys(clientMetadata) .some((key) => key.toLowerCase().startsWith('x-codex-')) + if (hasCodexKey) return clientMetadata + if (!corroborated) return undefined const hasFlatIdentity = readStringKey(clientMetadata, 'thread_id') !== undefined && readStringKey(clientMetadata, 'session_id') !== undefined - return hasCodexKey || hasFlatIdentity ? clientMetadata : undefined + return hasFlatIdentity ? clientMetadata : undefined } /** diff --git a/llp/0144-codex-lineage-from-body-client-metadata.decision.md b/llp/0144-codex-lineage-from-body-client-metadata.decision.md index e32582e3..c8477024 100644 --- a/llp/0144-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0144-codex-lineage-from-body-client-metadata.decision.md @@ -62,9 +62,10 @@ lineage authority; the turn-metadata blob is the fallback.** `thread_id`, `session_id`, `turn_id` and the parent thread resolve body-map-first, blob-second. The two are projections of one snapshot, so they cannot disagree in real traffic; the body wins because it is the surface that is always there. The -map is only trusted when it carries a Codex-owned key (an `x-codex-*` entry, or -both `session_id` and `thread_id`), so a `client_metadata` from an unrelated -client cannot masquerade as Codex lineage. +map is only trusted when it carries a Codex-owned `x-codex-*` entry, or when the +transport has already identified the client as Codex, so a `client_metadata` from +an unrelated client cannot masquerade as Codex lineage +([#body-is-a-codex-signal](#body-is-a-codex-signal)). The turn-metadata **blob** is still read from the header first and from the body map second. Both spellings of the blob are byte-equal but for Code Mode tool @@ -77,6 +78,38 @@ a generic `/v1/responses` and can carry no Codex-namespaced header, so gating codex-context resolution on headers would have left the body unread exactly where it matters most. +**The Codex-owned key is what makes the map evidence. The flat identity pair is +not.** Only an `x-codex-*` prefixed entry names the client. `session_id` and +`thread_id` are ordinary names any agent framework may write into a +`client_metadata` map, and the projector's matched path set includes the fully +generic `/v1/responses` and `/v1/chat/completions`, which every +OpenAI-compatible client posts to. Accepting the bare pair as evidence would +therefore stamp an unrelated client `client_name: 'codex'` and let it dictate +this row's `conversation_id` and `session_id`, the latter being the partition key +([LLP 0030](./0030-session-id-partition-key.decision.md)). That is the same +defect class as the fictional `thread-id` header +([#real-header-names](#real-header-names)), reached through the body instead of a +header, and in a capture product a misfiled client is a privacy question and not +a cosmetic one. + +So the pair is honoured only once the transport has already identified the +exchange as Codex independently of the body: the `chatgpt` upstream, the +`/backend-api/codex/` namespace, an `x-codex-*` compatibility header, or a +`codex`-prefixed user-agent product (`hasCodexTransportSignal`). Real Codex +loses nothing, because `client_metadata` carries `x-codex-installation-id` and +`x-codex-window-id` on every request (the table in [#context](#context)), so the +strict branch alone already covers every request Codex is known to make. The +corroborated pair is the fallback for a build that stopped writing them, which is +exactly the version drift this document is guarding against, so it is kept rather +than deleted. + +The outer `match` gate deliberately still reads only the path and the +`x-codex-turn-metadata` header, never the body, so it and the body signal agree +only because the matched path set covers every route Codex posts to. That +covering assumption is pinned by a test rather than removed by teaching `match` +to read the body, which would widen the projector's claim over arbitrary paths +for no request Codex is known to make. + **Only header names Codex actually emits are read.** The audit of every header this file reads: @@ -169,7 +202,7 @@ rollout tree, keyed on the rollout's session id. - Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` (`readCodexClientMetadata`, `readCodexTurnMetadata`, `resolveCodexContext`, - `resolveConversationId`, `isCodexExchange`, `lineageSource`, + `resolveConversationId`, `hasCodexTransportSignal`, `lineageSource`, `lineageConflict`). - Tests: `test/plugins/codex-exchange-projector.test.js` (lineage surfaces), `test/plugins/codex-rollout-cwd.test.js` (subscription-route fixtures). diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 2ec3fba0..70b87be8 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1366,6 +1366,112 @@ test('lineage_source names the surface the thread actually came from', () => { assert.ok(!('lineage_conflict' in projection.attributes.codex)) }) +// @ref LLP 0144#body-is-a-codex-signal [tests]: a flat `session_id` + +// `thread_id` pair is not a Codex-exclusive shape, and `/v1/responses` and +// `/v1/chat/completions` are generic matched paths that any OpenAI-compatible +// client posts to. Honouring the pair as evidence of Codex would reopen through +// the body exactly what removing the `thread-id` header closed: an unrelated +// client stamped `client_name: 'codex'` and dictating this row's +// `conversation_id` and `session_id` (the partition key, LLP 0030). So the row +// must come out exactly as if the map had not been sent at all. +test('a non-Codex client sending only a flat client_metadata identity pair is not treated as Codex', () => { + const projector = createCodexExchangeProjector() + const flatPair = { session_id: 'foreign-session', thread_id: 'foreign-thread' } + const shapes = [ + { + path: '/v1/responses', + body: { model: 'gpt-5', input: 'go' }, + response_body: JSON.stringify({ output_text: 'done' }), + }, + { + path: '/v1/chat/completions', + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }, + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }, + ] + for (const shape of shapes) { + /** @param {Record} body */ + const project = (body) => /** @type {any} */ (projector.project(exchange({ + path: shape.path, + // A user-agent no Codex build produces, and no Codex-namespaced header. + request_headers: JSON.stringify({ 'user-agent': 'some-agent-framework/2.1' }), + request_body: JSON.stringify(body), + response_body: shape.response_body, + }), context())) + + const projection = project({ ...shape.body, client_metadata: flatPair }) + assert.equal(projection.client_name, undefined, `${shape.path}: must not be stamped codex`) + assert.notEqual(projection.conversation_id, 'foreign-thread') + assert.notEqual(projection.session_id, 'foreign-session') + // `identity_source` is stamped for every row; no lineage attribute is. + assert.equal(projection.attributes.codex.thread_id, undefined) + assert.equal(projection.attributes.codex.session_id, undefined) + assert.equal(projection.attributes.codex.lineage_source, undefined) + // Strongest form: the ambiguous map contributes nothing, so the row is + // byte-identical to the same request without it. + const control = project(shape.body) + assert.equal(projection.conversation_id, control.conversation_id) + assert.equal(projection.session_id, control.session_id) + } +}) + +// @ref LLP 0144#body-is-a-codex-signal [tests]: corroboration is what makes the +// flat pair readable, not the pair itself, so the guard above must narrow only +// WHO may be called Codex and not WHAT a known Codex client's map carries. A +// Codex user-agent is corroboration on its own, so a Codex turn whose map states +// only the flat pair still resolves its lineage from the body. +test('a transport-corroborated Codex request still resolves lineage from a flat-only client_metadata', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + request_headers: JSON.stringify({ 'user-agent': 'codex_cli_rs/0.55.0' }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.client_name, 'codex') + assert.equal(projection.conversation_id, 'thread-ua') + assert.equal(projection.session_id, 'session-ua') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0144#body-is-a-codex-signal [tests]: `match` gates on the path (plus +// the turn-metadata header) and never reads the body, while codex-context +// resolution accepts a Codex-owned body map on its own. The two only stay +// consistent because the matched path set covers every route Codex posts to: a +// body-only Codex request on an unmatched path would be rejected at the gate +// before the body was read. Pin that covering assumption rather than widen +// `match` on a hypothetical, so a Codex route the set does not cover fails here +// instead of silently going unrecorded. +test('every route Codex posts to is matched, so a body-only Codex request is never dropped at the gate', () => { + const projector = createCodexExchangeProjector() + // The ChatGPT-subscription namespace and the API-key Responses path. + for (const path of ['/backend-api/codex/responses', '/v1/responses']) { + const input = exchange({ + path, + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-gate', + session_id: 'session-gate', + thread_id: 'thread-gate', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }) + assert.equal(projector.match(input), true, `${path} must pass the match gate`) + const projection = /** @type {any} */ (projector.project(input, context())) + assert.equal(projection.client_name, 'codex', `${path} must resolve a codex context`) + assert.equal(projection.conversation_id, 'thread-gate') + } +}) + // @ref LLP 0144#row-identity [tests]: already-recorded shapes must not re-key. // These literals were captured from the pre-change projector, so a drift in // `conversation_id` resolution for a shape HypAware already recorded shows up From 3255242eea8cba3bd17de4bb7ba096db4646dc9a Mon Sep 17 00:00:00 2001 From: neutral-reconciler Date: Thu, 30 Jul 2026 03:37:08 +0000 Subject: [PATCH 5/5] Renumber LLP 0144 to 0151 to clear the collision with the OpenClaw block PR #475 merged llp/0144-shadow-provider-per-api-shape.decision.md to master as part of the OpenClaw 0142-0149 block, so this branch's llp/0144-codex-lineage-from-body-client-metadata.decision.md would have put two documents at 0144. Because the filenames differ, git reported no conflict and CI stayed green, so nothing on the rung ladder would have caught it before merge. 0151 verified free across master and every remote branch. 0150 is held by fix/issue-465, renumbered there from 0143 for the same reason. Purely a renumber: the document's content is unchanged, and the rename carries every reference with it (12 @ref annotations in exchange-projector.js, 1 in the gateway_codex_capture smoke, 14 in codex-exchange-projector.test.js, 1 in codex-rollout-cwd.test.js, plus the cross-links and Related entries in LLP 0083 and LLP 0141, and the heading). No residual 0144 reference remains on this branch and all six referenced anchors resolve in the renamed document. The human directed this renumber explicitly, accepting that moving the head strips neutral:approved and re-opens the review ladder. It resolves this one collision and sets no precedent for issue #469, where the general renumber-versus-qualified-citation convention is still open. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 24 ++++++++-------- .../smoke/flows/gateway_codex_capture.js | 2 +- ...83-codex-live-cwd-from-rollout.decision.md | 6 ++-- ...esktop-rides-the-codex-adapter.decision.md | 4 +-- ...age-from-body-client-metadata.decision.md} | 2 +- test/plugins/codex-exchange-projector.test.js | 28 +++++++++---------- test/plugins/codex-rollout-cwd.test.js | 2 +- 7 files changed, 34 insertions(+), 34 deletions(-) rename llp/{0144-codex-lineage-from-body-client-metadata.decision.md => 0151-codex-lineage-from-body-client-metadata.decision.md} (99%) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 6d287718..bdc3b4fe 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -67,7 +67,7 @@ export function createCodexExchangeProjector(opts = {}) { // Any Codex client tags a turn-metadata-carrying request with // `x-codex-turn-metadata`, even when the path looks generic, so accept the // header as a sufficient match signal. It is NOT a Desktop-only signal. - // @ref LLP 0144#real-header-names [constrained-by]: every Codex client + // @ref LLP 0151#real-header-names [constrained-by]: every Codex client // emits it, so the match is client-independent. if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true // NOTE this gate deliberately does not consult the body, while @@ -653,7 +653,7 @@ function firstPlainObject(...values) { // spelling can never match. The other names read in this file (`originator`, // `user-agent`, `x-client-request-id`, and the response's `x-oai-request-id`) // are real too, from the shared default client and `codex-api`. -// @ref LLP 0144#real-header-names [constrained-by]: named constants so a +// @ref LLP 0151#real-header-names [constrained-by]: named constants so a // fictional header name cannot be reintroduced by a typo. const X_CODEX_TURN_METADATA = 'x-codex-turn-metadata' const X_CODEX_WINDOW_ID = 'x-codex-window-id' @@ -666,13 +666,13 @@ const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' * @param {Record} reqBody */ function resolveCodexContext(input, provider, path, reqBody) { - // @ref LLP 0144#body-is-a-codex-signal [implements]: a Codex-owned body map + // @ref LLP 0151#body-is-a-codex-signal [implements]: a Codex-owned body map // identifies the exchange on its own, so the API-key route's generic // `/v1/responses` resolves with no Codex header at all. The transport signal is // resolved first because it is also what corroborates the body's ambiguous // flat identity pair (see `readCodexClientMetadata`). const transportIsCodex = hasCodexTransportSignal(input, provider, path) - // @ref LLP 0144#body-is-authority [implements]: the flat body map first, the + // @ref LLP 0151#body-is-authority [implements]: the flat body map first, the // turn-metadata blob second. Both are projections of one Codex snapshot, so // they agree whenever both are present; the body is preferred because it is // the only one present for every request kind. @@ -724,11 +724,11 @@ function resolveCodexContext(input, provider, path, reqBody) { // Which surface stated the identity this row is keyed on. Recorded so a // future Codex version that stops sending one of them is visible in a query // instead of showing up as a silent drift in `conversation_id`. - // @ref LLP 0144#lineage-source [implements]: make version drift queryable. + // @ref LLP 0151#lineage-source [implements]: make version drift queryable. const lineage_source = lineageSource(clientMetadata, metadata) // The precedence above trusts the two surfaces to agree. Nothing here can // verify that, so when they do not, say so on the row. - // @ref LLP 0144#lineage-conflict [implements]: the tie-break leaves evidence. + // @ref LLP 0151#lineage-conflict [implements]: the tie-break leaves evidence. const lineage_conflict = lineageConflict(clientMetadata, metadata) // Strip any credential userinfo at ingress, before it reaches the first-class // `git_remote` field or the `attributes.codex.git_origin_url` mirror. @@ -788,7 +788,7 @@ function resolveCodexContext(input, provider, path, reqBody) { * * Kept separate from the body signal because it is also what corroborates a * `client_metadata` map carrying no Codex-owned key of its own. - * @ref LLP 0144#body-is-a-codex-signal [implements] + * @ref LLP 0151#body-is-a-codex-signal [implements] * * @param {AiGatewayExchangeInput} input * @param {string} provider @@ -823,8 +823,8 @@ function hasCodexTransportSignal(input, provider, path) { * the pair is trusted only once `corroborated` says the transport already * identified the exchange as Codex, where it adds lineage detail to a client * that is already known rather than naming the client. - * @ref LLP 0144#body-is-authority: the always-present lineage surface. - * @ref LLP 0144#body-is-a-codex-signal [constrained-by]: which keys of the map + * @ref LLP 0151#body-is-authority: the always-present lineage surface. + * @ref LLP 0151#body-is-a-codex-signal [constrained-by]: which keys of the map * are evidence of Codex, and which only carry detail. * * @param {unknown} reqBody @@ -848,7 +848,7 @@ function readCodexClientMetadata(reqBody, corroborated) { * The turn-metadata blob. Codex transports it twice per HTTP request: as the * `x-codex-turn-metadata` header, and as the same-named string entry of the * body's `client_metadata` map. The header is read first so already-recorded - * rows keep their exact identity (@ref LLP 0144#row-identity); the body entry + * rows keep their exact identity (@ref LLP 0151#row-identity); the body entry * is the fallback for a hop that dropped the header. Absent entirely for * request kinds Codex marks as carrying no turn metadata, which is why the flat * body keys, not this blob, are the lineage authority. @@ -907,7 +907,7 @@ function lineageSource(clientMetadata, metadata) { * filling the two surfaces from different state a queryable fact rather than a * silent preference. The row still keys on the body, so this adds a signal and * changes no identity. - * @ref LLP 0144#lineage-conflict [implements]: an unverifiable agreement + * @ref LLP 0151#lineage-conflict [implements]: an unverifiable agreement * assumption gets a recorded signal. * * @param {Record | undefined} clientMetadata @@ -967,7 +967,7 @@ function selectCodexWorkspace(metadata, cwd) { * @param {ReturnType} codexContext */ function resolveConversationId(reqBody, input, provider, path, codexContext) { - // @ref LLP 0144#real-header-names [implements]: the thread comes from the + // @ref LLP 0151#real-header-names [implements]: the thread comes from the // context's own resolution (body map, then turn-metadata blob) and nowhere // else. The `thread-id` / `session-id` header names that used to be consulted // here are names Codex never emits, so they could only ever have let a diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index 254b2be9..a1832098 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -120,7 +120,7 @@ export async function run({ harness, expect }) { const codexThreadId = `thread-${harness.devRunId}` const codexSessionId = `session-${harness.devRunId}` const codexTurnId = `turn-${harness.devRunId}` - // @ref LLP 0144#body-is-authority: Codex states its lineage in the body's flat + // @ref LLP 0151#body-is-authority: Codex states its lineage in the body's flat // `client_metadata` map on every request kind, so the fixture carries it there // and NOT under the `thread-id` / `session-id` header names Codex never emits. const responsesBody = JSON.stringify({ diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index 392e2d78..c9fe1290 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Gateway, Sources **Author:** Phil / Claude **Date:** 2026-07-07 -**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0144 +**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0151 > The `@hypaware/codex` **live** exchange projector resolves an exchange's `cwd` > from the session's local rollout (`session_meta.cwd`) when the request carries @@ -33,7 +33,7 @@ no `x-codex-turn-metadata` and therefore no `workspaces`. (This paragraph previously said `codex-tui` never sends that header and that it is Codex Desktop behavior. That is false: Codex's `compatibility_headers` emits it for every turn regardless of client. See -[LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#context).) +[LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#context).) So "cwd is always available at projection time" was really "cwd is available when the client volunteers it" - and for an entire first-class traffic class, it often did not: @@ -65,7 +65,7 @@ Codex now has the symmetric fallback. filesystem work; the rollout is consulted **only** on a miss. - **Keyed on the codex session id.** The live path already resolves it: the body's `client_metadata.session_id`, else the turn-metadata blob - ([LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); + ([LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); it was never a `session-id` header, a name Codex does not emit). The rollout filename embeds it, matched via the `sessionIdFromPath` helper shared with the backfill. Only a real Codex session has a rollout, so non-codex traffic never diff --git a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index a795b45e..58c178f4 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Sources, Onboarding **Author:** Kenny / Claude **Date:** 2026-07-28 -**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0144 +**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0151 > Names a coverage fact that was already true in code and nowhere in the > product surface. Nothing about capture changes here; the picker copy, the @@ -26,7 +26,7 @@ routes, neither of which was named anywhere a user looks: Codex client sends it on a turn that carries turn metadata - Desktop included. (This bullet previously said Desktop is what sends it; that is false, and nothing in the coverage claim depends on it. See - [LLP 0144](./0144-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) + [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) 2. **Backfill.** The rollout tree under `$CODEX_HOME/sessions/**` is written by both surfaces too. That half rests on the provider's long-standing assumption (`codex/src/backfill.js`) and on smoke fixtures that synthesize diff --git a/llp/0144-codex-lineage-from-body-client-metadata.decision.md b/llp/0151-codex-lineage-from-body-client-metadata.decision.md similarity index 99% rename from llp/0144-codex-lineage-from-body-client-metadata.decision.md rename to llp/0151-codex-lineage-from-body-client-metadata.decision.md index c8477024..a72dd9e6 100644 --- a/llp/0144-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0151-codex-lineage-from-body-client-metadata.decision.md @@ -1,4 +1,4 @@ -# LLP 0144: Codex lineage reads the body's `client_metadata`, not header names +# LLP 0151: Codex lineage reads the body's `client_metadata`, not header names **Type:** Decision **Status:** Active diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 70b87be8..9592772e 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1110,10 +1110,10 @@ test('conversation_id falls back to a stable hash when no codex metadata or sess }) // --------------------------------------------------------------------- -// Lineage surfaces (LLP 0144) +// Lineage surfaces (LLP 0151) // --------------------------------------------------------------------- -// @ref LLP 0144#body-is-authority [tests]: the flat body `client_metadata` map +// @ref LLP 0151#body-is-authority [tests]: the flat body `client_metadata` map // is the only lineage surface Codex fills for every request kind, so a turn // that carries no Codex header at all must still resolve its thread, session, // turn and parent thread. @@ -1148,7 +1148,7 @@ test('Codex lineage resolves from the durable body client_metadata when no linea assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) -// @ref LLP 0144#body-is-a-codex-signal [tests]: the API-key route posts to a +// @ref LLP 0151#body-is-a-codex-signal [tests]: the API-key route posts to a // generic `/v1/responses` with no Codex-namespaced header, so the body map is // also what identifies the exchange as Codex at all. test('body client_metadata alone identifies a Codex exchange on a generic responses path', () => { @@ -1189,7 +1189,7 @@ test('body client_metadata alone identifies a Codex exchange on a generic respon assert.equal(projection.cwd, '/work/api') }) -// @ref LLP 0144#real-header-names [tests]: the compatibility headers Codex +// @ref LLP 0151#real-header-names [tests]: the compatibility headers Codex // really emits keep working, including `x-codex-parent-thread-id` (the name the // projector previously got wrong). test('Codex lineage resolves from the compatibility headers Codex actually sends', () => { @@ -1222,7 +1222,7 @@ test('Codex lineage resolves from the compatibility headers Codex actually sends assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') }) -// @ref LLP 0144#real-header-names [tests]: `thread-id`, `session-id` and +// @ref LLP 0151#real-header-names [tests]: `thread-id`, `session-id` and // `parent-thread-id` are names Codex never emits. Reading them let an // unrelated proxy hop or a hand-rolled client dictate `conversation_id`, which // is the partition-adjacent row identity, so they must resolve to nothing. @@ -1251,7 +1251,7 @@ test('a bare lineage header name Codex never sends resolves to nothing, not a wr assert.equal(projection.session_id, projection.conversation_id) }) -// @ref LLP 0144#body-is-authority [tests]: body and blob are two projections of +// @ref LLP 0151#body-is-authority [tests]: body and blob are two projections of // one Codex snapshot and cannot disagree in real traffic; pin which one wins so // the tie-break is a decision rather than an accident of argument order. test('body client_metadata wins over the turn-metadata blob when the two disagree', () => { @@ -1277,12 +1277,12 @@ test('body client_metadata wins over the turn-metadata blob when the two disagre assert.equal(projection.conversation_id, 'thread-body') assert.equal(projection.session_id, 'session-body') assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') - // @ref LLP 0144#lineage-conflict [tests]: the tie-break leaves evidence, so + // @ref LLP 0151#lineage-conflict [tests]: the tie-break leaves evidence, so // the disagreement the body silently won is on the row and countable. assert.equal(projection.attributes.codex.lineage_conflict, 'thread_id,session_id') }) -// @ref LLP 0144#lineage-conflict [tests]: the signal must be absent, not merely +// @ref LLP 0151#lineage-conflict [tests]: the signal must be absent, not merely // falsy, for the agreeing traffic that is every turn Codex is known to send, or // a nonzero conflict count stops being evidence of anything. test('agreeing lineage surfaces record no lineage_conflict', () => { @@ -1316,7 +1316,7 @@ test('agreeing lineage surfaces record no lineage_conflict', () => { assert.ok(!('lineage_conflict' in projection.attributes.codex)) }) -// @ref LLP 0144#lineage-conflict [tests]: only a real disagreement counts. A +// @ref LLP 0151#lineage-conflict [tests]: only a real disagreement counts. A // field one surface omits is the normal per-request-kind shape, not a conflict. test('a lineage field only one surface states is not a conflict', () => { const projector = createCodexExchangeProjector() @@ -1338,7 +1338,7 @@ test('a lineage field only one surface states is not a conflict', () => { assert.ok(!('lineage_conflict' in projection.attributes.codex)) }) -// @ref LLP 0144#lineage-source [tests]: the recorded name is the surface the +// @ref LLP 0151#lineage-source [tests]: the recorded name is the surface the // identity came from. Here the body states only `session_id`, so `thread_id`, // which is what `conversation_id` keys on, comes from the blob. test('lineage_source names the surface the thread actually came from', () => { @@ -1366,7 +1366,7 @@ test('lineage_source names the surface the thread actually came from', () => { assert.ok(!('lineage_conflict' in projection.attributes.codex)) }) -// @ref LLP 0144#body-is-a-codex-signal [tests]: a flat `session_id` + +// @ref LLP 0151#body-is-a-codex-signal [tests]: a flat `session_id` + // `thread_id` pair is not a Codex-exclusive shape, and `/v1/responses` and // `/v1/chat/completions` are generic matched paths that any OpenAI-compatible // client posts to. Honouring the pair as evidence of Codex would reopen through @@ -1415,7 +1415,7 @@ test('a non-Codex client sending only a flat client_metadata identity pair is no } }) -// @ref LLP 0144#body-is-a-codex-signal [tests]: corroboration is what makes the +// @ref LLP 0151#body-is-a-codex-signal [tests]: corroboration is what makes the // flat pair readable, not the pair itself, so the guard above must narrow only // WHO may be called Codex and not WHAT a known Codex client's map carries. A // Codex user-agent is corroboration on its own, so a Codex turn whose map states @@ -1439,7 +1439,7 @@ test('a transport-corroborated Codex request still resolves lineage from a flat- assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) -// @ref LLP 0144#body-is-a-codex-signal [tests]: `match` gates on the path (plus +// @ref LLP 0151#body-is-a-codex-signal [tests]: `match` gates on the path (plus // the turn-metadata header) and never reads the body, while codex-context // resolution accepts a Codex-owned body map on its own. The two only stay // consistent because the matched path set covers every route Codex posts to: a @@ -1472,7 +1472,7 @@ test('every route Codex posts to is matched, so a body-only Codex request is nev } }) -// @ref LLP 0144#row-identity [tests]: already-recorded shapes must not re-key. +// @ref LLP 0151#row-identity [tests]: already-recorded shapes must not re-key. // These literals were captured from the pre-change projector, so a drift in // `conversation_id` resolution for a shape HypAware already recorded shows up // here as a changed `message_id` / `part_id`. diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index 4982e9b8..1a510007 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -50,7 +50,7 @@ const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' * its identity but no workspace: session and thread present, no cwd anywhere. * That is the shape these tests need, because the rollout fallback only runs * when the request states a session id and no in-band cwd. - * @ref LLP 0144#body-is-authority [tests]: keyed on the surface Codex really + * @ref LLP 0151#body-is-authority [tests]: keyed on the surface Codex really * fills, not on a `session-id` header Codex never emits. */ function subscriptionClientMetadata() {