Skip to content

hyp session ignore names the session container Codex drops on, not a thread id (#453) - #458

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-453
Jul 30, 2026
Merged

hyp session ignore names the session container Codex drops on, not a thread id (#453)#458
philcunliffe merged 4 commits into
masterfrom
fix/issue-453

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The defect

hyp session ignore could report success while recording continued, which in a privacy control is worse than an error.

The verb resolved a Codex thread id (the rollout's session_meta.payload.id). The drop keys on the session container: exchange-projector.js:98 computes session_id = stringValue(codexContext?.session_id) ?? conversationId and drops at :148 on that value. A root thread takes session_id = SessionId::from(thread_id), the same uuid, so the two coincide and nothing looks wrong; a subagent thread inherits the root's session_id and mints its own thread_id (and exports that thread id as CODEX_THREAD_ID to its shell tool calls). So an opt-out taken from inside a subagent tool call named an id the gateway never matches, and printed "the gateway will drop this session".

The trade-off chosen: liveness proof and the correct key, by splitting their jobs

CODEX_THREAD_ID is the better liveness signal (Codex sets it on the process it spawns for a tool call, so a finished thread cannot have set it, where rollout mtime is only a proxy) but it is the wrong grain. Neither source carries the answer alone:

sourcestatesdoes not state
CODEX_THREAD_IDwhich thread is running nowthe session containing it
the rollout session_metasession_id (container), id (thread), cwdwhether that session is still live

So the variable is used as a selector, not an answer: it names the live thread, the rollout for that thread is looked up by payload.id, and the container is read out of it. The cwd + staleness path stays as the fallback (old Codex, or a hand invocation), and it too now reports the container. Where only one of the two can be had, the key wins and the verb refuses: a refusal costs a re-run with an explicit id, a confident wrong key costs the user the recording they believed they had stopped (LLP 0066 R10/R13, LLP 0067 §cli-drop-key).

The thread id is not discarded, it is reported beside the container (thread_id in --json, session_id_source: codex_env_rollout, and a human-form note that the drop covers every thread in the session). Reporting one id and calling it "the session" is how the two got conflated in the first place.

The trap, and how a present session_id is told from a back-filled one

Codex's SessionMetaLine has a hand-written Deserialize that back-fills session_id from id when the field is absent (codex-rs/protocol/src/protocol.rs:3157-3184), so a resolver reading a deserialized session_meta gets the thread id back under the container's name on every pre-field rollout: this exact defect, reintroduced invisibly through its own fix, on the only files where nothing else would reveal it.

readRolloutMeta therefore parses the raw JSONL line, so an absent field is visible as absent, and absent means refuse (naming the file, saying why a thread id will not do, pointing at hyp session status <session-id>). It does not fall back to the thread id, and a stated thread whose rollout cannot be read does not fall back to the cwd scan either, since that scan answers about a thread nothing tied to this invocation.

What moved together

  • ai-gateway/src/session_command.js: resolution returns the container; CODEX_THREAD_ID selects the rollout; legacy/orphan/two-stated-clients all refuse; provenance carries thread_id and the grain disclosure.
  • codex/src/backfill.js: session_id from session_meta.session_id (thread as fallback for a pre-field rollout), thread in conversation_id, mirroring the live projector, so the opt-out names one identifier rather than one per ingestion path. Row identity is untouched: the fallback-hash scope is conversation_id ?? session_id (LLP 0030 decision 3), and conversation_id still holds the thread, so part_ids are unchanged and keep deduping against live rows.
  • codex/skills/hypaware-privacy/SKILL.md: its step-1 script POSTed payload.id. It now reads payload.session_id, stops on a rollout that records none, and points at hyp session ignore --json first.
  • LLP 0066 (R13, plus a corrected §scope: the Codex over-drop is live, not latent, and the mirror-image under-drop is the worse direction), LLP 0067 (§cli-drop-key, §cli-legacy-rollout, §backfill-partition-key, provenance + test plan), LLP 0030 (consequences).

Ground-truth gate: the failure path is what the tests exercise

test/plugins/ai-gateway-session-status.test.js

  • subagent divergence, against the code that drops (not a restated string): a subagent-shaped rollout (root session_id != own payload.id) resolves session-root on both the stated-thread and cwd paths; that id makes codex/src/exchange-projector.js return USAGE_POLICY_DROP for a subagent turn, and thread-subagent (the id the verb used to state) does not - the silent no-op, pinned so it cannot come back.
  • legacy rollout refuses: no session_id field refuses on both paths, and the same thread in a rollout that records a container resolves, so the refusal is provably about the absent field. hyp session ignore on it prints nothing that reads as done, exits unknown, and adds nothing to the ignored set.
  • plus: CODEX_THREAD_ID beats a cwd match inside the staleness window; a stated thread with no rollout refuses; a blank variable falls through; both client variables set at once refuses; the grain/provenance disclosure from status and ignore alike.

test/plugins/codex-backfill.test.js: a subagent rollout partitions on session_meta.session_id with the thread in conversation_id, through the real materializer to the row columns; a rollout with no session_id keeps the thread.

Verified failing-then-passing: with the source changes stashed, the 6 new session-status tests and the new backfill test fail; with them applied all pass.

  • npm test: 2855 pass, 8 fail - all 8 are the pre-existing test/core/leave-command.test.js baseline, unrelated to these files.
  • npx tsc -p tsconfig.json --noEmit: clean.
  • Smokes: session_optout_capture_drop, backfill_codex_fixture, walkthrough_backfill_client_history all ok.

Ordering with PR #450

#450 (fix/issue-442) is still OPEN, so this branch is based on origin/master and #450 should land first. They both rewrite resolveSessionIdForCli and its types, so expect a conflict in session_command.js, types.d.ts, llp/0066, llp/0067 and test/plugins/ai-gateway-session-status.test.js.

Resolving it is a supersede, not a merge: #450 returns CODEX_THREAD_ID directly as the session id (source: 'codex_env') and records in LLP 0067 that this leaves the thread/container divergence open for a follow-up. This change is that follow-up, and it keeps #450's liveness benefit while fixing the grain, so where the two disagree take this version: the variable becomes a selector, the answer comes from the rollout, and codex_env_rollout replaces codex_env. The two-stated-clients refusal is semantically identical in both, so keep one copy. #449 touches unrelated files.

Fixes#453

neutral-loopand others added 2 commits July 29, 2026 21:58
…#453)
`hyp session ignore` could report success while recording continued. The verb
resolved a Codex THREAD id (the rollout's `session_meta.payload.id`), while the
adapter drop keys on the session CONTAINER (`exchange-projector.js`: `session_id
= metadata.session_id ?? conversation_id`). A root thread takes `session_id =
SessionId::from(thread_id)`, so the two coincide and nothing looks wrong; a
subagent thread inherits the root's session id and mints its own thread id, so
an opt-out taken from inside a subagent tool call named an id the gateway never
matches, and the verb printed "the gateway will drop this session".
- `resolveSessionIdForCli` now resolves the container. `CODEX_THREAD_ID`, when
set, is used as a SELECTOR rather than an answer: it names the live thread (no
mtime liveness proxy), the rollout recording that thread is looked up, and the
container is read out of it. The cwd + staleness path is unchanged except that
it too reports the container. The thread id is carried alongside for
provenance and display (`thread_id` in `--json`, a whole-session grain note in
the human form), so the output stays honest about which value came from where.
- The rollout's raw JSONL line is what is parsed. Codex's `SessionMetaLine` has
a hand-written `Deserialize` that back-fills `session_id` from `id`, so a
struct-shaped read hands back the thread id under the container's name on any
pre-field rollout: the same defect, reintroduced invisibly through its own fix.
An absent field is therefore visible as absent and REFUSES (fail-closed, LLP
0066 R10/R13) instead of substituting the thread.
- Two clients each stating a session (nested Codex/Claude) refuses; a stated
thread with no readable rollout refuses rather than falling back to the cwd
scan, which would answer about a thread nothing tied to this invocation.
- `codex/src/backfill.js` agrees about the partition key: `session_id` from
`session_meta.session_id` (thread as the fallback for a pre-field rollout),
the thread in `conversation_id`, mirroring the live projector. Row identity is
untouched: the fallback-hash scope is `conversation_id ?? session_id`.
- The Codex `hypaware-privacy` skill body read `payload.id` for the same POST;
it now reads `payload.session_id`, stops on a rollout that records none, and
points at `hyp session ignore --json` first.
LLP 0066 gains R13 (name the container or refuse) and a corrected §scope: the
Codex over-drop is live, not latent, and the mirror-image under-drop is the
worse direction. LLP 0067 gains §cli-drop-key (the liveness-vs-correct-key
trade-off), §cli-legacy-rollout (the back-fill trap) and §backfill-partition-key.
LLP 0030's consequences record that backfill reads the container.
Tests exercise the failure path: a subagent-shaped rollout resolves the
container on both paths and that id makes the real projector return
USAGE_POLICY_DROP for a subagent turn while the thread id does not; a legacy
rollout refuses on both paths and `hyp session ignore` on it prints nothing that
reads as done, exits unknown, and adds nothing to the set.
Fixes#453
Co-Authored-By: Claude <noreply@anthropic.com>
Review of #458 found two ways the raw-line read could still hand back a
confident key the gateway can never match, plus one new refusal with no test.
- A blank (or non-string) `session_meta.payload.session_id` resolved
`ok: true` with that value as the drop key: `readRolloutMeta` tested
`length > 0` while `statedEnv` in the same file tests `trim()`. `hyp
session ignore` would print "ignored" for a key no row is ever stamped
with, which is the silent no-op #453 exists to remove, reached by another
route. It now refuses like the legacy case, value still passed on
byte-identical (R5).
- The envelope `type` was never checked, so a first record that is not the
`session_meta` header resolved whenever it happened to carry `id` and
`cwd`. Only the header states the container; `codex/src/rollout-cwd.js`
type-checks the same line for the same reason.
- The refusal for rollouts that disagree about which session contains a
stated thread had no test: mutating it away left the suite green. It has
one now, with the agreeing case beside it so the test is about disagreement.
LLP 0066 R13 and LLP 0067 §cli-legacy-rollout record that present-but-unusable
refuses too, and the 0067 test plan lists the three cases.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Verdict

The design is right and the central claim holds.CODEX_THREAD_ID as a selector rather than an answer is the correct split, the raw-line read genuinely defeats Codex's back-filling Deserialize, and the drop was verified against the real projector rather than a restatement. Three findings, all in the same family: the raw-line read was fail-closed on absence but not fail-closed on presence - a session_id that was present and unusable was still reported as the answer. Two guards added, one untested refusal given a test. Nothing about the architecture changed.

Reviewed at 933cac3. Fixes pushed as a308fe1.

Facts verified against Codex source (not taken on trust)

  • SessionMeta { session_id: SessionId, id: ThreadId, ... }, both serialized unconditionally: payload.session_id really is on disk for current Codex (protocol.rs:3059).
  • The trap is real: impl Deserialize for SessionMetaLine does if !fields.contains_key("session_id") { fields.insert("session_id", fields["id"]) } (protocol.rs:3173-3179). A struct-shaped read cannot tell legacy from root apart. The raw-line read is not defensive stylistics, it is the only way to see the difference.
  • RolloutItem is #[serde(tag = "type", content = "payload")], so the {"type":"session_meta","payload":{...}} shape the resolver assumes is the actual wire shape.
  • CODEX_THREAD_ID_ENV_VAR is injected per exec tool call and re-injected outside the include_only filter (shell_environment.rs:106, runtimes/mod.rs:270), so the liveness/provenance claim is accurate.

1. Refusal boundary, path by path

Every case below was run against the real resolveSessionIdForCli (probe script, not a restated assertion). "ANSWER" means a confident ok: true with a session id.

#pathat 933cac3after fix
1first line is not a session_meta record (no id)REFUSEREFUSE
2first line typed turn_context but carrying id/cwd/session_idANSWER (codex_rollout)REFUSE (finding B)
3session_meta present but on line 2REFUSEREFUSE
4first line truncated mid-JSONREFUSEREFUSE
5session_meta line longer than the 64 KiB read windowREFUSEREFUSE
6session_id: "" (both paths)REFUSEREFUSE
7session_id: " " (blank, non-empty), both pathsANSWER, sessionId === " "REFUSE (finding A)
8session_id a number / null / object / array / boolREFUSEREFUSE
9two rollouts, same payload.id, different containersREFUSE (naming both) - but untested, finding CREFUSE + test
10two rollouts, same payload.id, same containerANSWER (correct: agreement is not ambiguity)unchanged
11two rollouts, same payload.id, one legacyREFUSEREFUSE
12CODEX_THREAD_ID names a thread with no rollout on diskREFUSE (does not fall back to the cwd scan)unchanged
13CODEX_THREAD_ID set, sessions dir absent entirelyREFUSEunchanged
14thread id = ../../../etc/passwd, /etc/passwd, th/../th, ..REFUSE - no traversal: the id is only ever compared to parsed payload.id, never joined into a pathunchanged
15rollout outside the sessions dir reached by a symlinked file inside itREFUSE - entry.isFile() is false for a symlink, so it is never readunchanged
16rollout outside the sessions dir reached by a symlinked directoryREFUSE - entry.isDirectory() is false for a symlink, walk does not followunchanged
17stated thread whose rollout cwd != invocation cwdANSWER - correct by design: the env states provenance, the cwd is not a second opinion on itunchanged
18stated thread whose rollout is 40 days staleANSWER - documented residual, see finding Dunchanged
19stated thread whose session_meta has no cwdREFUSE (fail-closed; strictly the stated path does not need a cwd)unchanged
20CODEX_THREAD_ID=" th-q " (padded, no exact match)REFUSEunchanged
21CODEX_THREAD_ID="" / " "falls through to cwd path (correct)unchanged
22blank CLAUDE_CODE_SESSION_ID + real CODEX_THREAD_IDANSWER via Codex (correct: blank is not stated)unchanged
23both client variables setREFUSE naming bothunchanged
24truncated scan (maxScan hit) + stated thread foundANSWER - sound: an identity hit cannot be invalidated by an unread fileunchanged
25truncated scan + cwd pathREFUSEunchanged
26two rollouts recording the same cwd, same containerREFUSE - the ambiguity rule fires on count, before comparing containers. Conservative, and correct for a privacy controlunchanged

2. The back-fill defence works

  • Legacy rollout (no session_id key), stated-thread path and cwd path: refuses on both, message names the file, says a thread id will not do, points at the escape hatch.
  • Same thread id in a rollout that does record a container: resolves to session-root. So the refusal is provably about the absent field, not about the fixture.
  • Mutating the presence check to the back-fill (? sessionId : id) fails exactly two named tests: "a legacy rollout with no session_id field REFUSES" and "hyp session ignore on a legacy rollout reports no success and exits unknown". The defence has teeth.

3. The drop fires, and does not over-drop

Against the real codex/src/exchange-projector.js with x-codex-turn-metadata shaped as a subagent turn:

scenarioresult
ignore the resolved container, subagent turnUSAGE_POLICY_DROP
ignore the container, root turnUSAGE_POLICY_DROP
ignore the container, sibling threadUSAGE_POLICY_DROP (the disclosed whole-session scope)
ignore the thread id (the old behaviour), subagent turnRECORDS - session_id=session-root on the row. The silent no-op, reproduced
ignore container A, turn in container B that reuses thread id thread-subRECORDS - no over-drop
ignore container A, turn in container B with the same cwdRECORDS - no over-drop (the session drop does not consult cwd)
nothing ignoredRECORDS

Over-drop is structurally bounded: the drop compares one string to the stamped session_id, so it can only ever cover threads that share a container, which is exactly what the new grain note discloses.

4. Backfill partition change does not move row identity

Ran the real provider + real aiGatewayBackfillMaterializer over the same fixtures at 74aea66 and 933cac3 and diffed:

  • Subagent rollout: part_id / message_idbyte-identical (9d69ea59ecfdd8c0#0, 20af2f8046ea65b5#0). Only session_id moved (thread-subagent to session-root) and attributes.codex.thread_id appeared. Confirmed cause: the fallback-hash and prior-message scope is conversation_id ?? session_id (message_projector.js:358), and conversation_id still holds the thread, so the hash input never changed. native_id also still the thread, so watermark/dedup keys are untouched.
  • Pre-field (legacy) rollout: entirely unchanged - partitions on the thread, conversation_id the thread, no thread_id attribute. Backwards compatible.
  • Root rollout: entirely unchanged (the two ids coincide).

5. Test teeth

  • Claimed 6+1 confirmed exactly. With session_command.js and codex/src/backfill.js reverted to 74aea66, precisely 7 tests fail: the 6 new session-status tests and the new subagent backfill test. No others, and nothing extra.
  • Every guard traced to its own named test by mutation. readRolloutMeta back-fill to id (2 tests), two-stated-clients refusal, stated-thread fall-through, cwd-path legacy refusal, stated-path legacy refusal, grain note, thread_id reporting, statedEnv blankness, backfill partition key, backfill conversation_id, backfill thread_id attribute guard - each mutation reddened its own test. One exception, finding C below.
  • npm test at 933cac3: 2852 pass / 8 fail, all 8 the test/core/leave-command.test.js baseline. After my fixes: 2855 pass / 8 fail, same baseline, no regression. npx tsc -p tsconfig.json --noEmit clean. Smokes session_optout_capture_drop, backfill_codex_fixture, walkthrough_backfill_client_history all ok.
  • 66 @refs and 37 anchor links across the touched files all resolve (checked against {#slug}, inline <a id="...">, and heading slugs). No em dash on any added line. No trailing semicolons.

Findings

A. Medium - a blank session_id was reported as the answer (FIXED)

session_command.js:801 (at 933cac3) tested sessionId.length > 0, while statedEnv five functions away tests trim().length > 0. A rollout whose session_meta.payload.session_id is " " resolved ok: true, sessionId: " ", and hyp session ignore would POST that, get ignored: true back from a gateway that stores any opaque string, and print success. No row is ever stamped " ", so the drop matches nothing: this is the #453 failure class reached by a different route, and it is the one shape the presence check does not cover. Not reachable from a current Codex (SessionId is a uuid), so Medium not High - but the whole premise of this change is that the resolver does not trust the producer's serializer, and trusting the producer's value shape is the same bet one layer down.

Fixed: blank or non-string means absent, value still returned byte-identical (R5). legacyRolloutError now reads "carries no session_id field (or a blank one)" so the message stays true for both.

B. Low - the envelope type was never checked (FIXED)

readRolloutMeta's own docstring, LLP 0067, and the SKILL.md fallback all say the key comes from the session_meta header; nothing enforced it. Any first record carrying id + cwd resolved (probe #2 above). Not exploitable with today's Codex records - TurnContextItem has cwd but no id, ResponseItem has id but no cwd - so this is defence in depth. Notable mainly because the sibling reader codex/src/rollout-cwd.js:93 already type-checks the same line, so the codebase disagreed with itself about whether the guard is needed.

Fixed: parsed.type !== 'session_meta' returns undefined, with the header case tested beside it so the guard is provably about the record type.

C. Low - the "rollouts disagree about the container" refusal had no test (FIXED)

resolveFromStatedThread's distinct.length > 1 refusal is new in this PR and is the only new guard that mutation could not detect: if (false && distinct.length > 1) left the full suite green. It is reachable in the wild (a copied or restored history, two files claiming one thread id), and it is a refusal in a privacy control, which is the category this PR's own gate says must be exercised.

Fixed: a named test for the disagreement case (asserting both candidate keys are named) with the agreeing case beside it.

D. Low, no change - the stated-thread path applies no staleness bound

CODEX_THREAD_ID pointing at a rollout last written 40 days ago answers confidently (probe #18). LLP 0067 documents this residual honestly ("a process that OUTLIVES its spawn ... the claim to make there is proof of provenance, not proof of liveness"), and adding an age bound here would false-refuse on a legitimately long tool call, since a rollout is not appended to while the call runs. The output never claims liveness either. Accepted as designed - recorded here so the next reader knows it was probed and not overlooked.

E. Nits, no change

  • A rollout whose session_meta omits cwd is invisible to the stated-thread path, which does not need a cwd. Fail-closed, so harmless.
  • A padded CODEX_THREAD_ID refuses (good) but interpolates the raw padded value into the message (CODEX_THREAD_ID= th-q ), which reads oddly.
  • Duplicate session_id keys in one JSON line resolve to the last (JSON.parse semantics). Not a vector.

F. Follow-up, out of scope for this PR

codex/src/rollout-cwd.js is called with the container id (exchange-projector.js:120, rolloutCwd?.resolve(codexContext.session_id)) but finds the rollout by the uuid embedded in the filename, which is the thread id (sessionIdFromPath, backfill.js:799). For a subagent turn on the ChatGPT-subscription route the container is the root's id, so the lookup finds the root's rollout and stamps the root's session_meta.cwd on a subagent row. Same conflation family, pre-existing under LLP 0083, unchanged by this PR, and it degrades to the parent's cwd rather than to NULL. Worth its own issue now that this PR has made the two grains explicit; not a reason to hold this one.


What I changed

Pushed to fix/issue-453 as a308fe1 (from 933cac3), 4 files, +136/-10:

  • hypaware-core/plugins-workspace/ai-gateway/src/session_command.js - the two guards in readRolloutMeta (+ docstring and legacyRolloutError wording).
  • test/plugins/ai-gateway-session-status.test.js - three tests: blank/non-string session_id, header-only key source, disagreeing rollouts.
  • llp/0066-session-opt-out.spec.md - R13 now says present-but-unusable refuses too.
  • llp/0067-session-opt-out.design.md - §cli-legacy-rollout records the two shape guards ("reading the raw line is not trusting the line"); test plan lists the three cases.

Positively verified at the pushed head: parsed.type !== 'session_meta' at session_command.js:802, sessionId.trim().length > 0 at :815, "(or a blank one)" at :680; the three tests present; each new guard reddens its own named test under mutation; 2855/8 with the same baseline; tsc clean.

Ordering with #450 - guidance re-checked, and it is still right

#450 (fix/issue-442) is still OPEN (not draft, head 63067fe) and its 5 files are all in this PR's set, so the stated conflict list (session_command.js, types.d.ts, llp/0066, llp/0067, test/plugins/ai-gateway-session-status.test.js) is exact. I confirmed from #450's diff that it returns CODEX_THREAD_IDdirectly as the session id with source: 'codex_env', so "resolving the conflict is a supersede, take this version" is correct as written.

One addition the body does not state: the ordering is only safe in the stated direction. If this PR lands first, #450 must not then be merged as-is - its codex_env resolution would reintroduce the thread id as the drop key and silently undo this fix, exactly the regression this PR pins in a test. Once this lands, #450 should be closed as superseded (or reduced to nothing) rather than rebased and merged.

Host state

No state-mutating hyp subcommand was run. ~/.claude/settings.json, ~/.codex/config.toml and ~/.config/systemd/ were not touched, and ~/.codex/sessions was not read: every probe set an explicit temporary CODEX_HOME. Work was done in a detached worktree; /work/hypaware was never checked out or switched. Smokes ran hermetically with a temp HYP_HOME.

…as the CLI
Round-2 review of #458. Round 1 closed two holes in `readRolloutMeta` (the
first record must be the `session_meta` header; a present-but-blank
`session_id` counts as absent) and wrote both into LLP 0066 as a MUST. The
`hypaware-privacy` fallback script reads the same line in the same PR and did
neither: it took `r["payload"]` whatever the record type was, so a first line
typed `turn_context` carrying `session_id` would have been POSTed as the
container, and `if not sid` accepts `" "` and any truthy non-string.
It also could not carry an id containing whitespace at all: `read -r
SESSION_ID ROLLOUT_CWD` splits on it, so such an id would have been POSTed
truncated - a key the gateway never matches, reported as an opt-out. It now
refuses and points at `hyp session ignore --json`, which keeps the token
verbatim.
Also correct the `resolveFromStatedThread` docblock. "An unread file cannot
invalidate a hit" holds for FINDING the thread (an identity test) but not for
the agreement check below it, which does read on the listing: on a truncated
scan a disagreeing rollout may never be reached and a lone match is taken as
agreement. Measured at maxScan=1 over two rollouts stating one `payload.id`
under two containers: the resolver answers confidently. Accepted as a known
limit (recorded in LLP 0067) rather than closed, because refusing on
truncation would disable auto-resolution for any history past the bound and
the trigger takes hand-copied history: a fork copies the parent `session_meta`
as a LATER line, and only line 1 is read.
LLP 0067 also now records why the stated-thread path carries no staleness
bound, which was reviewed and accepted but undocumented.
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review, round 2 of 2 (FINAL) - reviewed a308fe1, fixes pushed as bcb464a

Round 1 reviewed 933cac3 (comment). This round re-measured every load-bearing claim at the new head rather than carrying it forward, judged round 1's three fixes, gave an independent verdict on finding D, and found two more things. Both are fixed. Nothing remains that blocks the change on its own merits; two items go to triage as recorded limits, not defects.

Gates at bcb464a: npm test 2855 pass / 8 fail, all 8 the pre-existing test/core/leave-command.test.js baseline. npx tsc -p tsconfig.json --noEmit clean. Smokes session_optout_capture_drop, backfill_codex_fixture, walkthrough_backfill_client_history all ok. No em dash and no statement-terminating semicolon on any added line. Every @ref the PR adds resolves (LLP 0030#decision, LLP 0067#cli-session-id, checked against heading slugs, trailing {#slug}, and inline <a id="...">/<a name="...">, the last being the form 27 docs in this corpus use); the pre-existing broken refs in the report are all in unrelated files (LLP 0101/0105/0107/0138) and are not this PR's.


1. Round 1's three fixes: all correct, and none of them narrowed anything real

A - sessionId.trim().length > 0 (session_command.js:815). Correct, and it is trim-to-reject, not trim-to-normalise. This is the crux, so measured both halves:

The value is validated trimmed and returned raw, which is the right pairing here, not the classic bug. It is the discipline already written down and enforced everywhere else on this key:

  • control.js:157-159extractSessionId: rejects on value.trim().length === 0, return value raw, with a docblock that says why in as many words - "Trimming here would desync the stored token from the adapter's lookup key: a whitespace-padded session_id would be stored trimmed but looked up raw, so ignoredSessions.has() would miss and the exchange would be RECORDED despite the opt-out".
  • control.js:76 (the GET route): same, raw.trim().length === 0 to reject, session_id: raw to answer.
  • session_command.js:689statedEnv: same, and its comment says the same.
  • The other side of the comparison does not trim: otel/src/otlp/common.js:112stringValue is value.length > 0, so exchange-projector.js:98 stamps the container byte-identically.

So round 1's fix makes readRolloutMeta the fourth site with one rule, and the comment it added ("Returned byte-identical ... only the usability test trims") is accurate. Measured against the real projector rather than argued:

P1a session_id = " session-padded " -> RESOLVES on both paths, sessionId === " session-padded " (raw)
P1b that exact raw value -> USAGE_POLICY_DROP from codex/src/exchange-projector.js
the same value trimmed -> NOT dropped
P1c session_id = " " -> refuses on both paths

P1b is the point: a normalising resolver would have missed the drop it just reported. And trim().length > 0 cannot reject an id with incidental surrounding whitespace, because it only rejects an id that is entirely whitespace - " x " still resolves, raw. Nothing legitimate narrowed.

One consequence worth naming, not fixing: stringValue does not trim, so a container literally stamped " "is a live drop key the projector honours (P1d confirms), and the CLI can now no longer name it. That is unreachable in practice and the whole control plane already refuses blank tokens at control.js:76/:157 (a hyp session ignore " " gets a 400), so the CLI refusing is the consistent direction, not a new hole.

B - parsed.type !== 'session_meta' (session_command.js:802). Correct, and it matches the two places that matter:

  • codex/src/rollout-cwd.js:93: if (!isPlainObject(row) || stringValue(row.type) !== 'session_meta') return undefined. Same check, same reason, and the resolver's comment now cites it.
  • Codex's own reader does it too: codex-rs/core/src/rollout/recorder.rs, reject_unknown_thread_history_mode opens with if value.get("type").and_then(Value::as_str) != Some("session_meta") { return Ok(()) }.

Is session_meta guaranteed to be the FIRST line, or merely usually? Guaranteed for a rollout Codex writes, and I checked the writer rather than the comment. RolloutWriter::write_pending_once calls ensure_writer_open(), then write_session_meta_if_needed(), then write_pending_items_once() - the header is emitted before any pending item on the first flush, and self.meta is cleared after, so it is written exactly once and first. The one case that could have broken this is a fork: load_rollout_items carries a comment that "Later SessionMeta lines can be copied from fork history", and Codex handles it by taking "the FIRST SessionMeta encountered in the file as the canonical thread id". So a forked rollout has its own header at line 1 and the parent's copied header later - which the line-1-only read never sees. That is a point in the design's favour, not against it: reading only line 1 is what keeps a copied parent header from being mistaken for this rollout's own.

The residual is that codex/src/backfill.js:490 scans every line for the first session_meta while the resolver reads only line 1, so a rollout whose header is not line 1 is resolvable by backfill and refused by the CLI. Measured (P1f): the CLI refuses. That is the fail-closed direction and needs no change.

C - the distinct.length > 1 test. Present and it does bite; see finding F below, which is about the reasoning around it, not the test.


2. Finding D (no staleness bound on the stated-thread path): round 1's conclusion is right, and the liveness proof is genuine. I would go further than its stated reason.

Behaviour re-confirmed at this head (P2): a 40-day-old rollout answers on the stated path; the same rollout is refused on the cwd path with "it is a finished session rather than this one".

The liveness proof is real, and it is stronger than the docblock at session_command.js:70-77 claims. From codex-rs, populate_env applies its steps in this order: (1) inherit per policy, (2) default excludes *KEY*/*SECRET*/*TOKEN*, (3) custom exclude, (4) set overrides, (5) include_only retain, (6) insert CODEX_THREAD_ID. Because the insert is step 6, the value a tool-call subprocess sees is always the spawning thread's own id: it survives include_only (as the PR says) and also exclude (which the PR does not claim), and under inherit = "all" it overwrites any stale value inherited from the Codex process's own environment. So there is exactly one candidate thread, always the live one.

That is why no bound is needed, and the argument is not really "a bound would false-refuse during a long tool call" - it is that the two paths are guarding different risks:

  • The cwd scan's risk is wrong identity: a stale rollout matching the cwd is a different, finished session being mistaken for this one. The bound is the only thing standing between the user and being told a session they are not in is covered. It belongs there.
  • The stated path structurally cannot have that risk. CODEX_THREAD_ID names one thread and the container is read out of that thread's own rollout, so the answer is the right container regardless of the file's mtime. The only thing age could tell you is whether the session is still live, and that does not change the key.

The residue is a process that outlives the thread that spawned it (a backgrounded or nohup'd escapee keeps the variable). Ignoring there adds one entry to an in-memory set (LLP 0066#ephemeral) naming a container that is still the correct container - the over-drop direction, which costs nothing and drops nothing. An age bound would instead false-refuse exactly when the rollout is legitimately untouched, i.e. during a long tool call, pushing the user toward giving up: the under-drop direction, which is the one this whole PR argues is worse. Trading a harmless over-drop for a possible under-drop is the wrong trade, so the bound stays off here and on there.

I also checked that nothing in the output overclaims for a finished session. runMutation prints "the gateway will drop this session" - forward-looking only, no claim about rows already captured - plus the in-memory caveat and the codex_env_rollout provenance note, which states what was read from where and asserts no liveness. Verdict: accept D as-is. It was under-documented rather than under-guarded, so I have written the reasoning into LLP 0067 §cli-drop-key so the next reader does not have to re-derive it.


3. Load-bearing claims re-measured at this head

Round 1's fixes touched the read path, so these were re-run, not carried forward. Own harness, not the PR's test helpers, so a mutated helper cannot flatter the result. 14/14 pass.

claimresult at a308fe1
stated-thread path yields the container for a subagent threadsession-root, source: codex_env_rollout, thread_id: thread-sub
real projector drops on that keyUSAGE_POLICY_DROP from codex/src/exchange-projector.js
real projector records on the thread id (the old no-op)not dropped, rows produced, session_id: session-root / conversation_id: thread-sub
the container key also drops the root and a sibling subagent turnboth USAGE_POLICY_DROP (the documented over-drop)
no over-drop: a different container sharing the thread idrecords, session_id: session-other
no over-drop: two containers sharing one cwdrefuses, never yields a key
legacy rollout (no session_id)refuses on both paths, never offers the thread id as the answer
stated thread with no rolloutrefuses, and does not leak the cwd-scan answer

Row identity vs origin/master (f9b9667): ran the real backfill provider through aiGatewayBackfillMaterializer on subagent / root / legacy rollout shapes and diffed. All 12 part_id / message_id / native_id values byte-identical. The only diffs anywhere in the output are the intended session_id (thread-subagent to session-root) and the new thread_id codex attribute; the root and legacy shapes are identical throughout. This holds because the fallback-hash scope is conversation_id ?? session_id (LLP 0030 decision 3) and conversation_id still carries the thread.


4. New findings (both fixed in bcb464a)

E (Medium) - the hypaware-privacy fallback script did not apply the two guards this PR's own LLP now makes a MUST

codex/skills/hypaware-privacy/SKILL.md is the other implementation of the same read, and this PR edits that exact python block. After round 1 it had diverged from the resolver on both of the holes round 1 had just closed, while llp/0066 (added in a308fe1) says a field "carried on a record that does not state the container ... MUST refuse the same way":

  • no type check: p = r.get("payload", {}) ran whatever the record type was, so a first line typed turn_context carrying session_id would have been POSTed as the container - round 1's finding B, in the file the same PR touches;
  • if not sid: accepts " " (truthy in python) and any truthy non-string, e.g. 12345 posted as "12345" - round 1's finding A.

Fixed, mirroring readRolloutMeta shape for shape. One extra guard while there: read -r SESSION_ID ROLLOUT_CWD splits on whitespace, so an id containing any whitespace would have been POSTed truncated - a key the gateway never matches, printed as an opt-out, which is precisely the #453 failure class. It now refuses and points at hyp session ignore --json, which keeps the token verbatim. Verified by porting the guard faithfully (this box has no python3) and running every shape:

PRINT "s-ok" "/c" <- header, good id
EXIT not-session_meta <- turn_context first line
EXIT no-usable-session_id <- legacy / blank / numeric / null
EXIT whitespace-in-id <- padded id the shell would have truncated
EXIT no-payload <- header with no payload
PRINT "s-ok" "/c d" <- cwd containing spaces still fine

Also checked the block stays safe inside python3 -c '...' (no single quote, no backslash) and that a sys.exit fails the script the same way the pre-existing guard already did (read sees EOF, returns nonzero, set -e exits).

F (Low) - resolveFromStatedThread's docblock claimed an immunity the agreement check does not have

The comment read "an unread file cannot invalidate a hit". True for finding the thread, which is an identity test. Not true for the distinct.length > 1 agreement check three lines below, which reads on the listing: on a truncated scan a disagreeing rollout may never be reached, and a lone match is then taken as agreement. Measured, two rollouts stating one payload.id under two containers:

untruncated -> refuses, names session-A and session-B
maxScan=1 -> ok: CONFIDENT ANSWER session-B (evidence: rollout-dup-b.jsonl)
maxScan>=2 -> refuses

rolloutFiles walks with a LIFO stack, so which files fall inside the bound is arbitrary. I fixed the reasoning, not the behaviour, and want that choice visible: refusing on truncation would disable auto-resolution for every history past MAX_ROLLOUT_SCAN (5000 rollouts is reachable for a heavy user), and the trigger needs two rollouts whose first line states one payload.id under two different containers, which Codex does not write - a fork copies the parent session_meta as a later line and only line 1 is read - so it takes hand-copied history. Recorded as a known limit in LLP 0067 §cli-drop-key and named in the docblock. For triage if anyone disagrees with that trade; the honest options are to disclose truncation in the provenance (needs a new field, plumbing, and a test-plan entry) or to refuse and accept the usability cost.


5. Merge order with #450 - still correct at this head, restated

Merge #450 first, then #458. Re-verified rather than assumed: #450 (fix/issue-442) is still OPEN and not draft, and its diff adds { env: 'CODEX_THREAD_ID', source: 'codex_env' }, returning the thread id directly as the session id. At this head there is no bare codex_env anywhere in the tree (grep finds only codex_env_rollout, in session_command.js:375/:658, types.d.ts:185/:214, llp/0067:436 and two tests). So merging #450after#458 would reintroduce codex_env and silently restore the thread id as the drop key - the exact defect #453 is about, reintroduced by the merge rather than by any commit. Resolving the conflict is a supersede: where the two disagree, take #458's version. Note already posted on #450.

6. For triage

  1. F above - the agreement check under a truncated scan. Documented as a known limit, not closed.
  2. Codex subscription route resolves .hypignore against the ROOT thread's cwd for a subagent turn: rollout-cwd matches the filename thread id, not the container it is given #459 (already filed, not re-filed here) - rollout-cwd.js resolves a subagent's .hypignore cwd from the root thread's rollout. Untouched by this PR.

Host state

No state-mutating hyp subcommand was run. No hyp init / attach / detach / join / leave. ~/.claude/settings.json, ~/.codex/config.toml and ~/.config/systemd/ untouched; every probe used its own mktempCODEX_HOME and the real ~/.codex/sessions was never read. All work in a detached worktree; /work/hypaware never switched branches. Only origin/fix/issue-453 was pushed to.

philcunliffe pushed a commit that referenced this pull request Jul 29, 2026
… not a heading-only form
Review of PR #456 (head 94fde3f). Round 1 verified, two actionable findings
fixed, both in surfaces this PR already rewrites.
codex/skills/hypaware-privacy/SKILL.md - Step 1 no longer sends a thread id.
Round 1 disclosed, in prose, that `CODEX_THREAD_ID` is a thread id while the
drop keys the session container (`codex/src/exchange-projector.js:98`), and left
the code path in place because #458 rewrites this block. The disclosure is not
sufficient: the thing that runs is the bash block, `ai-gateway/src/control.js`
sets `ignored = true` unconditionally on POST for whatever opaque token it was
handed, so the block prints `opt-out confirmed` and the review then discusses
the machine's most sensitive content believing it is not recorded. The prose
caveat gives an agent no verb with which to re-check. The block is also doomed
text: #458's version of Step 1 has no `CODEX_THREAD_ID` in it, so keeping the
risky form buys nothing that survives the rebase.
Step 1 now resolves the id from the rollout's `payload.session_id`, the
container the gateway matches, and refuses when a rollout predates it rather
than substituting `payload.id`. That is #458's decision, so this shrinks the
conflict instead of widening it. #452(a)'s win is untouched: cwd matching,
refusal on zero / ambiguous / stale, and the `INFERRED from <rollout>` label.
`CODEX_THREAD_ID` keeps a paragraph explaining why it is not the answer and
what #453 makes of it. The verification comment now states its own bound: a
true `ignored` proves the token is in the drop set, not that it is this
session's.
Verified with a fixture harness over the extracted block (fake CODEX_HOME, fake
gateway, stubbed curl for the default-port case): 12 cases pass, including a
subagent fixture where `CODEX_THREAD_ID` is exported and diverges from the
container - the container is what reaches the gateway and the thread id never
does. Round 1's `|| true` fix is intact: the documented
`http://127.0.0.1:8787` default is still reached with no config.toml.
test/plugins/codex-privacy-skill-session-id.test.js - new.
#452(a) and the grain rule had no ground-truth gate; the PR body says so. Pins
both decisions a later edit could silently reverse, in the manner of
test/plugins/query-skill-description.test.js. Fails on each of five mutations:
reverting to head's `CODEX_THREAD_ID` assignment, collecting `payload['id']`,
substituting the thread id when no container is recorded, restoring
newest-by-mtime, and dropping the staleness refusal.
.claude/skills/ref-check/SKILL.md §3 - `{#slug}` is not a heading-only form.
Form 2 was written as an explicit *heading* anchor. The corpus's usual
placement is a list item, one bullet per named proposition
(`- **Deadline rule** {#deadline}: ...`): 25 markers across 8 docs, and 47
references target them. A checker following §3 literally reports all 47 as
BROKEN, which is the same false-positive class this PR exists to remove, and it
is most of the "69 pre-existing broken" baseline. With `{#...}` indexed on any
line, 22 references are genuinely broken, so #457's count wants correcting.
Attribution numbers re-measured, since they were understated: heading-slugs-only
breaks 632 references (366 `{#...}`, 266 inline `<a id>`), not 498 (229/269).
Whitespace collapsing adds 30, as stated. The heading-count parenthetical said
709 headings / 718 ids; the corpus has 1098 / 1107 (709 is close to the count of
*distinct* heading texts). The slug rule itself is confirmed: rendered all 132
`llp/` files through GitHub's `POST /markdown` and diffed id-for-id, 1107 of
1107 exact. Round 1's two narrowings are confirmed necessary and sufficient -
stripping all inline HTML misses 4 ids, stripping none misses 9.
llp/0001 records the list-item placement alongside the heading form.
npm test 2848 pass / 8 fail (the leave-command.test.js baseline) / 1 skipped;
typecheck clean.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage verdict: SHIP (non-blocking residuals only)

Round 1 and round 2 both hit exceptional depth (26 refusal paths probed, upstream
codex-rs checked at the source level rather than taken on trust, drop/over-drop/row-identity
re-measured at each new head, the parallel hypaware-privacy skill implementation brought back
into line). This triage re-verified the two open items independently in a detached worktree at
head bcb464a (fresh git worktree add --detach off origin/master, PR ref checked out on top,
node_modules symlinked in, no state-mutating hyp command run anywhere) rather than trusting the
record.

R1 - verified myself, not just re-read: the confident-wrong-answer under a truncated scan is real, and reproduces exactly as round 2 described

Built the trigger directly against resolveSessionIdForCli (exported from
hypaware-core/plugins-workspace/ai-gateway/src/session_command.js), no PR test helpers, a scratch
CODEX_HOME never touching ~/.codex:

sessions/day1/rollout-A.jsonl -> {"type":"session_meta","payload":{"id":"thread-shared","session_id":"session-A","cwd":"/wherever"}}
sessions/day2/rollout-B.jsonl -> {"type":"session_meta","payload":{"id":"thread-shared","session_id":"session-B","cwd":"/wherever"}}
resolveSessionIdForCli({ env: { CODEX_HOME, CODEX_THREAD_ID: 'thread-shared' }, cwd: '/wherever', maxScan: 1 })
-> { ok: true, sessionId: 'session-B', source: 'codex_env_rollout', evidence: 'rollout-B.jsonl', threadId: 'thread-shared' }
same fixture, maxScan: 2 (both files reachable)
-> { ok: false, error: '...disagree about which session contains it - session-B, session-A...' }

Cause, read at session_command.js:634-673: resolveFromStatedThread builds matches by walking
only scan.files (bounded by maxScan via rolloutFiles, a LIFO directory walk at
session_command.js:716-739), and unlike the cwd path it has noscan.truncated guard before
computing distinct = [...new Set(matches.map(m => m.sessionId))]. When the walk's bound lands
between the two dupes, matches.length === 1 by construction and distinct.length is trivially 1

  • the disagreeing rollout is never read, so there is nothing to disagree with. This is not a
    hypothetical: it is the same code path, same repo, at the head under review.

But the precondition is stronger than "5000-rollout scan cap" makes it sound, and I went one
level past round 2's own justification to check this
: round 2 argued the trigger "takes
hand-copied history" because a fork copies the parent's session_meta as a later line, and only
line 1 is read. I checked codex-rs's rollout recorder directly rather than taking that as given:
RolloutRecorderParams::Create (the path for a fresh rollout - root thread, subagent thread, or
fork) calls precompute_log_file_info and takes thread_id = log_file_info.conversation_id, a
freshly generated id for that file, on every single creation - there is no code path where two
distinct rollout files are created with the same thread_id as their own line-1 SessionMeta.
RolloutRecorderParams::Resume reopens the same existing file by path rather than writing a new
one. So the trigger isn't just "needs hand-copied history" as a soft description - it structurally
cannot arise from rollout creation, forking, or resuming; the only way to get two on-disk files
whose first line states one payload.id under two different session_ids is to manually
duplicate/edit rollout files. At that point the adversary already has write access to
~/.codex/sessions and can fabricate a cwd match, a fake session entirely, or anything else this
control reads - which is already outside this control's trust boundary, not a gap this control
introduces.

Verdict: PREFERENCE, not a blocker. The bug is real and the mechanism is exactly the failure
class this PR exists to close, which is why it's worth having flagged and documented rather than
silently accepted - but shipping requires both an artificial (non-organic) file state and landing
inside a specific truncation window, and even reaching that state presupposes a threat model this
control was never scoped to resist. Round 2's own choice - fix the docblock, name the limit in
LLP 0067, flag to triage - is the right level of response. I would not block the PR on it, and I
am not filing a new issue: the reasoning is already recorded in code (session_command.js:618-626)
and LLP 0067 §cli-drop-key where it will survive independent of this PR or #453 closing, and
#459 is already the open tracking issue for the sibling class of gap (stated-thread resolution
outrunning what the root/subagent split can prove). If a future PR someday makes maxScan
CLI-configurable or exposes provenance about truncation, that's the moment to revisit, not now.

R2 - staleness bound on the stated-thread path: reasoning holds, checked independently

Verified codex-rs's populate_env (in the exec_env/shell_environment module) inserts
CODEX_THREAD_ID as its last step, after default excludes, custom excludes, and
include_only retention all run - so the value a spawned tool-call process sees always wins over
anything it might otherwise have inherited, and survives every filter in the policy. That
independently confirms round 2's claim that there is exactly one candidate thread and it is always
the live one: age cannot change which container is correct, only whether the session is still
running, and that is a different question the staleness bound on the cwd path exists to answer
because the cwd path's risk (mistaking a finished, unrelated session for this one) is a wrong-identity
risk this path structurally does not have. The one residue - a backgrounded process that outlives
the thread that spawned it - lands in the over-drop direction (one extra ignore-set entry naming the
correct, still-valid container), which the PR's own stated priority ranks as the acceptable
direction versus an under-drop false refusal during a legitimate long-running call. Checked the
output too: runMutation's "the gateway will drop this session" is forward-looking only, makes no
claim about rows already captured, and the codex_env_rollout provenance states what was read and
where without asserting liveness. No overclaim found. Accept as-is, matching round 2.

Stale PR-body detail corrected

The body's ground-truth gate said npm test: 2852 pass, 8 fail, from before round 2's two
additional fixes (and their tests) landed. Corrected to 2855 pass, 8 fail (matches this triage's
own run and round 2's report), rest of the body left verbatim.

Merge order (restated per the standing instruction)

#450 must merge before #458. Merging #450 after this one would reintroduce a bare codex_env
source and silently restore the thread id as the drop key - the exact defect #453 is about, this
time reintroduced by merge order rather than by any single commit. #456 lands last. A note is
already on #450; nothing here changes that.

Host state

No state-mutating hyp subcommand was run (init/attach/detach/join/leave all avoided).
~/.claude/settings.json, ~/.codex/config.toml, ~/.config/systemd/ untouched. The R1
reproduction used a throwaway CODEX_HOME under this session's scratch dir; the real
~/.codex/sessions was never read. All work happened in a detached worktree; /work/hypaware
itself was never switched off master.

Not readying this PR and not merging it - that stays the author's/maintainer's call.

#450 landed on master and this branch is its follow-up, so where the two
disagree about session-id resolution the conflict resolves as a SUPERSEDE
rather than a merge, per this PR's stated ordering.
Kept from #450: the liveness benefit of CODEX_THREAD_ID (Codex sets it on the
process it spawns, so a finished thread cannot have set it), the
two-stated-clients refusal, and the blank-variable fallthrough.
Kept from this branch: the correct grain. CODEX_THREAD_ID is a selector, not an
answer - it names the live thread, the rollout is looked up by payload.id, and
the session container the gateway drops on is read out of it. `codex_env_rollout`
replaces `codex_env`.
Reconciled beyond the marked conflicts, where master's #450 text auto-merged but
its claims no longer hold:
- session_command.js `provenanceNotes` doc: CODEX_THREAD_ID no longer "states"
the session id, so its path is qualified rather than presented as stated.
Master's residual (a process outliving its spawn keeps the variable) is kept,
scoped to CLAUDE_CODE_SESSION_ID.
- LLP 0066 R10: the removed age bound is about liveness alone; the resolved
container stays an inference, so R12 still applies to it.
- LLP 0067 test plan: the stated-thread path reports `codex_env_rollout` with
the rollout named in `session_id_evidence`, not `codex_env` with none.
- Dropped #450's six resolver tests, which assert the superseded behaviour
(source `codex_env`, the thread id as the answer, no evidence). This branch's
tests cover the same intent at the corrected grain. Its one assertion with no
equivalent here - a blank CLAUDE_CODE_SESSION_ID beside a real Codex thread is
not ambiguity - is carried over, adapted.
Local run: npm test 2875 pass / 8 fail (all 8 are the pre-existing
test/core/leave-command.test.js baseline, identical on pristine origin/master);
the 96 tests in ai-gateway-session-status, codex-backfill and
codex-exchange-projector all pass; npm run typecheck clean; smokes
session_optout_capture_drop and backfill_codex_fixture ok.
Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Triage verdict: SHIP (non-blocking residual only) - head 1fe0862d

Round-2 review budget (2 rounds) was already spent at head bcb464a (comments above),
and that head's own residuals were triaged clean. Since then a merge commit
(1fe0862d, "Merge origin/master into fix/issue-453") landed PR #450 into this
branch and resolved the collision as a supersede. No review has ever seen that
merge commit
- the rung ladder routes a review-round-exhausted PR straight to
triage, never back to review, so I am the only gate this content passes through.
I read it in full rather than treating "the merge commit says X" as true.

The six deleted resolver tests: verified SAFE SUPERSEDE, not lost coverage

test/plugins/ai-gateway-session-status.test.js is +475/-110 in the real gh pr diff (master's contribution alone, git diff f9b9667 c551d6e, is +108/-0 - all
six of these tests). I diffed origin/master against this head directly
(git diff origin/master HEAD -- test/...) and confirmed by name that all six of
master's #450 tests are gone and nothing else was deleted:

  1. CODEX_THREAD_ID beats the disk scan: a stated id is not an inference
  2. the 30-minute stale-rollout window cannot hand out a DEAD id when Codex states the live one
  3. two clients each stating a session id is ambiguity, and ambiguity refuses
  4. an empty CODEX_THREAD_ID is not a stated id: it falls through rather than resolving to nothing
  5. an explicit session id argument still beats a Codex-stated one
  6. a Codex-stated id is reported as stated, not as INFERRED from disk

All six assert the superseded contract (source: 'codex_env', the thread
id returned as the answer, no session_id_evidence) - exactly the shape #453
exists to remove. For each I found the successor at the new grain and ran it
green:

  • [codex] Add durable cache spool #1 and [codex] Remove OpenTelemetry npm dependencies #2 (stated id beats disk scan; dead-vs-live liveness proof) →
    session_command.js:537'CODEX_THREAD_ID selects the live rollout without the mtime proxy, then the container is read from it' - same dead/live
    fixture shape, same assertions, now on session_id (container) with
    evidence naming the rollout instead of on the bare thread id.
  • [codex] Add root tests and remove donor tree #3 (two clients refuse) → :663'two clients each stating a session refuse rather than picking one' - near-identical to the deleted test.
  • [codex] add PR checks #4 (blank var falls through; blank CLAUDE_CODE_SESSION_ID beside a real
    Codex thread is not ambiguity) → carried verbatim in intent into :537,
    lines 564-576 ("A blank variable is not a statement: it falls through...
    Nor is a blank Claude variable beside a real Codex thread AMBIGUITY"). This
    is exactly the "one assertion carried forward, adapted" the merge commit
    message claims - I checked the claim against the diff, not just the prose.
  • [codex] clarify first-run picker and npx daemon bin #5 (explicit arg beats env) → not duplicated, and correctly not: I read
    runSessionStatus/runSessionIgnore (session_command.js:132-135,
    runMutation) and the explicit-id branch is a ternary that short-circuits
    beforeresolveSessionIdForCli is ever called - it cannot distinguish
    which env var would have fired. The pre-existing test at :273 ("an explicit
    session id argument beats the environment", present before this PR and
    unrelated to hyp session: take Codex's stated CODEX_THREAD_ID over the rollout mtime guess (#442 A, D) #450) already exercises that exact branch; a CODEX_THREAD_ID
    variant would hit the identical code path. Not a coverage gap.
  • Feature: github-install #6 (stated id reported as codex_env/no evidence) → this is the literal
    superseded contract. Its replacement, :679'a Codex answer discloses the grain it acts at, and names the thread beside the container', is the
    subagent-shaped scenario end-to-end through runSessionStatus +
    runSessionIgnore, asserting session_id_source: 'codex_env_rollout',
    session_id_evidence: '<rollout file>', thread_id - i.e. it is issue
    hyp session ignore from a Codex subagent thread opts out an id the drop never matches: silent no-op reported as success #453's actual regression test, not a weaker substitute.

I ran both touched test files directly (node --test test/plugins/ai-gateway-session-status.test.js test/plugins/codex-backfill.test.js):
59/59 pass. Full npm test in a clean worktree at this head: 2875 pass /
8 fail
, all 8 the pre-existing test/core/leave-command.test.js
ERR_MODULE_NOT_FOUND baseline (ordinary missing-node_modules artifact of a
fresh worktree, unrelated to these files) - matches the merge commit's own
reported numbers and the master baseline. grep -rn "'codex_env'" over the
tree: zero hits, confirming no bare superseded source string survives anywhere.

Verdict: safe supersede. No deleted assertion encodes a behaviour that is
now uncovered.

The four "beyond marked conflicts" reconciliations: verified accurate, not overreach

I diffed each site against both master's pre-merge version and this head:

  • provenanceNotes docstring (session_command.js:336-374): master's text
    said a client-set CLAUDE_CODE_SESSION_IDorCODEX_THREAD_ID "states"
    the session id outright. The merge correctly narrowed this: only
    CLAUDE_CODE_SESSION_ID states the container; CODEX_THREAD_ID states the
    thread and the container still has to be read from the rollout, so its path
    gets its own qualifying note (idSource === 'codex_env_rollout') rather than
    being treated as stated. The outliving-spawn residual is correctly re-scoped
    to CLAUDE_CODE_SESSION_ID only. Accurate.
  • LLP 0066 R10 (llp/0066-session-opt-out.spec.md:243-268): master's text said
    both stated variables "state" the id and carry no inference bound. The merge
    added the correct qualification - "only the Claude one states the drop key
    ... under R13 [CODEX_THREAD_ID] may only select the rollout the container
    is then read out of ... it stays an inference, and R12 still applies to it."
    This is a genuine correction, not a rewrite of a decision that wasn't this
    PR's to make - it's restating an existing rule (R12, inference must be
    disclosed) to correctly cover the now-changed codex_env_rollout case.
  • LLP 0067 test plan (llp/0067-session-opt-out.design.md:562-616): master's
    text described the stated-Codex-path test as asserting source: 'codex_env'
    with no evidence. The merge corrected this to codex_env_rollout with the
    rollout named in session_id_evidence - matching the actual tests in the
    file (verified above). Accurate, and the surrounding untouched claims (blank
    variable falls through, two-clients refusal) were correctly left intact.
  • LLP 0030 addition (llp/0030-session-id-partition-key.decision.md:105-113,
    +8 lines): a new consequence bullet describing how Codex backfill's
    container/thread split now interacts with decision 1 (non-null session_id
    as partition key). Scoped correctly to a consequence of a decision already
    made in this doc, not a new decision.

No overreach found in any of the four - each correction fixes a claim that
git diff f9b9667 c551d6e shows master's auto-merged text actually made and
that this PR's design (confirmed against the code) contradicts.

Everything else in the merge commit

The merge's combined diff touches many files outside session-id scope
(docs/PRIVACY.md, various SKILL.mds, client_detach_disk.js,
claude-settings-attach.test.js, etc.) - these are #449's changes landing on
master via the same merge; git diff origin/master HEAD (the real PR scope)
shows zero difference on any of them, confirming the merge took master's
side cleanly with no bleed into this PR's own files. The real PR diff against
origin/master is the 10 files the PR body's "What moved together" section
names, nothing more.

CI is green at this head (typecheck/test x2 node versions), mergeable: MERGEABLE. No new blocking issues found in the merge commit.

Residual (non-blocking, carried from the prior review round)

  • hypaware-core/plugins-workspace/ai-gateway/src/session_command.js:670
    (resolveFromStatedThread's distinct.length > 1 agreement check): under a
    truncated rollout scan, a disagreeing duplicate rollout for the same
    thread may not be reached, so a lone match reads as agreement. Requires
    hand-duplicated rollout files under ~/.codex/sessions (Codex itself never
    writes two files whose line-1 session_meta states one thread under two
    containers - verified against codex-rs's RolloutRecorderParams::Create,
    which mints a fresh thread id per file) - an adversary who can plant that
    already has write access to the sessions directory and can fabricate other
    answers this control reads. Round-2 review and the prior triage (head
    bcb464a) both independently classified this PREFERENCE, and I
    independently re-traced the code and concur.
    Not filing a new follow-up issue: the reasoning is already recorded
    in-tree at session_command.js:618-637 and LLP 0067 §cli-drop-key, which
    is the explicit, deliberate choice the prior triage made ("the reasoning is
    already recorded in code ... where it will survive independent of this PR or
    hyp session ignore from a Codex subagent thread opts out an id the drop never matches: silent no-op reported as success #453 closing"). Re-checked gh issue list --label neutral:fix --search "PR #458" before writing this: no issue titled for this PR's deferred findings
    exists, and opening one now would duplicate documentation already committed
    to the code and the LLP corpus rather than close a gap.

Not readying or merging this PR - that stays the maintainer's call.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 30, 2026
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

🤖 neutral: triage marker repaired (was a dangling #M placeholder)

The triage comment above appended this marker to the PR body:

<!-- neutral-triage: 1fe0862d... #M -->

#M was a literal placeholder, not an issue number. The triage decided against opening a follow-up issue (its reasoning: the one residual finding is already recorded in-tree at session_command.js:618-637 and LLP 0067 §cli-drop-key), but the marker template still cited one, so the PR body carried a reference to an issue that did not exist.

That reasoning was sound, but a dangling reference in the permanent record is not. The follow-up issue now exists as #470 and the marker points at it. The classification is unchanged: the resolveFromStatedThread agreement check under a truncated rollout scan remains non-blocking, for the reasons two independent triage passes gave.

This PR is now at its terminal: mergeable, green, reviewed, neutral:approved. Held for your merge.

One thing to weigh before you merge, which is about ordering rather than this PR's contents: PR #456's triage found that merging master's #450without this PR reintroduces the thread-vs-container defect in hyp session ignore, and it is now neutral:stuck for exactly that reason. Both of that PR's review rounds called landing this PR first "required, not merely convenient". So the sequencing that unsticks #456 is to merge this PR before #456.

@philcunliffe
philcunliffe merged commit 5d270a5 into masterJul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-453 branch July 30, 2026 03:16
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
Two things, both forced by what landed on master while this was in review.
## The conflict: #458 rewrote the file this PR collapses
#458 ("hyp session ignore names the session container Codex drops on") and this
PR both touch `readRolloutMeta` in `ai-gateway/src/session_command.js`, from
opposite directions. #458 grew it: a `type === 'session_meta'` envelope guard, a
third field (`payload.session_id`), a blank-after-trim test on that field, and a
new resolution path (`resolveFromStatedThread`) built on it. This PR deletes it:
issue #465's whole value is that no second copy of the `session_meta` rules
survives, so the function becomes a delegation to
`src/core/codex/rollout_session_meta.js`.
Resolved by keeping both. Every behaviour #458 added is intact - the container
is still the answer, `CODEX_THREAD_ID` is still a selector rather than an answer,
a legacy or blank `session_id` still refuses rather than falling back to the
thread id - and none of the predicates behind it live here any more. The three
rules (raw line, envelope type, blank-is-absent) are stated once, in the core
reader, which is the point of #465.
## Third-copy check
#458's own resolution logic does NOT read `session_meta` fields directly.
`resolveFromStatedThread` matches on `meta.threadId` and reads `meta.sessionId`,
both from `readRolloutMeta`, so it is a second *resolution path*, not a second
*reader*, and routing it through the shared reader needed no change to it. What
had become a full second copy is `readRolloutMeta` itself: #458 gave it its own
envelope guard and its own blank test on the new field, which is precisely the
duplicate this PR removes. `statedEnv` is a blankness test on an environment
variable, not on the header, and stays.
The only other site that reads `session_meta` fields itself is
`codex/src/backfill.js`, which walks whole rollout files (folding `turn_context`)
and so cannot call a first-line reader. It shares the one `cwd` predicate
(`sessionMetaCwd`) and deliberately does not share rule 3's id refusal: a
backfilled row must land in some partition, where the CLI can refuse. Now stated
in the LLP rather than only in the code comment.
## One behaviour change the merge required
`readRolloutMeta` no longer discards a rollout whose `cwd` is unusable; it passes
`cwd: undefined` through. The shared reader refuses a blank or relative `cwd`
(LLP 0150 #usable-cwd), and on the cwd-matching path that is what we want. On
#458's stated-thread path `cwd` is never consulted, so requiring it would have
turned a field-level predicate into a file-level one and refused a session whose
container is plainly on disk - a regression of #458 introduced by tightening a
field it does not use. Pinned by a new test, mutation-checked: restoring the cwd
requirement reddens it.
## Reconciled beyond the conflict markers
Four claims that auto-merged cleanly but stopped being true once #458 landed:
- LLP 0150 said `sessionId` "has no consumer yet on purpose" and that moving the
verb onto the container "is #453's job". #453 is closed and the verb is moved;
the section now records that, and that #458 added a resolution path but no
second reader.
- LLP 0150 said the `hyp session` caller "compares `meta.cwd` against an absolute
invocation cwd, so a relative value never matched". True of one of its two
paths now. Rewritten to say why the other path must not refuse on `cwd` at all.
- LLP 0150's rule 3 read as a blanket "callers refuse", which the backfill does
not. Scoped to the reader, with the backfill's different answer explained.
- The `readRolloutMeta` doc comment said `meta.sessionId` "is deliberately not
consulted here". It is consulted now.
Also: LLP 0150's Context bullet no longer implies `CODEX_THREAD_ID` makes the
rollout unnecessary, and `resolveSessionIdForCli`'s legacy-rollout note points at
`legacyRolloutError`, which is where that refusal now lives.
## The renumber: collision avoidance, NOT a ruling on #469#475 landed `llp/0143-openclaw-registers-no-attach-probe.decision.md` while this
PR held `llp/0143-one-reader-for-codex-session-meta.decision.md`. Different
filenames, so git flags no conflict, but merging as-is would put two documents at
0143 on master, a fifth duplicate after 0098, 0099, 0111 and 0142. This document
moves to 0150 (0149 is the highest on master) and all 12 references follow:
`@ref` annotations in `session_command.js`, `backfill.js`, `rollout-cwd.js`,
`rollout_session_meta.js`, `types.d.ts` and four test files, plus the heading and
one self-reference in the document.
**This is mechanical collision avoidance and sets no precedent.** Issue #469
asks whether the later claimant renumbers or whether citations become
filename-qualified, and that question is still open and unowned. Renumbering here
is only what avoids adding a sixth duplicate today; whichever way #469 is
decided, nothing about this commit should be read as having decided it. The
human's 0143 is untouched, as are LLP 0142's two references to it.
Verified: `npm test` 2915 tests, 2905 pass, 8 fail (the pre-existing
`test/core/leave-command.test.js` set, identical on a pristine `origin/master`
worktree), +12 tests and no new failures. `npm run typecheck` clean. Smokes
`gateway_codex_capture`, `session_optout_capture_drop`, `backfill_codex_fixture`
all ok. All `@ref LLP 0150` targets and anchors resolve.
Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…esent, and ref-check's snapshot note scopes itself
#453 landed on master via #458, so `hyp session ignore` already uses
CODEX_THREAD_ID as a selector. Round 3 corrected that tense in the skill's
Step 1 prose and left the same stale "until then" in the test that pins the
decision, which is the surface a later reader checks the rule against.
Also scopes round 3's snapshot note to the section it measured: the sample
report under "Report findings" carries illustrative counts that were never
measured at `79d147c`, so "every count on this page" overclaimed.
Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Jul 31, 2026
…THREAD_ID, and the ignore caveat names the fork (#452, #455) (#456)
* docs: Codex session id comes from CODEX_THREAD_ID, and the ignore caveat names the fork too (#452, #455)
Two documentation-accuracy fixes plus the one user-visible string they both
point at.
#455 - `hyp session ignore` printed "a gateway restart drops it", which reads
as the exhaustive list of ways the opt-out stops applying. LLP 0066 §readable
names a second: the client minting a new session id for what the user
experiences as one conversation. The caveat now names both, hoisted into a
single `EPHEMERAL_NOTE` so the writer's wording and `status`'s cannot drift
apart. LLP 0066 R9 gains the requirement; LLP 0067's annotation map gains the
site.
#452(a) - the Codex `hypaware-privacy` skill identified the current session by
picking the newest rollout by mtime, and asserted Codex exposes no session env
var. Codex states `CODEX_THREAD_ID` in the environment of the subprocesses it
spawns, and newest-by-mtime is exactly the heuristic that resolves a finished
session, so a privacy skill built on it can mark or purge another session's
rows. The skill now prefers the stated id and describes the disk scan as the
fallback it is: cwd-matched, refusing on ambiguity, and bounded by the ~30
minute staleness window, reported as "inferred from <rollout>".
#452(b) - all ten `@ref LLP 0086#*` annotations resolve: LLP 0086 carries its
anchors as inline `<a id="...">`, the corpus's normal way to give one section
several named propositions, so the `grep '{#'` that reported them dangling was
too narrow. The real defect was the checker's contract: ref-check's index step
described heading slugs only, which reports 269 resolvable refs across the
corpus as broken. It now indexes all three anchor forms, warns on duplicate LLP
numbers, and slugifies the way the renderer does. LLP 0001's "heading-slug
anchors" line is amended to match. The sibling annotation at
`ai-gateway/src/types.d.ts` carried no gloss, which LLP 0000 requires; it and
its two neighbours now have one.
Co-Authored-By: Claude <noreply@anthropic.com>
* review fixes: reachable base fallback, CODEX_THREAD_ID grain disclosure, exact slug rule
Review of PR #456 (head fa7c3bc). Three fixes, all in the surfaces this PR
already rewrites.
codex/skills/hypaware-privacy/SKILL.md
- The `[model_providers.hypaware]` base_url lookup aborted the whole Step 1
script under `set -e -o pipefail` whenever config.toml was missing or had no
`base_url` (grep exits nonzero, pipefail propagates, the assignment fails).
The documented `http://127.0.0.1:8787` default on the next line was therefore
unreachable, and because grep's stderr is discarded the operator saw
"resolved session <id>" followed by nothing and exit 1. Pre-existing, but it
is the happy path of the block this PR rewrites. `|| true` restores it.
Verified with a fixture harness: the five Step 1 cases (stated id, zero
matches, ambiguous, stale, one fresh match) now all behave as documented,
refusals still exit 1 without reaching the gateway.
- `CODEX_THREAD_ID` is a thread id; the gateway drops on the session container
(`codex/src/exchange-projector.js:98` keys on `metadata.session_id`, falling
back to the conversation id). They coincide for a root thread and diverge for
a subagent, which exports its own thread id, so an opt-out taken there names
a token the drop never matches while the control route still echoes
`ignored: true`. Step 1 said the stated id "needs no inference" with no grain
caveat; it now states the grain, names issue #453 as the correction, and tells
the operator not to treat such a confirmation as proven.
.claude/skills/ref-check/SKILL.md §3
- The slug rule is right about whitespace but silent on two details. Headings
that carry their own `<a id>` (9 in this corpus) must have the anchor tags
stripped before slugifying, and only those: `<target>` inside a code span is
text the renderer keeps. Repeated slugs take the renderer's `-1` suffix.
Verified against GitHub's markdown API over all 709 llp/ headings (718 ids):
the stated rule now reproduces every one exactly.
- Corrected the false-BROKEN attribution. Measured on this corpus: indexing
only heading slugs breaks 498 refs (229 `{#...}`, 269 inline `<a id>`);
collapsing whitespace adds 30. The anchor forms dominate, not the whitespace.
- Noted that `{#...}` is a corpus convention GitHub does not honor, so such an
anchor resolves for ref-check but does not navigate in a rendered view.
ai-gateway/src/types.d.ts
- The two remaining glossless `@ref`s in the file this PR was de-glossing
(`0066#control-path`, `0066#ephemeral`) now carry one, per LLP 0000.
npm test 2845 pass / 8 fail (the leave-command.test.js baseline) / 1 skipped;
typecheck clean; ref-check unchanged at 69 pre-existing broken, 0 for LLP 0086.
Co-Authored-By: Claude <noreply@anthropic.com>
* review round 2: the skill sends the session container, and {#slug} is not a heading-only form
Review of PR #456 (head 94fde3f). Round 1 verified, two actionable findings
fixed, both in surfaces this PR already rewrites.
codex/skills/hypaware-privacy/SKILL.md - Step 1 no longer sends a thread id.
Round 1 disclosed, in prose, that `CODEX_THREAD_ID` is a thread id while the
drop keys the session container (`codex/src/exchange-projector.js:98`), and left
the code path in place because #458 rewrites this block. The disclosure is not
sufficient: the thing that runs is the bash block, `ai-gateway/src/control.js`
sets `ignored = true` unconditionally on POST for whatever opaque token it was
handed, so the block prints `opt-out confirmed` and the review then discusses
the machine's most sensitive content believing it is not recorded. The prose
caveat gives an agent no verb with which to re-check. The block is also doomed
text: #458's version of Step 1 has no `CODEX_THREAD_ID` in it, so keeping the
risky form buys nothing that survives the rebase.
Step 1 now resolves the id from the rollout's `payload.session_id`, the
container the gateway matches, and refuses when a rollout predates it rather
than substituting `payload.id`. That is #458's decision, so this shrinks the
conflict instead of widening it. #452(a)'s win is untouched: cwd matching,
refusal on zero / ambiguous / stale, and the `INFERRED from <rollout>` label.
`CODEX_THREAD_ID` keeps a paragraph explaining why it is not the answer and
what #453 makes of it. The verification comment now states its own bound: a
true `ignored` proves the token is in the drop set, not that it is this
session's.
Verified with a fixture harness over the extracted block (fake CODEX_HOME, fake
gateway, stubbed curl for the default-port case): 12 cases pass, including a
subagent fixture where `CODEX_THREAD_ID` is exported and diverges from the
container - the container is what reaches the gateway and the thread id never
does. Round 1's `|| true` fix is intact: the documented
`http://127.0.0.1:8787` default is still reached with no config.toml.
test/plugins/codex-privacy-skill-session-id.test.js - new.
#452(a) and the grain rule had no ground-truth gate; the PR body says so. Pins
both decisions a later edit could silently reverse, in the manner of
test/plugins/query-skill-description.test.js. Fails on each of five mutations:
reverting to head's `CODEX_THREAD_ID` assignment, collecting `payload['id']`,
substituting the thread id when no container is recorded, restoring
newest-by-mtime, and dropping the staleness refusal.
.claude/skills/ref-check/SKILL.md §3 - `{#slug}` is not a heading-only form.
Form 2 was written as an explicit *heading* anchor. The corpus's usual
placement is a list item, one bullet per named proposition
(`- **Deadline rule** {#deadline}: ...`): 25 markers across 8 docs, and 47
references target them. A checker following §3 literally reports all 47 as
BROKEN, which is the same false-positive class this PR exists to remove, and it
is most of the "69 pre-existing broken" baseline. With `{#...}` indexed on any
line, 22 references are genuinely broken, so #457's count wants correcting.
Attribution numbers re-measured, since they were understated: heading-slugs-only
breaks 632 references (366 `{#...}`, 266 inline `<a id>`), not 498 (229/269).
Whitespace collapsing adds 30, as stated. The heading-count parenthetical said
709 headings / 718 ids; the corpus has 1098 / 1107 (709 is close to the count of
*distinct* heading texts). The slug rule itself is confirmed: rendered all 132
`llp/` files through GitHub's `POST /markdown` and diffed id-for-id, 1107 of
1107 exact. Round 1's two narrowings are confirmed necessary and sufficient -
stripping all inline HTML misses 4 ids, stripping none misses 9.
llp/0001 records the list-item placement alongside the heading form.
npm test 2848 pass / 8 fail (the leave-command.test.js baseline) / 1 skipped;
typecheck clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* review round 3: the ambiguity refusal survives a non-string session_id, and ref-check's counts are dated
Two low-severity fixes from the round-3 review of the merge head dc79fc5.
codex/skills/hypaware-privacy/SKILL.md: the ambiguity refusal built its
candidate list with `', '.join(m[0] or m[1] for m in matches)`. The merge folded
in master's non-string `payload.session_id` guard, but that guard runs only on
matches[0], after the ambiguity branch has already returned. So two rollouts
recording this cwd, one of them carrying a non-string session_id, raised
`TypeError: sequence item 0: expected str instance, int found` and printed a
Python traceback instead of "N rollouts record cwd X: ambiguous, confirm the
session id with the user". It failed closed (exit 1, no gateway call), but the
refusal text is what the agent relays to the user, and a traceback tells them
nothing to act on. `str(...)` around the element.
Verified by execution: extracted the Step 1 block verbatim and ran it against
fixture CODEX_HOME trees with a stub gateway. Before: traceback. After:
"2 rollouts record cwd /tmp/... (777, C2): ambiguous, confirm the session id
with the user", exit 1, 0 gateway calls. 20 cases total re-run, unchanged.
.claude/skills/ref-check/SKILL.md: section 3 presented "22 references are
genuinely broken" as "the number a run should reproduce". Those figures were
measured at 79d147c over 132 files / 129 LLP numbers; this head carries 149
files / 149 numbers, 1220 headings and ~1495 `@ref LLP` occurrences, because
master added LLPs 0157-0159 and others while this branch was open. A number
that drifts with the corpus reads as a regression signal when it is not. The
counts are now dated to the commit they were taken at, and the paragraph says
what a run must actually reproduce: the rule, and no BROKEN report for a
reference the three anchor forms resolve.
npm test 3041 tests, 3032 pass, 8 fail, 1 skipped - the 8 are the pre-existing
test/core/leave-command.test.js "leave ..." set, name-for-name identical to a
pristine origin/master worktree (3037 tests, same 8). npm run typecheck clean.
Co-Authored-By: Claude <noreply@anthropic.com>
* review round 4: the CODEX_THREAD_ID test comment states the landed present, and ref-check's snapshot note scopes itself
#453 landed on master via #458, so `hyp session ignore` already uses
CODEX_THREAD_ID as a selector. Round 3 corrected that tense in the skill's
Step 1 prose and left the same stale "until then" in the test that pins the
decision, which is the surface a later reader checks the rule against.
Also scopes round 3's snapshot note to the section it measured: the sample
report under "Report findings" carries illustrative counts that were never
measured at `79d147c`, so "every count on this page" overclaimed.
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-loop <neutral-loop@users.noreply.github.com>
Co-authored-by: test <test@test.com>
Co-authored-by: neutral <neutral@hyparam.dev>
Co-authored-by: test <test@example.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hyp session ignore from a Codex subagent thread opts out an id the drop never matches: silent no-op reported as success

1 participant

@philcunliffe