From d9027ba6eff81504641edede088271942ac7760d Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 04:10:04 +0000 Subject: [PATCH 1/4] Codex lineage: only a namespaced signal may trust the flat pair (#473) Deferred review findings from PR #467, per the maintainer decisions on issue #473 (A: tighten, B: proceed). A. `hasCodexTransportSignal` answered both "may this exchange be called Codex" and "may its unnamespaced `client_metadata` identity pair be trusted". A forged `user-agent: codex_cli_rs/1.0` therefore let a flat `session_id` / `thread_id` pair dictate `conversation_id` and the LLP 0030 partition key. Split the predicate: `hasCodexNamespaceSignal` (chatgpt upstream, `/backend-api/codex/` namespace, `x-codex-*` header) is what corroborates the pair; the user-agent branch stays only in the loose predicate, which names the client. Real Codex is unaffected, since its map always carries `x-codex-installation-id` and `x-codex-window-id`. B. LLP 0151's header audit claimed nothing emits `thread-id` / `session-id`. Both are real names built by `build_session_headers` on Codex's `/responses/compact` and websocket-handshake paths. They remain correctly unread, since the turn-metadata blob states the same ids for the one request kind that carries them. Corrected the claim at every echo. LLP 0151 is Active and immutable, so both land in new LLP 0164 with `Extended-by` forward-refs on the two affected sections. Co-Authored-By: Claude --- .../codex/src/exchange-projector.js | 74 +++++++--- .../smoke/flows/gateway_codex_capture.js | 4 +- ...eage-from-body-client-metadata.decision.md | 17 +++ ...-pair-needs-a-namespace-signal.decision.md | 132 ++++++++++++++++++ test/plugins/codex-exchange-projector.test.js | 83 +++++++++-- test/plugins/codex-rollout-cwd.test.js | 2 +- 6 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 llp/0164-codex-flat-pair-needs-a-namespace-signal.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 8fa783e8..53a70379 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -752,15 +752,18 @@ const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' function resolveCodexContext(input, provider, path, reqBody) { // @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`). + // `/v1/responses` resolves with no Codex header at all. const transportIsCodex = hasCodexTransportSignal(input, provider, path) + // @ref LLP 0164#flat-pair-corroboration [implements]: naming the client and + // trusting its unnamespaced identity pair are two different questions, and the + // user-agent may only answer the first. Passing the narrow predicate here is + // the whole of that split. + const flatPairIsCorroborated = hasCodexNamespaceSignal(input, provider, path) // @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. - const clientMetadata = readCodexClientMetadata(reqBody, transportIsCodex) + const clientMetadata = readCodexClientMetadata(reqBody, flatPairIsCorroborated) if (!transportIsCodex && clientMetadata === undefined) return undefined const metadata = readCodexTurnMetadata(input, clientMetadata) const userAgent = readHeader(input.request_headers, 'user-agent') @@ -896,24 +899,48 @@ 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. + * The transport signals that spell a name out of Codex's own proprietary + * vocabulary: the ChatGPT upstream, the `/backend-api/codex/` route namespace, + * or an `x-codex-*` compatibility header. Producing any of them takes knowledge + * of a route or header name Codex never published as an interface, which is a + * meaningfully higher bar than copying a product string. * - * Kept separate from the body signal because it is also what corroborates a + * This, and NOT the looser `hasCodexTransportSignal`, is what corroborates a * `client_metadata` map carrying no Codex-owned key of its own. - * @ref LLP 0151#body-is-a-codex-signal [implements] + * @ref LLP 0164#flat-pair-corroboration [implements]: the strict half of the + * split, the only half a partition key is allowed to rest on. * * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path */ -function hasCodexTransportSignal(input, provider, path) { +function hasCodexNamespaceSignal(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 + return Boolean(readHeader(input.request_headers, X_CODEX_WINDOW_ID)) +} + +/** + * Whether the transport alone identifies this exchange as Codex, before any part + * of the request body is consulted: any namespace signal above, or a Codex + * user-agent product. + * + * Deliberately the loose half of the split. The user-agent is a product-name + * convention, not a namespace: any process on the user's own machine can set + * `codex_cli_rs/...` without knowing anything Codex-specific. That is enough to + * label a row's client, which is a description, and not enough to promote an + * unnamespaced `client_metadata` pair into `conversation_id` and the LLP 0030 + * partition key, which is an identity. + * @ref LLP 0164#flat-pair-corroboration [implements]: the loose half, which may + * name the client and nothing more. + * + * @param {AiGatewayExchangeInput} input + * @param {string} provider + * @param {string} path + */ +function hasCodexTransportSignal(input, provider, path) { + if (hasCodexNamespaceSignal(input, provider, path)) return true const userAgent = readHeader(input.request_headers, 'user-agent') return codexClientFromUserAgent(userAgent).entrypoint !== undefined } @@ -935,16 +962,20 @@ function hasCodexTransportSignal(input, provider, path) { * 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. + * the pair is trusted only once `corroborated` says a Codex-namespaced transport + * signal already identified the exchange, where it adds lineage detail to a + * client that is already known rather than naming the client. A Codex-shaped + * user-agent is NOT such a signal: see `hasCodexNamespaceSignal`. * @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. + * @ref LLP 0164#flat-pair-corroboration [constrained-by]: which transport signals + * may corroborate the pair. * * @param {unknown} reqBody - * @param {boolean} corroborated Whether the transport (upstream, route, Codex - * header, or Codex user-agent) already identified this exchange as Codex. + * @param {boolean} corroborated Whether a Codex-namespaced transport signal + * (the ChatGPT upstream, the Codex route namespace, or an `x-codex-*` header) + * already identified this exchange as Codex. * @returns {Record | undefined} */ function readCodexClientMetadata(reqBody, corroborated) { @@ -1084,9 +1115,12 @@ function selectCodexWorkspace(metadata, cwd) { function resolveConversationId(reqBody, input, provider, path, codexContext) { // @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 - // non-Codex hop dictate this row's identity. + // else. No Codex turn states its lineage in the `thread-id` / `session-id` + // header names that used to be consulted here, so they could only ever have + // let a non-Codex hop dictate this row's identity. + // @ref LLP 0164#header-audit-correction [constrained-by]: those two names are + // real on Codex's compaction and websocket paths, and still not read here, + // because the turn-metadata blob states the same ids for that request kind. if (codexContext?.thread_id) return codexContext.thread_id const sessionId = readMetadataSessionId(reqBody) if (sessionId) return sessionId diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index a1832098..4ed66b1a 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -122,7 +122,9 @@ export async function run({ harness, expect }) { const codexTurnId = `turn-${harness.devRunId}` // @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. + // and NOT under the bare `thread-id` / `session-id` header names. + // @ref LLP 0164#header-audit-correction: those two names are real on Codex's + // compaction and websocket paths, but no turn states its lineage in them. const responsesBody = JSON.stringify({ model: 'gpt-5-codex', input: [{ role: 'user', content: [{ type: 'input_text', text: 'help refactor' }] }], diff --git a/llp/0151-codex-lineage-from-body-client-metadata.decision.md b/llp/0151-codex-lineage-from-body-client-metadata.decision.md index a72dd9e6..f8c31218 100644 --- a/llp/0151-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0151-codex-lineage-from-body-client-metadata.decision.md @@ -43,6 +43,9 @@ Two consequences the projector was on the wrong side of: ([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. + (**Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - + two of the three are real names on paths this audit did not reach, and + correctly unread anyway.) The premise that `x-codex-turn-metadata` is a Codex Desktop signal ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md#context), @@ -92,6 +95,12 @@ defect class as the fictional `thread-id` header header, and in a capture product a misfiled client is a privacy question and not a cosmetic one. +> **Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#flat-pair-corroboration).** +> The user-agent branch is dropped from the corroborating predicate below. It +> is a product-name convention any local process can copy, so it may name the +> client but not promote the flat pair into the partition key. The other three +> signals are unchanged, and so is everything else in this section. + 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 @@ -124,6 +133,14 @@ read.** The audit of every header this file reads: | `x-oai-request-id` (response) | yes | the service | | ~~`thread-id`~~, ~~`session-id`~~, ~~`parent-thread-id`~~ | **no** | nothing emits them; removed | +> **Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction).** +> The last row overstates: `session-id` and `thread-id` are real header names on +> Codex's `/responses/compact` and websocket-handshake paths, though not on the +> turn-streaming path this document audited, and `parent-thread-id` is fictional +> as stated. The verdict is unaffected (both remain correctly unread, since the +> turn-metadata blob states the same ids for the one request kind that carries +> them), only the reason. + `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 diff --git a/llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md b/llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md new file mode 100644 index 00000000..6abd52f9 --- /dev/null +++ b/llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md @@ -0,0 +1,132 @@ +# LLP 0164: a Codex user-agent may name the client, not key the row + +**Type:** Decision +**Status:** Active +**Systems:** Plugins, Sources, Gateway +**Author:** Claude +**Date:** 2026-07-31 +**Related:** LLP 0030, LLP 0083, LLP 0141, LLP 0151 + +> Carries the two deferred review findings from +> [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md)'s +> implementing PR (#467, follow-up issue #473). LLP 0151 is Active and +> immutable, so the tightening and the factual correction land here as new +> findings that ref it, not as edits to it. + +## Context + +LLP 0151 moved Codex lineage off header names onto the request body's flat +`client_metadata` map, and gated the map's non-Codex-exclusive `session_id` + +`thread_id` pair on the transport having already identified the exchange as +Codex ([#body-is-a-codex-signal](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-a-codex-signal)). +The predicate it gated on, `hasCodexTransportSignal`, answered four signals: the +`chatgpt` upstream, the `/backend-api/codex/` route namespace, an `x-codex-*` +compatibility header, and a `codex`-prefixed user-agent product. + +Two findings against that shape, both raised on the PR and deferred as +non-blocking: + +1. **One of the four signals is a naming convention, not a namespace.** A + forged `user-agent: codex_cli_rs/1.0` plus a body `client_metadata` carrying + only the flat pair satisfied the predicate, and the pair then dictated + `conversation_id` and `session_id`, the latter being the partition key + ([LLP 0030](./0030-session-id-partition-key.decision.md)). The other three + signals need Codex's proprietary route or header vocabulary; the user-agent + needs only a product string any process on the machine can copy. This is + residual rather than introduced: the pre-LLP-0151 projector let the same + user-agent reach a header fallback with no body gate at all, so LLP 0151 was + already a net tightening. It is worth closing anyway because the value it + steers is a privacy-adjacent partition key in a traffic-capture product. +2. **The header audit overstated its case.** LLP 0151 + [#real-header-names](./0151-codex-lineage-from-body-client-metadata.decision.md#real-header-names) + records `thread-id`, `session-id` and `parent-thread-id` as names no Codex + version emits. Two of the three are real on paths the audit did not reach. + +## Decision + +**"May be called Codex" and "may have its +flat identity pair trusted" are two predicates, and the user-agent answers only +the first.** The loose predicate keeps all four signals and keeps deciding +`client_name`, `client_version` and whether a codex context resolves at all: a +description of the client, revisable on the next row, costing nothing if wrong. +The strict predicate is the three Codex-namespaced signals only (`chatgpt` +upstream, `/backend-api/codex/` namespace, `x-codex-*` header), and it alone +corroborates a `client_metadata` map that carries no `x-codex-*` key of its own. +An identity is not revisable: it clusters rows on disk (LLP 0030 §Breaking) and +scopes the fallback `message_id`, so it may rest only on a name the client had +to learn from Codex rather than one it could guess from a release note. + +Real Codex loses nothing today. Its map carries `x-codex-installation-id` and +`x-codex-window-id` on every request (LLP 0151 +[#context](./0151-codex-lineage-from-body-client-metadata.decision.md#context)), +which is the self-naming branch and never needed corroboration. The accepted +cost is narrow and deliberate: a hypothetical future Codex build that stops +writing any `x-codex-*` map key, sends no compatibility header, posts to a +generic path, and is therefore reachable only by user-agent, loses lineage until +this file is updated. That build would already be a version drift LLP 0151 exists +to make visible, and it surfaces as an absent `lineage_source` +([#lineage-source](./0151-codex-lineage-from-body-client-metadata.decision.md#lineage-source)) +rather than as silence. + +Rejected: dropping the user-agent from the loose predicate too. It is good +enough to label a client and it is the only signal the API-key route's generic +`/v1/responses` carries when the body map is absent, so removing it would cost +real capture to buy nothing, since labelling was never the exposure. + +**Correction to LLP 0151's header audit: +`session-id` and `thread-id` are real Codex header names on two paths.** +`codex-rs/codex-api/src/requests/headers.rs::build_session_headers` inserts +headers literally named `session-id` and `thread-id`. It has two call sites in +`codex-rs/core/src/client.rs`: `compact_conversation_history` (the +`/responses/compact` endpoint, which HypAware's `isOpenAiResponsesPath` matches) +and `build_websocket_headers` (the `stream_responses_websocket` handshake). So +LLP 0151's "nothing emits them" is wrong for those two paths. It stands for +`parent-thread-id`, which remains a name nothing produces and was simply the +wrong spelling of `x-codex-parent-thread-id`, and it stands for the primary HTTP +turn-streaming path (`build_responses_request` / `stream_responses_api`), which +is the path essentially all ordinary Codex traffic uses. + +**The projector still does not read them, and that stays right.** The +correction is to the justification, not to the decision. The compaction requests +that carry the two headers are `CodexResponsesRequestKind::Compaction`, which +Codex marks `has_turn_identity = true`, so their `x-codex-turn-metadata` blob +independently states the same `session_id` and `thread_id` that +`readCodexTurnMetadata` already reads. `Memory` requests (`summarize_memories`) +state identity on no surface at all by Codex's own design, so there is nothing +for any reader to find. Reading the two bare names would therefore add no id +HypAware does not already have, while reopening exactly what LLP 0151 closed: +they are unnamespaced names any proxy hop or hand-rolled client may set, and a +wrong value there dictates row identity. The audit row's verdict ("removed") +survives its stated reason being corrected. + +The websocket handshake was not investigated further. HypAware's HTTP-proxy +gateway model does not surface it as a request/response exchange, so no +projector path reads its headers either way. + +## Consequences + +- `hasCodexTransportSignal` is now the union of `hasCodexNamespaceSignal` and + the user-agent branch, and only the narrow one is passed to + `readCodexClientMetadata`. That single argument is the whole of the split. +- Row identity does not move for any shape HypAware has recorded from real + Codex traffic, because every such request either carries an `x-codex-*` map + key or arrives on a namespaced route. The pinned-literal identity test in + `test/plugins/codex-exchange-projector.test.js` is unchanged and still passes. +- A user-agent-only exchange whose map states only the flat pair now records the + content-hash fallback identity and no `lineage_source`, and is still stamped + `client_name: 'codex'`. That asymmetry is the decision, so it is asserted as + one test with both halves rather than two tests. +- LLP 0151 gains `Extended-by` forward-refs at the two affected sections. Its + text is otherwise untouched. + +## References + +- Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` + (`hasCodexNamespaceSignal`, `hasCodexTransportSignal`, + `readCodexClientMetadata`, `resolveCodexContext`). +- Tests: `test/plugins/codex-exchange-projector.test.js` (the user-agent + asymmetry, and the namespace-corroborated flat-pair case). +- [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md) - the + decision this extends and corrects. +- [LLP 0030](./0030-session-id-partition-key.decision.md) - why an identity is + not revisable the way a client label is. diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 5fdd551a..0732737b 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1481,10 +1481,14 @@ test('Codex lineage resolves from the compatibility headers Codex actually sends assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') }) -// @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. +// @ref LLP 0151#real-header-names [tests]: no Codex turn states its lineage +// under these bare names, so reading them only let an unrelated proxy hop or a +// hand-rolled client dictate `conversation_id`, which is the partition-adjacent +// row identity. They must therefore resolve to nothing. +// @ref LLP 0164#header-audit-correction [tests]: `parent-thread-id` is fictional +// outright, while `thread-id` and `session-id` are real on the compaction and +// websocket-handshake paths and still unread, since the turn-metadata blob +// states the same ids for the one request kind that carries them. 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({ @@ -1677,9 +1681,64 @@ test('a non-Codex client sending only a flat client_metadata identity pair is no // @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 -// 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', () => { +// Codex-namespaced header 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 namespace-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({ 'x-codex-window-id': 'window-corr' }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-corr', thread_id: 'thread-corr' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.client_name, 'codex') + assert.equal(projection.conversation_id, 'thread-corr') + assert.equal(projection.session_id, 'session-corr') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0164#flat-pair-corroboration [tests]: the user-agent is a product-name +// convention any local process can copy, so it may name the client but must not +// promote an uncorroborated flat pair into the LLP 0030 partition key. The two +// halves are asserted together: the row is still called Codex (all four signals +// still answer "may be called Codex"), and the flat pair still contributes +// nothing (only the namespace signals answer "may have its flat pair trusted"). +test('a Codex user-agent alone does not let a flat client_metadata pair dictate row identity', () => { + const projector = createCodexExchangeProjector() + /** @param {Record} body */ + const project = (body) => /** @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', ...body }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + const projection = project({ client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' } }) + // Loose half: the user-agent still identifies the client. + assert.equal(projection.client_name, 'codex') + assert.equal(projection.client_version, '0.55.0') + // Strict half: nothing of the unproven pair reaches the row. + assert.notEqual(projection.conversation_id, 'thread-ua') + assert.notEqual(projection.session_id, 'session-ua') + 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 row is identical to the same request without the map. + const control = project({}) + assert.equal(projection.conversation_id, control.conversation_id) + assert.equal(projection.session_id, control.session_id) +}) + +// @ref LLP 0164#flat-pair-corroboration [tests]: dropping the user-agent branch +// from the strict predicate must not cost a Codex build that still writes an +// `x-codex-*` entry into its map. That key names the client on its own, so a +// user-agent-only transport keeps full lineage whenever the map is self-naming. +test('a Codex user-agent request keeps its lineage when the map carries a Codex-owned key', () => { const projector = createCodexExchangeProjector() const projection = /** @type {any} */ (projector.project(exchange({ path: '/v1/responses', @@ -1687,14 +1746,18 @@ test('a transport-corroborated Codex request still resolves lineage from a flat- request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go', - client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' }, + client_metadata: { + 'x-codex-installation-id': 'install-ua', + session_id: 'session-ua-owned', + thread_id: 'thread-ua-owned', + }, }), 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.conversation_id, 'thread-ua-owned') + assert.equal(projection.session_id, 'session-ua-owned') assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index 2943e0a2..32fa5e3a 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -56,7 +56,7 @@ const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' * That is the shape these tests need, because the rollout fallback only runs * when the request states an id and no in-band cwd. * @ref LLP 0151#body-is-authority [tests]: keyed on the surface Codex really - * fills, not on a `session-id` header Codex never emits. + * fills, not on a bare `session-id` header no Codex turn sends. */ function subscriptionClientMetadata() { return { From 53ef09905c7e41dc000a690d406c766f7805280f Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 04:12:14 +0000 Subject: [PATCH 2/4] Renumber LLP 0164 -> 0165 to resolve a cross-branch number collision PR #502 (fix/issue-421) already mints llp/0164-status-names-recent-clients- from-gateway-entrypoints.decision.md and is held+approved awaiting a human merge, so it has the prior claim on 0164. This branch's decision doc and every @ref pointing at it move to 0165, which is free across master and all open PR branches. No content change: the file is renamed and the number updated at all 13 reference sites (5 source, 3 test, 3 in LLP 0151's Extended-by forward refs, the doc title, and the smoke flow). Both anchors still resolve. --- .../plugins-workspace/codex/src/exchange-projector.js | 10 +++++----- hypaware-core/smoke/flows/gateway_codex_capture.js | 2 +- ...codex-lineage-from-body-client-metadata.decision.md | 6 +++--- ...dex-flat-pair-needs-a-namespace-signal.decision.md} | 2 +- test/plugins/codex-exchange-projector.test.js | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) rename llp/{0164-codex-flat-pair-needs-a-namespace-signal.decision.md => 0165-codex-flat-pair-needs-a-namespace-signal.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 53a70379..bad9cb05 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -754,7 +754,7 @@ function resolveCodexContext(input, provider, path, reqBody) { // identifies the exchange on its own, so the API-key route's generic // `/v1/responses` resolves with no Codex header at all. const transportIsCodex = hasCodexTransportSignal(input, provider, path) - // @ref LLP 0164#flat-pair-corroboration [implements]: naming the client and + // @ref LLP 0165#flat-pair-corroboration [implements]: naming the client and // trusting its unnamespaced identity pair are two different questions, and the // user-agent may only answer the first. Passing the narrow predicate here is // the whole of that split. @@ -907,7 +907,7 @@ function resolveCodexContext(input, provider, path, reqBody) { * * This, and NOT the looser `hasCodexTransportSignal`, is what corroborates a * `client_metadata` map carrying no Codex-owned key of its own. - * @ref LLP 0164#flat-pair-corroboration [implements]: the strict half of the + * @ref LLP 0165#flat-pair-corroboration [implements]: the strict half of the * split, the only half a partition key is allowed to rest on. * * @param {AiGatewayExchangeInput} input @@ -932,7 +932,7 @@ function hasCodexNamespaceSignal(input, provider, path) { * label a row's client, which is a description, and not enough to promote an * unnamespaced `client_metadata` pair into `conversation_id` and the LLP 0030 * partition key, which is an identity. - * @ref LLP 0164#flat-pair-corroboration [implements]: the loose half, which may + * @ref LLP 0165#flat-pair-corroboration [implements]: the loose half, which may * name the client and nothing more. * * @param {AiGatewayExchangeInput} input @@ -969,7 +969,7 @@ function hasCodexTransportSignal(input, provider, path) { * @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. - * @ref LLP 0164#flat-pair-corroboration [constrained-by]: which transport signals + * @ref LLP 0165#flat-pair-corroboration [constrained-by]: which transport signals * may corroborate the pair. * * @param {unknown} reqBody @@ -1118,7 +1118,7 @@ function resolveConversationId(reqBody, input, provider, path, codexContext) { // else. No Codex turn states its lineage in the `thread-id` / `session-id` // header names that used to be consulted here, so they could only ever have // let a non-Codex hop dictate this row's identity. - // @ref LLP 0164#header-audit-correction [constrained-by]: those two names are + // @ref LLP 0165#header-audit-correction [constrained-by]: those two names are // real on Codex's compaction and websocket paths, and still not read here, // because the turn-metadata blob states the same ids for that request kind. if (codexContext?.thread_id) return codexContext.thread_id diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index 4ed66b1a..5225cd0a 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -123,7 +123,7 @@ export async function run({ harness, expect }) { // @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 bare `thread-id` / `session-id` header names. - // @ref LLP 0164#header-audit-correction: those two names are real on Codex's + // @ref LLP 0165#header-audit-correction: those two names are real on Codex's // compaction and websocket paths, but no turn states its lineage in them. const responsesBody = JSON.stringify({ model: 'gpt-5-codex', diff --git a/llp/0151-codex-lineage-from-body-client-metadata.decision.md b/llp/0151-codex-lineage-from-body-client-metadata.decision.md index f8c31218..87558aea 100644 --- a/llp/0151-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0151-codex-lineage-from-body-client-metadata.decision.md @@ -43,7 +43,7 @@ Two consequences the projector was on the wrong side of: ([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. - (**Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - + (**Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - two of the three are real names on paths this audit did not reach, and correctly unread anyway.) @@ -95,7 +95,7 @@ defect class as the fictional `thread-id` header header, and in a capture product a misfiled client is a privacy question and not a cosmetic one. -> **Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#flat-pair-corroboration).** +> **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#flat-pair-corroboration).** > The user-agent branch is dropped from the corroborating predicate below. It > is a product-name convention any local process can copy, so it may name the > client but not promote the flat pair into the partition key. The other three @@ -133,7 +133,7 @@ read.** The audit of every header this file reads: | `x-oai-request-id` (response) | yes | the service | | ~~`thread-id`~~, ~~`session-id`~~, ~~`parent-thread-id`~~ | **no** | nothing emits them; removed | -> **Extended-by: [LLP 0164](./0164-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction).** +> **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction).** > The last row overstates: `session-id` and `thread-id` are real header names on > Codex's `/responses/compact` and websocket-handshake paths, though not on the > turn-streaming path this document audited, and `parent-thread-id` is fictional diff --git a/llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md b/llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md similarity index 99% rename from llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md rename to llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md index 6abd52f9..8b9245bc 100644 --- a/llp/0164-codex-flat-pair-needs-a-namespace-signal.decision.md +++ b/llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md @@ -1,4 +1,4 @@ -# LLP 0164: a Codex user-agent may name the client, not key the row +# LLP 0165: a Codex user-agent may name the client, not key the row **Type:** Decision **Status:** Active diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 0732737b..0ccb32df 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1485,7 +1485,7 @@ test('Codex lineage resolves from the compatibility headers Codex actually sends // under these bare names, so reading them only let an unrelated proxy hop or a // hand-rolled client dictate `conversation_id`, which is the partition-adjacent // row identity. They must therefore resolve to nothing. -// @ref LLP 0164#header-audit-correction [tests]: `parent-thread-id` is fictional +// @ref LLP 0165#header-audit-correction [tests]: `parent-thread-id` is fictional // outright, while `thread-id` and `session-id` are real on the compaction and // websocket-handshake paths and still unread, since the turn-metadata blob // states the same ids for the one request kind that carries them. @@ -1702,7 +1702,7 @@ test('a namespace-corroborated Codex request still resolves lineage from a flat- assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) -// @ref LLP 0164#flat-pair-corroboration [tests]: the user-agent is a product-name +// @ref LLP 0165#flat-pair-corroboration [tests]: the user-agent is a product-name // convention any local process can copy, so it may name the client but must not // promote an uncorroborated flat pair into the LLP 0030 partition key. The two // halves are asserted together: the row is still called Codex (all four signals @@ -1734,7 +1734,7 @@ test('a Codex user-agent alone does not let a flat client_metadata pair dictate assert.equal(projection.session_id, control.session_id) }) -// @ref LLP 0164#flat-pair-corroboration [tests]: dropping the user-agent branch +// @ref LLP 0165#flat-pair-corroboration [tests]: dropping the user-agent branch // from the strict predicate must not cost a Codex build that still writes an // `x-codex-*` entry into its map. That key names the client on its own, so a // user-agent-only transport keeps full lineage whenever the map is self-naming. From e1d50098e5ac3f1c2075909c755166ec630cda94 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 04:42:05 +0000 Subject: [PATCH 3/4] Review: correct the sixth and seventh echoes of LLP 0151's header audit PR #517 corrected five echoes of "nothing emits `thread-id` / `session-id`". Two more survive. `readCodexClientMetadata`'s JSDoc still calls `thread-id` a "fictional" header, three lines above the block this PR rewrote. Per LLP 0165 #header-audit-correction only `parent-thread-id` is fictional; the defect class the sentence names is unchanged, so only the adjective moves. LLP 0083 repeats the claim twice, in a doc the correction sweep did not reach: `codex-rs` "defines ... and nothing else", and the bare names "appear nowhere in it". LLP 0083 is Accepted and immutable, so both take `Extended-by` forward-refs to LLP 0165, the same shape this PR gave 0151. npm test: 3080 pass, 8 fail, the pre-existing leave-command failures (issue #512) and no others. Co-Authored-By: Claude --- .../plugins-workspace/codex/src/exchange-projector.js | 2 +- llp/0083-codex-live-cwd-from-rollout.decision.md | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index bad9cb05..99445adf 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -961,7 +961,7 @@ function hasCodexTransportSignal(input, provider, path) { * `/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 + * bare `thread-id` header read this document removed, only through the body. So * the pair is trusted only once `corroborated` says a Codex-namespaced transport * signal already identified the exchange, where it adds lineage detail to a * client that is already known rather than naming the client. A Codex-shaped diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index 5eb32879..f2bdda34 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -94,7 +94,10 @@ has the symmetric fallback. so the container is not what selects a rollout. The live path resolves the thread from the body's `client_metadata.thread_id`, else the turn-metadata blob ([LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); - it was never a `thread-id` header, a name Codex does not emit). Only a real + it was never a `thread-id` header; that name is real on paths this document + did not reach, and stated no lineage the projector reads on any of them, see + **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)**). + Only a real Codex thread has a rollout, so non-codex traffic never scans. The name is a cheap prefilter, not the answer: the located file's `payload.id` is re-checked against the id asked for, and a disagreement is a **refusal** (cwd unknown) @@ -217,6 +220,12 @@ has the symmetric fallback. remains the open acceptance check, and it is [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md)'s point that no hermetic smoke can supply it. + (**Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - + `session-id` and `thread-id` are real names `codex-rs` emits from + `build_session_headers`, on the `/responses/compact` and websocket paths this + bullet's reading did not reach; only `parent-thread-id` is fictional. The rule + the bullet states is unaffected, because no Codex turn states its lineage in + those two names on any path the projector reads.) - **First line only, cached per thread 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 From e5610e3f90383e3a51f06154bc80d70394104251 Mon Sep 17 00:00:00 2001 From: test Date: Fri, 31 Jul 2026 05:39:27 +0000 Subject: [PATCH 4/4] Review: correct the eighth echo of LLP 0151's header audit The rollout-cwd fallback's JSDoc still asserted the corrected claim: the bare `session-id` header name "is not one Codex emits or this file reads". Per LLP 0165 #header-audit-correction it is a real Codex name, emitted by `build_session_headers` on the `/responses/compact` and websocket paths. Only the "or this file reads" half survives, and it survives for a stated reason, so the sentence now says the file reads it on no path and refs LLP 0165 for why that stays right. Rounds 1 and 2 of review have now found three echoes past the five the PR corrected; this is the last occurrence in the tree. npm test: 3080 pass, 8 fail, the pre-existing leave-command failures (issue #512) and no others. npm run typecheck clean. Co-Authored-By: Claude --- .../plugins-workspace/codex/src/exchange-projector.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 99445adf..fb68873e 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -253,9 +253,12 @@ export function createCodexExchangeProjector(opts = {}) { * When the client states no thread id the container is still the right key for a * ROOT thread (there the two are one uuid). That was once the common * subscription-route shape, a bare `session-id` header; it is not any more, and - * that header name is not one Codex emits or this file reads - * (@ref LLP 0151#real-header-names). Since the adapter began reading the body's - * flat `client_metadata` map, which Codex fills with BOTH ids on every request + * that header name is not one this file reads on any path + * (@ref LLP 0151#real-header-names). The name is real on Codex's compaction and + * websocket paths, so leaving it unread is a decision rather than an oversight: + * those requests state the same ids in the turn-metadata blob + * (@ref LLP 0165#header-audit-correction). Since the adapter began reading the + * body's flat `client_metadata` map, which Codex fills with BOTH ids on every request * (@ref LLP 0151#body-is-authority), an ordinary Codex turn states its thread and * is answered by the branch above. What is left for the two lines below is a turn * that names a container on a Codex-owned surface while naming no `thread_id` on