Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) - #467
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
🤖 neutral: renumbered LLP 0143 to 0144 (duplicate number cleared)This branch and PR #466 (
Neither branch could see the other, so neither was wrong, but two documents cannot share a number, and duplicate LLP numbers are precisely the corpus defect issue #463 tracks. Rather than let both merge and create the problem on Purely a renumber. The document's content is untouched. The rename carried every reference with it: 8 Verified after the rewrite: no residual Note for whoever reviews: this branch still overlaps #462 on the corrected |
…eferring 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 <noreply@anthropic.com>
Review round 1 - verdict: findings (2 fixed, 2 left)Reviewed at head The substance of this PR holds up under adversarial checking. Every load-bearing claim in the description was independently verified rather than taken on trust, and the two that matter most (row identity, byte-identity test non-vacuity) are confirmed by running the old projector, not by reading the diff. Verified claimsRow identity does not move, and the byte-identity test is NOT vacuous. I also ran both projectors over a nine-shape battery and deep-compared the whole projected row, not just the ids. Result:
No first-class row field silently changed for any shape HypAware already resolved. The only change to an already-recorded shape is one added attribute, and attributes provably do not feed the identity hash: the guard test yields the same Privacy: clean. Renumber is complete and honest. Zero occurrences of Fixture fallout: the #257 regression is not weakened. Both reshaped CLAUDE.md compliance: clean. No em dash on any added line in the PR or in my fix. No semicolons. No Findings1. Medium - a body/blob disagreement was resolved silently, with nothing recorded. FIXED. This was the review's main target. The precedence rests on a claim about another program's internals: that Codex projects one snapshot onto both surfaces, so they are equal whenever both are present. HypAware cannot verify that. The failure mode was silent preference with no detectable conflict: the body won and the blob's contradicting value was discarded without trace, and Fixed additively: 2. Low -
3. Low - NOT fixed, needs a human: the flat-pair trust branch re-opens a narrow version of the hole this PR closes.
I did not change it. It is an explicit recorded decision at LLP 0144#body-is-a-codex-signal, narrowing it to the 4. Informational - NOT fixed:
Notes, out of scope as briefed
|
… 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 <noreply@anthropic.com>
Review round 2 of 2 - verdict: findings (1 fixed, 1 informational left, 1 correction to round 1)This is round 2 of 2, the final review round. After this the PR routes to Reviewed at head Round-1 finding 3 (the flat-pair trust branch): REACHABLE, and now FIXED
Reachability verdict: reachable, and wider than round 1 described. I ran the projector at
So an unrelated client needed only two ordinary body keys to be filed as Codex and to dictate Fix ( Why the PR's fix stays intact. LLP 0144's own source table says Verification.
Round-1 finding 4 (
|
|
Triage rung (LLP 0017) for head Verdict: safe to merge. No true blocker. Two non-blocking residuals are filed as #473 (see labels: it also carries What I verified, by reading and by execution in a scratch worktree at this head:
Residual (filed as #473, both non-blocking):
|
Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
🤖 neutral: merging this PR as-is adds a duplicate LLP 0144 to
|
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 <noreply@anthropic.com>
🤖 neutral: renumbered LLP 0144 to 0151 as you askedDone, and the trade you accepted has now been taken: the head moved to Verified before touching anything. 0151 is free across Purely a renumber. The document's content is unchanged. The rename carried every reference with it:
Confirmed afterwards: no residual The commit message states this resolves one collision and sets no precedent for issue #469, where the general renumber-versus-qualified-citation convention is still open and still waiting on you. Two things worth deciding while this is fresh
And the enforcement half is already merged. PR #468 landed test('no LLP number is claimed by two documents', { skip: 'issue #463 item 1: renumber vs qualified-citation is an open corpus decision' }, ...)Clear those four duplicates and delete that |
|
Triage rung (LLP 0017) for head Renumber commit, re-verified from the tree (own count, not copied from the commit message):
Verdict: clean. This was a correct, faithful, complete renumber with no smuggled content change. Semantic drift against current master (four PRs since last real review: #475, #458, #461, #468): merged current Residuals, already tracked as #473 (
Both re-judged against the current tree and current master; I agree with the prior classification on both, no new evidence changes them, no new items found to defer. Outcome: nothing here is a true blocker. Everything is PREFERENCE or already tracked. This PR, including the previously-unreviewed renumber commit, can merge safely. Residual tracking: #473 (unchanged, not duplicated). |
…nes #466 aged `llp/0083-codex-live-cwd-from-rollout.decision.md` was the only conflict: this branch inserts an "an unusable in-band cwd is a miss" bullet into the Decision list, and master (#467) rewrote the adjacent "keyed on the codex session id" bullet to name `client_metadata.session_id` and LLP 0151. Both wanted; both kept. Two claims this branch made stopped being true when #466 landed, so they are corrected rather than carried across: - The bullet said the rollout-stated `cwd` is "not yet" held to the same rule and that PR #466 would close that half. #466 landed: `rollout-cwd.js` reads through `readRolloutSessionMeta`, which applies `sessionMetaCwd`, so a refused in-band value now falls through to an already-predicated source. Stated that way, and the "two limits" count drops to the one that remains (#476). - `usableInBandCwd`'s docstring pointed at "LLP 0143 #usable-cwd, PR #466" and said the core module "is not on `master` yet; unify them once it lands". That LLP is now 0150, and the module has landed. The duplication is kept with its real reason: LLP 0150 scopes the in-band path out of its own mandate, so borrowing its predicate here would widen 0150's stated scope, and the `error_kind` split needs the two conjuncts apart. The merged text now says explicitly that this bullet is not a consequence of LLP 0150, so nothing implies the in-band path is covered by 0150's rule. No code behavior changes in this merge: the projector, its helper, and the tests merged textually clean and are re-verified against what #466/#467 landed. Co-Authored-By: Claude <noreply@anthropic.com>
…471) (#474) * Codex live projector: an unusable in-band cwd is a miss, not a path (#471) The in-band cwd (`codexContext.cwd` / `reqBody.cwd` / `metadata.cwd` / `metadata.user_id.cwd`, the LLP 0083 fast path) reached `resolver.resolve(cwd)` unpredicated. The matcher's first act is `path.resolve(cwd)`, so a relative value was measured against the DAEMON's process cwd: a confident `.hypignore` verdict for a directory the session never ran in, plus that bogus value stamped on the row as its container. `usableInBandCwd` now requires non-blank and absolute before the value reaches the gate, and logs a `plugin.codex.usage_policy_cwd_unusable` warn with an `error_kind` and a hashed cwd so the refusal is observable rather than silent. This does NOT convert the path to fail-closed: a refused cwd falls through to the rollout fallback, and when that states nothing too the row records `cwd = NULL` and is recorded, the existing precedent (LLP 0049 R1 as extended by LLP 0085). What changes is that an unconfirmable cwd yields an honest NULL instead of a verdict computed for the wrong directory. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: state the fail-open this refusal narrows, and why the trim stays Review round 1 on #474. Two disclosure gaps in `usableInBandCwd`, both comment-only and both inside the new helper, so no extra conflict surface against the held PRs that also touch this file. - The PR reasoned that refuse-and-NULL strictly dominates accept-and-guess with "identical fail-open exposure for the should-be-ignored case". It does not. When the daemon's own process cwd sits under an ignoring `.hypignore`, the guessed base reached the correct verdict and this change now records where it previously dropped. Neither the daemon's launchd plist nor its systemd unit renders a working directory, so that cwd is `$HOME` for a `--user` unit and whatever shell started a foreground daemon. Refusing is still right (the same base produced false drops for every session that ran elsewhere), but the narrowing is real and belongs next to the code, not only in a PR body. - `cwd.trim().length > 0` gates nothing: a blank string is never absolute on either platform, so `isAbsolute` already refuses it. Dropping that conjunct leaves the whole suite green, which reads as dead code to the next person to simplify the predicate. It is load-bearing only for the `error_kind` split that tells `cwd_blank` from `cwd_not_absolute`. Say so. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: name the two limits this predicate does not reach Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com> Co-authored-by: neutral-reconciler <neutral@hyparam.dev>
…e key at the .hypignore gate (#477) * Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate (#476) `selectCodexWorkspace` falls back to the first `workspaces` turn-metadata key when none matches the request's `cwd`, and `resolveCodexContext` stamped that result as the one resolved `cwd` (LLP 0083) that feeds the `.hypignore` gate. So when the request's real cwd matched no declared workspace, the privacy verdict was computed for an unrelated directory: an opted-out session could be recorded (the leak), and a session no `.hypignore` covered could be dropped. An explicit in-band `cwd` now outranks the workspace key for the gate and the stamp. The key keeps its enrichment role and still supplies the `cwd` on the subscription route, where the request states none and the key is the only in-band source there is. A refused substitution is reported as `plugin.codex.usage_policy_workspace_cwd_refused` with hashed paths. `selectCodexWorkspace` itself is untouched to keep the conflict surface with PRs #462, #467 and #474 as small as possible. Co-Authored-By: Claude <noreply@anthropic.com> * LLP 0083: record the limits of the workspace-key refusal (#476 review) Review of PR #477 verified by execution that the fix closes #476 case (a) and several unreported variants of it, and found three consequences the amendment did not state: - the workspace key still outranks the rollout fallback, so a subscription-route session that declares a `workspaces` map never consults `session_meta.cwd` and a first-key guess can still decide its verdict (true on `master` too); - because the key keeps enriching, a row recorded where it used to drop (clean in-band cwd, ignored declared workspace) carries that ignored workspace's identity; - the gate does not canonicalize, so a symlinked in-band spelling of an ignored directory is now recorded where the key's canonical spelling used to drop it. Doc only: no code change, so the projector's contended lines are untouched. Co-Authored-By: Claude <noreply@anthropic.com> * LLP 0083: the stated limits of the workspace-key refusal cite their issues (#476 review round 2) Round 2 of the #477 review filed each residual as its own issue so it does not live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd, pre-existing), #481 (a newly-recorded row carries an ignored workspace's identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling of an ignored directory escapes its .hypignore). Also corrects the third limit. It was written as a trade this amendment makes; execution against a real on-disk symlink shows it is a property of the shared matcher that predates the amendment, that the amendment swaps which of two symmetric spellings trips it rather than opening a new leak, and that the widest case (a declared symlinked key with no in-band cwd at all) is identical before and after. Notes that the matcher fix must canonicalize the local-only list entries too, since canonicalizing only the incoming cwd un-governs an entry a user marked by its symlink spelling. Doc only: exchange-projector.js stays byte-identical, so the conflict surface against #462 and #474 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com>
A semantic merge, not a textual one: master reworked the same Codex cwd path under this branch's feet (#466 LLP 0150, #467 LLP 0151, #474, #477), so the conflicts had to be resolved on what the combined behaviour means, not on which side's hunk looked cleaner. Four files, and what each side wanted: - exchange-projector.js: master wrapped the in-band cwd in `usableInBandCwd` (#474) and added the refused-workspace warn (#477), both around the very expression this branch replaced. Kept both, with this branch's `resolveRolloutCwd` as the fallback rather than master's inline `rolloutCwd.resolve(session_id)`, which is the container key #459 is about. - rollout-cwd.js: master replaced the local first-line read with core's one `readRolloutSessionMeta` (LLP 0150); this branch added a thread-identity guard on top of that read. Composed rather than chosen: the guard now compares `meta.threadId` from the shared reader. The two fit exactly, because LLP 0150 rule 1 (raw JSONL line, never Codex's `Deserialize`) is the property the guard depends on to see an absent `payload.id` as absent. `meta.cwd` also arrives pre-predicated by `sessionMetaCwd`, so a blank or relative rollout cwd is now refused here too. Cache key stays the thread id. - LLP 0083: took master's Context correction and its unusable-in-band bullet, kept this branch's thread-keying thesis over master's superseded "keyed on the codex session id" bullet, and reconciled the prose that #467 falsified: the thread now comes from the body's `client_metadata`, not from `thread-id` / `session-id` header names Codex never emitted. The Consequences bullet promising a shared-reader follow-up was stale (that fold has landed) and now says so. - test/plugins/codex-rollout-cwd.test.js: git merged this file cleanly and the result was wrong in both directions, which is the part worth reading. Master's #257 fixtures key the fake resolver on the session id while stating a distinct thread id, so thread keying missed; rekeyed onto the thread id, which keeps master's deliberately-distinct pair. More seriously, this branch's #459 fixtures state identity through the bare `session-id` / `thread-id` / `parent-thread-id` headers, which #467 established are names no Codex version emits and removed the reads for. Left alone, four leak-direction tests failed outright and the refusal tests would have passed VACUOUSLY, for want of any id rather than because a refusal fired, silently gutting the gate. Ported the fixtures to the body `client_metadata` surface (LLP 0151), assertions unchanged. Checked, not assumed: - Regression gate still bites: master's two source files under this merged test file fail 11 of 23, including every #459 leak-direction case and all four refusal cases, so the ported fixtures are not vacuous. - `npm test`: 3039 pass / 8 fail, exactly the `leave-command` 8 that fail identically on a pristine `origin/master` worktree (73b4618), by name. - `npm run typecheck`: clean. No em dashes, no semicolons in changed lines. The LLP anchors cited (0150#usable-cwd, 0151#body-is-authority, 0083#container-fallback-gap) all resolve. Not touched, deliberately: the open `subagent_signal` finding at `resolveRolloutCwd`. The refusal is still value-blind and its shape is unchanged, but #467 narrowed its reachability, since a turn now has to carry neither a Codex-owned `client_metadata` map nor a turn-metadata blob to reach the container fallback at all. LLP 0083 records that narrowing without pretending it closes the question. Co-Authored-By: Claude <noreply@anthropic.com>
…ind cost Round 3 review of #462. Three corrections, all established by executing the real projector over every surface combination rather than by reading: - `resolveRolloutCwd`'s JSDoc still justified the container fallback with "the common subscription-route shape (a `session-id` header and nothing else)". Since #467 / LLP 0151 that header name is neither emitted by Codex nor read by this file, so the shape it names resolves nothing. Restated against the body's `client_metadata` map, which is what actually decides reachability now. - LLP 0083 said a turn reaches the fallback "only when it carries neither a Codex-owned `client_metadata` map nor a turn-metadata blob". Executed counter-example: either surface can be present and still state no `thread_id`, and then the fallback IS entered. The accurate condition is that no surface states a `thread_id`. - The value-blind refusal's cost (`review` / `compact` / `memory_consolidation` are same-workspace sub-threads, so refusing records a turn the root's cwd would have dropped) was unasserted. Two parameterized loops now pin both halves: the cost, and the bound on it (a turn stating its thread never reaches the guard). Each mutates red on its own mutation. Co-Authored-By: Claude <noreply@anthropic.com>
#462) * Codex rollout cwd keys on the thread, not the session container (#459) `createRolloutCwdResolver` located a rollout by the id embedded in its FILENAME, which is the thread (`session_meta.payload.id`), while the projector called it with the session CONTAINER (`metadata.session_id` / the `session-id` header). The two are the same uuid on a root thread, so every hand-check passed; a subagent thread inherits its root's container and mints its own thread id, so a subagent turn on the ChatGPT-subscription route resolved the ROOT thread's cwd. That cwd is what `.hypignore` is evaluated against (LLP 0083 / LLP 0050), so a subagent running in an `ignore` directory whose root was not got RECORDED: a directory-scoped privacy control silently not applying. The same value is stamped on the row, so the row also claimed a directory the turn never ran in. - The resolver's key is now the thread id, and its contract says so. - The located rollout must confirm it: `payload.id`, read off the raw JSONL line (never a deserialized `session_meta`, which Codex back-fills `session_id` from `id` in), must equal the id asked for. A filename/body disagreement is a refusal, logged as `plugin.codex.rollout_cwd_thread_mismatch`, not another thread's cwd deciding this turn. `payload.session_id` is deliberately not read here: the container does not select a rollout, and a legacy rollout carrying none still records a good cwd for its thread. - The projector passes the thread id, falling back to the container only for a turn that states no thread and no subagent lineage (a root thread, where the two ids are one value). A turn that announces lineage without naming its own thread resolves no cwd rather than the root's. LLP 0083 amended in the same commit: the keying bullet was the source of the defect, plus a Correction section and the two-readers note. Fixes #459 Co-Authored-By: Claude <noreply@anthropic.com> * Review: name the container fallback's residual gap and cover the two untested guards Review of PR #462 (head 9dcf733) found the container fallback's safety argument does not hold for the client it exists for, and two of the new rules were unenforced by the suite. - The lineage refusal (`thread_source = subagent` / `parent_thread_id`) is only reachable when the client volunteers its lineage: `thread_source` comes from `x-codex-turn-metadata` alone and `parent_thread_id` from that header or `parent-thread-id`. `codex-tui` sends none of them on the subscription route, which is precisely why the rollout fallback exists. So for that client the refusal cannot fire, the container fallback is the ONLY path, and a `codex-tui` subagent turn still resolves the ROOT thread's cwd: the #459 defect, narrowed to one shape rather than closed. Verified against the real projector and the real usage-policy resolver: a turn with just a `session-id` header records `/work/clean/root` while its own rollout says `/work/ignored/sub`. Asserted as a DOCUMENTED GAP test and named in LLP 0083 (`#container-fallback-gap`) with the open empirical question, rather than left as "not observed in practice". Dropping the fallback is not the answer: it returns every `codex-tui` turn, root threads included, to `cwd = NULL`. - Mutation testing: deleting the `thread_source === 'subagent'` disjunct left the suite green (the existing case states lineage via the `parent-thread-id` header only), and so did treating an absent `payload.id` as a match, though both code and LLP state that rule. One test each; both now redden their own guard. - LLP 0083 records the one live/backfill divergence the identity guard introduces: a `session_meta` with a `cwd` and no `id` is tolerated by `buildSession` (filename fallback) and refused live, and refusing means the turn is recorded, not dropped. Co-Authored-By: Claude <noreply@anthropic.com> * Review round 2: make the lineage refusal reachable, and correct it from Codex's own source Round 1 recorded the container fallback's residual gap as a doc note. Codex's own source (the `codex-rs` snapshot this PR already cites for `protocol.rs:3157-3184`) settles it, and the answer is a code fix. `CodexResponsesMetadata::compatibility_headers` shows what a Codex request actually carries: `x-codex-turn-metadata`, `x-codex-window-id`, `x-codex-parent-thread-id`, `x-openai-subagent`. The bare `thread-id`, `session-id` and `parent-thread-id` names the adapter reads appear nowhere in it. That made the refusal added by this PR dead in every branch: the fields it keyed on (`thread_source`, `parent_thread_id`) travel inside `x-codex-turn-metadata`, which also carries `thread_id`, so the thread-id path had already returned; and its header half read a name Codex does not send. - `resolveCodexContext` gains `subagent_signal`, folding in the two lineage headers Codex emits DIRECTLY, gated on their own value and not on the metadata blob. Those are the only lineage that survives a turn stating no thread id, so they are what make the refusal reachable. Deliberately not mirrored into `attributes` or the `parent_thread_id` column: widening what a row records is a separate change. - The refusal now consults it. One test per header; each reddens its own guard, as does removing the guard's use of the signal. - The remaining accepted case is narrowed to a turn stating a container and no lineage at all, which needs a client withholding its thread id AND every lineage signal on a subagent turn. The DOCUMENTED GAP test is rewritten to assert that, not a client-specific claim. - LLP 0083 records the header-name reading, that its "codex-tui does not send x-codex-turn-metadata" premise is at best version-specific (core emits it for every ordinary turn, `request_kind = Turn`), and that the durable fix is the body's `client_metadata.thread_id` - always present, unread today, and left out because it would newly populate `conversation_id` on rows that record null. Co-Authored-By: Claude <noreply@anthropic.com> * Review: correct LLP 0083's stale Decision thesis and clear the touched files of em dashes Three review findings on this branch, all in the convention/doc layer; no behavior change to the capture seam. 1. LLP 0083's `## Decision` still stated the superseded key. Its bolded thesis read "keyed on the session id the adapter already resolves" and the Context still wrote the rollout name as `rollout-<ts>-<session_id>.jsonl`, which is exactly the sentence the correction section says the defect came from. The keying bullet below it says "thread id", and the code annotates `@ref LLP 0083#decision [implements]: keyed on the thread id`, so an annotation pointed at prose that contradicted it. Both restated. 2. The identity guard logged one `error_kind` for two different diagnoses. An absent `payload.id` is the one rollout shape the backfill still accepts (LLP 0083 records it as a live/backfill divergence); a mismatching id is a renamed or copied file. They now report `thread_id_absent` and `thread_id_mismatch` under the same message, and both are asserted. 3. CLAUDE.md forbids the em dash anywhere. The branch added none, but left 27 in the files it rewrites, some inside the very JSDoc blocks it edited. Removed from all six touched files, with the punctuation each sentence wants. Checks: npm test 2857 pass / 8 fail, the pre-existing leave-command baseline verified identical on origin/master; npm run typecheck clean; npm run smoke -- gateway_codex_capture ok. The #459 regression gate was re-verified by restoring the three src files from origin/master: 9 of the 20 tests fail pre-fix, including the leak-direction case, and all 20 pass after. Co-Authored-By: Claude <noreply@anthropic.com> * Correct the container fallback's stale rationale and pin the value-blind cost Round 3 review of #462. Three corrections, all established by executing the real projector over every surface combination rather than by reading: - `resolveRolloutCwd`'s JSDoc still justified the container fallback with "the common subscription-route shape (a `session-id` header and nothing else)". Since #467 / LLP 0151 that header name is neither emitted by Codex nor read by this file, so the shape it names resolves nothing. Restated against the body's `client_metadata` map, which is what actually decides reachability now. - LLP 0083 said a turn reaches the fallback "only when it carries neither a Codex-owned `client_metadata` map nor a turn-metadata blob". Executed counter-example: either surface can be present and still state no `thread_id`, and then the fallback IS entered. The accurate condition is that no surface states a `thread_id`. - The value-blind refusal's cost (`review` / `compact` / `memory_consolidation` are same-workspace sub-threads, so refusing records a turn the root's cwd would have dropped) was unasserted. Two parameterized loops now pin both halves: the cost, and the bound on it (a turn stating its thread never reaches the guard). Each mutates red on its own mutation. Co-Authored-By: Claude <noreply@anthropic.com> * Attribute the guard's unreachability to the surface that actually earns it Round 4 review of #462, the last granted round. Rounds 2 and 3 narrowed the value-blind `subagent_signal` refusal to "unreachable from Codex traffic" but could not verify the premise it rests on, because the `codex-rs` snapshot was gone. Verified here by reading the emitting source directly (`openai/codex`, `codex-rs/core/src/responses_metadata.rs`, commit `1def0a8`, 2026-07-28), and the premise holds, but not for the stated reason. - The load-bearing invariant is not "each surface states a `thread_id`". That is false of the turn-metadata blob. It is that both surfaces emit `session_id` and `thread_id` AS A PAIR: `client_metadata` inserts them into one map literal, unconditionally, from two non-`Option` `String` fields, and `turn_metadata_payload` gates both on the same `has_turn_identity`. Neither surface can supply the container the fallback needs while withholding the thread that pre-empts it. - `has_turn_identity` is false for exactly one kind, `Memory`, which still emits the lineage the refusal keys on (`thread_source`, `parent_thread_id`, `x-openai-subagent: memory_consolidation`) with no id pair in the blob. So memory consolidation is the closest real Codex shape to the refusal's trigger, and what keeps it out is the flat body map ALONE, not the blob. The JSDoc and LLP 0083 both said or implied the blob answers such a turn. - Pinned by a test rather than left as prose: a memory-consolidation turn whose blob states lineage and neither id. It reddens if the body-map read for `thread_id` is removed, which is the change that would send this shape into the refusal and fail `.hypignore` open on it. LLP 0083 also now records the citation and the standing of the claim (upstream `main` snapshot, emitting code rather than captured traffic) so the next reader re-checks it in one step instead of re-deriving it a fifth time. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-loop <neutral-loop@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com> Co-authored-by: test <test@test.com>
Root cause
resolveCodexContextderived a turn's identity and lineage fromx-codex-turn-metadataplus three bare header names. Read against Codex's ownsource (
codex-rs/core/src/responses_metadata.rs,codex-rs/core/src/client.rs:929,codex-rs/codex-api/src/common.rs), Codexprojects one
CodexResponsesMetadatasnapshot onto three surfaces per HTTPrequest:
client_metadata(flat map, top-level field ofResponsesApiRequest)x-codex-installation-id,session_id,thread_id,x-codex-window-idalways;turn_id,x-codex-parent-thread-id,x-openai-subagentwhen set; and the whole turn-metadata blobx-codex-turn-metadata(header + same-named body entry)thread_source,sandbox,workspaces,parent_thread_id, ...session_id/thread_idare omitted for kinds with no turn identitycompatibility_headersx-codex-window-id,x-codex-turn-metadata,x-codex-parent-thread-id,x-openai-subagentTwo defects followed:
request body's
client_metadata, so identity depended on a surface Codex maylegitimately omit.
thread-id,session-idand
parent-thread-idare names no Codex version emits. They could neversupply a right value and could supply a wrong one: any hop or hand-rolled
client that set
thread-iddictatedconversation_id, the value the row'sfallback
message_id/part_idis scoped on (LLP 0030).parent-thread-idwas simply the wrong spelling of the realx-codex-parent-thread-id, so header-route subagent lineage never resolved.The fix
thread_id,session_id,turn_id, parent thread) resolvesbody-map-first, turn-metadata-blob-second, with the real
x-codex-parent-thread-idheader last.client_metadatamap is itself sufficient evidence that anexchange is Codex, so the API-key route's generic
/v1/responsesno longerneeds a Codex-namespaced header to be recognized. The map is only trusted when
it carries an
x-codex-*key or bothsession_idandthread_id, so anunrelated client's
client_metadatacannot masquerade as Codex lineage.the audit written next to them.
attributes.codex.lineage_source(body_client_metadata|turn_metadata)records which surface stated the identity, so a Codex version that stops
filling one is a queryable shift rather than a silent
conversation_iddrift.x-openai-subagentis real and deliberately still unread: adopting it wouldchange what
is_sidechainmeans (its values are Codex's subagent kinds),which is a separate decision. LLP 0143 says so explicitly.
Precedence, and why
The body map wins because it is the only surface present for every request
kind. It cannot conflict with the blob in real traffic: both are projections of
the same snapshot fields, so when both are present they are equal. The blob
itself is still read header-first, body-entry-second, because that is what
keeps already-recorded rows bit-identical.
Row identity
Nothing re-keys. For every shape HypAware already resolved an identity from, the
new precedence returns the same string (same snapshot field), and the removed
header names never matched real Codex traffic.
conversation_idtherefore doesnot move, so neither do the
message_id/part_idscoped on it.Already-recorded rows are left alone, no backfill; LLP 0143#row-identity
states why (the newly-identified rows were content-hash keyed and never joinable
to a thread; a backfill would re-key rows the partition spec clusters on for no
query anyone can express today, and
hyp backfill codexalready re-imports thathistory from the rollout tree).
Reproducing tests
test/plugins/codex-exchange-projector.test.js, all pinning LLP 0143 anchors:Codex lineage resolves from the durable body client_metadata when no lineage header is sent- a/backend-api/codex/responsesturn withrequest_headers: {}and bodyclient_metadata; assertsconversation_id,session_id,parent_thread_id,prompt_id,attributes.codex.*. Failed before(
conversation_idwas a content hash,attributes.codex.thread_idundefined).body client_metadata alone identifies a Codex exchange on a generic responses path- the API-key route with no Codex header at all. Failed before(
client_nameundefined: the exchange was not recognized as Codex).Codex lineage resolves from the compatibility headers Codex actually sends-the four real header names. Failed before on
parent_thread_id(misspelledname).
a bare lineage header name Codex never sends resolves to nothing, not a wrong value-thread-id: phantom-threadmust NOT become the row identity. Failedbefore:
conversation_id === 'phantom-thread'.body client_metadata wins over the turn-metadata blob when the two disagree-pins the tie-break. Failed before.
part_id and message_id stay byte-identical for the turn-metadata shape already recorded- literals captured from the pre-change projector(
e1a2ff876074693f#0,179fd16763044acd#0). Passed before and after: thisis the row-identity guard, not a reproducer.
Two
test/plugins/codex-rollout-cwd.test.jsfixtures rested on the samedisproved premise (a
session-idheader as the subscription route's identity)and were moved to the shape Codex really sends. That regression (#257: no in-band
cwd falls back to the rollout
session_meta.cwd) is unchanged, and the fixactually makes it reachable for real traffic, since the fallback is keyed on the
Codex session id, which previously had to arrive in a header Codex never sends.
Docs
header audit,
lineage_source, and the no-backfill row-identity decision.x-codex-turn-metadatais Codex Desktop behavior, and LLP 0083 additionallynamed a
session-idheader as the subscription route's session source. Bothcorrected in place with a pointer to LLP 0143. (PR Codex rollout cwd keys on the thread, not the session container (#459) #462's review reached the
same conclusion for the doc text; that PR is still open, so master still had
the false premise.)
gateway_codex_capturesmoke fixture now sends the bodyclient_metadataandno phantom headers, so the hermetic smoke is path-faithful.
Verification
npm test: 2885 tests, 2876 pass, 8 fail - all 8 are the pre-existingtest/core/leave-command.test.jsfailures, confirmed failing identically on aclean
origin/mastercheckout (3 pass / 8 fail there too). Nothing else fails.npx tsc -p tsconfig.json --noEmit: clean.npm run smoke -- gateway_codex_capture: ok.npm run smoke -- core_boot_noop: ok.Fixes #464