Skip to content

One reader for the Codex session_meta header, in core (#465) - #466

Merged
philcunliffe merged 5 commits into
masterfrom
fix/issue-465
Jul 30, 2026
Merged

One reader for the Codex session_meta header, in core (#465)#466
philcunliffe merged 5 commits into
masterfrom
fix/issue-465

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Root cause

Two modules read a Codex rollout's session_meta line and must apply the same
three rules, because both answers gate a privacy control and a wrong one is
silent:

  • hypaware-core/plugins-workspace/codex/src/rollout-cwd.js wants payload.cwd
    for the .hypignore match (LLP 0083).
  • hypaware-core/plugins-workspace/ai-gateway/src/session_command.js wants an
    id for hyp session (LLP 0066/0067).

Each held its own copy of the rules. The copies had already drifted into two
shipped defects (#453 stated a thread id the drop never matched; #459 resolved
the root's cwd for a subagent turn), and the drift was still live at HEAD:

  • readRolloutMeta (gateway) had no type === 'session_meta' guard, so a
    rollout whose first line is any other record carrying payload.id and
    payload.cwd (a turn_context does) resolved a confident id belonging to no
    session. hyp session ignore would then report success for a drop that drops
    nothing.
  • readRolloutCwd (codex) had the envelope guard but used stringValue, which
    accepts a whitespace-only cwd and hands it to the policy matcher as a path.

Patching both copies again would restore agreement without removing the reason
they disagree, so the fix removes the second copy instead.

Fix

src/core/codex/rollout_session_meta.js is now the only reader of that line.
parseRolloutSessionMeta / readRolloutSessionMeta return
{ threadId, sessionId, cwd } and state the three rules once:

  1. the raw JSONL line is the input, never a deserialized struct (Codex's
    hand-written Deserialize back-fills session_id from id),
  2. type must be session_meta,
  3. absent, non-string, and blank all read as undefined, and sessionId is
    never derived from threadId.

The bounded first-line read (line 1 only, 64 KiB prefix, LLP 0049 R6) moves in
with the parse rather than staying duplicated at each caller. Both callers now
delegate; neither keeps a predicate of its own.

It lives in core, not in @hypaware/codex: the gateway plugin cannot reach into
another plugin's private src/ modules, and single-caller ownership is exactly
what makes drift cheap. That is LLP 0003's rule (a behavior otherwise
copy-pasted into every plugin belongs in core) and follows the partition-spec
helpers' promotion to a neutral core home (LLP 0022). LLP 0143 records the
decision, the placement question the issue asked to answer, and the
consequences.

sessionId is exposed with no consumer on purpose: rule 1 exists for it, so
leaving it unread is what let the "never back-filled" property go unstated.
Which id each caller uses is unchanged here; that stays with #453/#459.

Reproducing tests

Both callers get a test that fails on the current code:

  • test/plugins/ai-gateway-session-status.test.js::a first line that is not a session_meta record resolves nothing, however much it looks like one -
    a rollout whose first line is a turn_context carrying payload.id and a
    matching payload.cwd. Asserts resolveSessionIdForCli refuses and never
    names the bogus id. Before: not ok (it resolved not-the-session-id).
    After: ok.
  • test/plugins/codex-rollout-cwd.test.js::a blank session_meta.cwd is no cwd, not a blank path handed to the policy matcher. Before: not ok (the
    resolver returned ' '). After: ok.

test/core/codex-rollout-session-meta.test.js is the union suite the issue's
ground-truth gate asks for, 13 tests over both callers' failure paths: legacy
rollout with no session_id (refuse, never back-fill), subagent rollout
(container and thread stay apart), four wrong envelope types plus
missing/blank/non-string type, blank and non-string fields, no payload,
byte-identical passthrough of a field that survives the blank test, first line
only, an over-long first line, and unreadable/empty/absent files. Every guard
was mutation-checked: each of the 8 mutations tried (drop the envelope guard,
back-fill sessionId from id, drop the trim, drop the string check, drop the
object gate, read the whole file instead of line 1, shrink the prefix, read
threadId off session_id) reddens a specific named test.

Verification

  • npm test: 2895 tests, 2886 pass, 8 fail. The 8 failures are
    test/core/leave-command.test.js, pre-existing and unrelated (identical set on
    unmodified origin/master); no new failures, +16 tests.
  • npm run typecheck (tsc -p tsconfig.json --noEmit): clean.
  • Hermetic smokes backfill_codex_fixture, gateway_codex_capture,
    session_optout_capture_drop: all ok.

Note for the reviewer

The issue asked for this after #458 landed, to avoid conflicting with a held PR.
#458 is still open, so this touches session_command.js (the file #458 rewrites)
and rollout-cwd.js (which #462 also touches). The conflicts are confined to
those two seams and both are small: each caller is now a two-line delegation, and
#458's move onto the session container becomes meta.sessionId at its seam
rather than a fourth rewrite of the reader. No existing LLP text was edited, so
there are no doc conflicts.

Fixes #465

Two plugins read a Codex rollout's first line to gate a privacy control:
`@hypaware/codex`'s live cwd resolver wants `payload.cwd` for the
`.hypignore` match (LLP 0083), and `hyp session` wants an id for the
session opt-out (LLP 0066/0067). Each held its own copy of the rules, and
the copies drifted twice, both times as a silent wrong answer: #453 named
a thread id the drop never matched, #459 resolved the root's cwd for a
subagent turn.

The drift was live at HEAD. `readRolloutMeta` in session_command.js had no
`type === 'session_meta'` guard, so a rollout whose first line is any other
record carrying `payload.id`/`payload.cwd` (a `turn_context` does) resolved
a confident id belonging to no session. The codex side had the guard but
accepted a whitespace-only `cwd` as a path and handed it to the policy
matcher.

`src/core/codex/rollout_session_meta.js` is now the only reader. It states
the three rules once (parse the raw JSONL line, require the session_meta
envelope, treat absent/non-string/blank as unresolvable and never back-fill
`sessionId` from `threadId`) and owns the bounded first-line read both
callers were duplicating. Core, not either plugin: the gateway cannot reach
into codex's private modules, and single-caller ownership is what makes
drift cheap. LLP 0143 records the decision and the placement.

`sessionId` has no consumer yet by design: rule 1 exists for it, so leaving
it unread is what let the property go unstated. Which id each caller uses
is unchanged here.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
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>
…ot Config

`Systems` is the discovery key: CLAUDE.md tells an engineer to read the LLP
tagged with a subsystem's `Systems` value before changing it. The reader lives
in `src/core/codex/` and exists to keep the `.hypignore` usage-policy control
from being evaluated against the wrong directory, so `Core` and `Usage-Policy`
are the tags a future change to either would search on. `Config` is not
implicated: nothing here parses or validates config.

Review round 1, minor: metadata only, no text or code change.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with findings (3 findings, 1 fixed and pushed, 2 deliberately left)

The PR does what #465 asked. The invariant is single-sourced for the header
read the issue named, the regression gate is real (I re-ran all 8 claimed
mutations, not a spot-check of 3, and every one reddens a specific named test),
placement matches the documented layering, and the two reproducing tests do
fail on the pre-fix callers. Nothing here blocks merge. The two findings I left
are pre-existing gaps in the same defect family, not regressions, and one of
them needs a human design call.

Reviewed at 1b826ba8. I then pushed one commit, 438cad9, containing only the
Low finding below (a single metadata line in LLP 0143, no code, no doc text).


1. Is the invariant actually single-sourced?

Yes, for the session_meta header read. Both original callers are now pure
two-line delegations with no predicate of their own:

  • hypaware-core/plugins-workspace/codex/src/rollout-cwd.js:89 -
    return readRolloutSessionMeta(rolloutPath)?.cwd. The local
    FIRST_LINE_MAX_BYTES, readFirstLine, and the
    isPlainObject/parseMaybeJson/stringValue predicate chain are all gone,
    and the hypaware/core/util import with them.
  • hypaware-core/plugins-workspace/ai-gateway/src/session_command.js:646-651 -
    readRolloutMeta is now 3 statements over the shared reader. The whole
    hand-rolled openSync/readSync/JSON.parse/type-check body is deleted.

I swept for a third copy and found none:

  • grep -rn session_meta over all non-test JS: the only remaining
    non-delegating reader is codex/src/backfill.js (see finding 3), which is a
    different shape, not a copy.
  • grep for payload.id / payload.cwd / payload.session_id: the only hits
    in the header-reading family are the three field reads inside
    src/core/codex/rollout_session_meta.js:79-81. The other hits
    (exchange-projector.js:498, response-items.js:56,72) read a
    response_item payload's own id, an unrelated field.
  • grep for the bounded-first-line idiom (indexOf('\n') over a fixed
    prefix): one hit in the new module,
    claude/src/session_context.js:186 (Claude transcripts, a different format),
    and two streaming readers. No surviving second rollout-header prefix read.

Also worth recording: the reader is imported by plugins, never the reverse, so
this creates no core-to-plugin coupling.

2. Privacy: do the guards hold?

I checked each case you asked about, by test and by direct execution.

case holds? evidence
absent type yes rollout_session_meta.js:73; test a missing, blank, or non-string envelope type is not session_meta either
wrong type yes same guard; test another envelope type carrying id/session_id/cwd is not the header covers turn_context, response_item, event_msg, compacted, plus session_meta_v2
absent cwd yes metaField at :135-137; test a blank or non-string field is absent, never an empty value passed on
blank / whitespace cwd yes value.trim().length > 0 at :136; test a blank session_meta.cwd is no cwd, not a blank path handed to the policy matcher
absent / non-object payload yes :77 narrows, optional chaining at :79-81; test a session_meta header with no payload resolves no field
malformed JSON yes :71-72; tests a line that is not JSON at all resolves nothing and a JSON line that is not an object resolves nothing
relative cwd no finding 2 below

Two good properties I want to call out because they are easy to lose later:
metaField returns the surviving value byte-identical (the trim is only the
emptiness test), pinned by a field that survives the blank test is returned byte-identical; and a first line longer than the 64 KiB bound refuses rather
than parsing half a record, pinned by its own test.

3. Is the regression gate real?

Yes. I mutated the guards in a worktree at 1b826ba8 and ran
test/core/codex-rollout-session-meta.test.js plus both callers' suites
(63 tests, green unmutated). All 8 mutations the PR body claims redden a named
test. Restored and re-verified green after each.

mutation reddens
drop the type === 'session_meta' guard another envelope type carrying id/session_id/cwd is not the header; a first line that is not a session_meta record resolves nothing, however much it looks like one; a rollout whose first line is a different envelope type yields no cwd, even carrying one
back-fill sessionId from id a legacy rollout with no session_id reports none: the thread id is never back-filled
drop the trim a blank or non-string field is absent...; a blank session_meta.cwd is no cwd, not a blank path handed to the policy matcher
drop the string check a blank or non-string field is absent, never an empty value passed on
drop the isPlainObject gate a line that is not JSON at all resolves nothing; a JSON line that is not an object resolves nothing; an unreadable, empty, or absent rollout resolves nothing
read the whole file, not line 1 readRolloutSessionMeta reads the first line and ignores the rest of the rollout + 10 caller tests
shrink the prefix to 64 bytes a rollout with no trailing newline is still one whole first line + 15 more
read threadId off session_id a subagent rollout keeps the container and the thread apart + 13 more

Separately, I confirmed the two reproducing tests are genuine: with only the
two caller files reverted to origin/master and the new tests left in place,
exactly the two claimed tests fail (a first line that is not a session_meta record resolves nothing... and a blank session_meta.cwd is no cwd...), 48
pass / 2 fail. Restored: 50/50.

4. Placement

Correct, and it does not invent a new coupling.

  • LLP 0003 "Principle" says a behavior that would otherwise be copy-pasted
    into every plugin belongs in core. Applies directly.
  • The precedent LLP 0143 cites is real: LLP 0022 "Shared core helpers", and
    src/core/backfill/scan_util.js is in fact already imported by three plugins
    by the same relative path.
  • The ../../../../src/core/... import style is pre-existing convention, not
    new: 5 such imports already in codex/src/, 10 in ai-gateway/src/.
  • src/core/codex/types.d.ts sits alongside 16 other src/core/*/types.d.ts.
  • LLP 0143 is honest about the cost (a client's on-disk format now lives in
    core) rather than eliding it, and 0143 is the next free number.

5. Conventions and tooling

All clean:

  • No semicolons; no @typedef; no inline import() types; JSDoc types only.
  • No em dashes on any line this PR adds (verified over the added-lines diff).
    The 9 in rollout-cwd.js and 6 in codex-rollout-cwd.test.js are all
    pre-existing context lines; see "left alone" below.
  • Type-import specifier is root-anchored with a .js extension:
    from '../../../src/core/codex/types.js' at rollout_session_meta.js:8.
    npm run build:types emits types/core/codex/rollout_session_meta.d.ts and
    the specifier still resolves from there (src/ is in package.json files).
  • Every new @ref resolves: 4 to LLP 0143 (exists, Status: Active) and one
    to LLP 0049#requirements, which is a real explicit {#requirements} anchor
    in an Accepted doc. Relations used are all standard (implements,
    constrained-by, tests), each with a gloss. The file-header and inline
    parenthetical (@ref ...) placements both have wide precedent in this repo.
  • npm run typecheck clean; npm run build:types clean; npm test 2895 tests,
    2886 pass, 8 fail, and the 8 are exactly the known-unrelated
    test/core/leave-command.test.js set.

Findings

1. LLP 0143:5 - Low - Systems mistags the subsystems it governs. FIXED.

**Systems:** Plugins, Gateway, Sources, Config. Systems is the discovery
key: CLAUDE.md tells an engineer to read the LLP tagged with a subsystem's
Systems value before changing that subsystem. Config is not implicated
here (nothing in this change parses or validates config), while the two tags
that would actually surface this doc to the engineer most likely to break it are
missing: the module lives in src/core/ (Core) and exists to keep the
.hypignore control from being evaluated against the wrong directory
(Usage-Policy, an established tag used by 17 other docs).

Fixed in 438cad9: now Core, Plugins, Gateway, Sources, Usage-Policy.
Metadata line only, no text or code change, so it cannot deepen the #458/#462
conflicts. Verified landed with
git diff 1b826ba8..438cad9 -- llp/0143-one-reader-for-codex-session-meta.decision.md
(one line, that line) and by reading the line back out of the committed tree.

2. src/core/codex/rollout_session_meta.js:136 - Low - a relative cwd passes rule 3 and reaches the policy matcher as a path. NOT FIXED, needs a human decision.

metaField accepts any non-blank string, so a session_meta whose cwd is
relative is returned byte-identical, and
codex/src/rollout-cwd.js:89 hands it straight on. The matcher then does
path.resolve(cwd) at src/core/usage-policy/matcher.js:109, resolving it
against the daemon's process cwd. Demonstrated, not inferred:

reader cwd for a RELATIVE path: "../elsewhere"
rollout-cwd resolver returns:  "../elsewhere"
policy verdict: {"class":"ignore","governedBy":"/tmp/daemonhome-XXXX/elsewhere/.hypignore","declared":"ignore"}

That is a .hypignore verdict governed by a file in a directory the session
never named, which is the same shape as #459. The gateway caller is not
exposed: session_command.js:524 does if (meta.cwd !== args.cwd) continue
against an absolute invocation cwd, so a relative value simply never matches
and the verb refuses.

Why I left it. It is not a regression: origin/master's stringValue had
identical latitude, and it is not one of the three rules #465 enumerated. More
importantly, closing it is a design choice with a privacy cost in both
directions, which is a human call and not a reviewer's: refusing a non-absolute
cwd makes .hypignore fail open for that session (the projector records
cwd = NULL), while accepting it yields a confident verdict about the wrong
directory. Refusing looks better to me, because a defined fail-open is what
rollout-cwd.js already documents for "no cwd", but it adds a fourth rule and
so needs LLP 0143 text, a test, and a decision recorded, not a quiet
one-liner slipped into review. Suggest a follow-up issue rather than growing
this PR, which already conflicts with #458 and #462 at both seams.

3. codex/src/backfill.js:530 - Low - the third session_meta reader still applies the looser blank test to a cwd that gates the same control. NOT FIXED, follow-up.

cwd: firstString(stringValue(metaPayload.cwd), firstTurnString(turnPayloads, 'cwd')),
and that session.cwd feeds the same privacy gate at
backfill.js:240 (resolver.resolve(session.cwd)). stringValue
(src/core/util/json_util.js:25-27) accepts a whitespace-only string, so a
session_meta.cwd of ' ' reaches path.resolve and is evaluated as a path,
which is precisely the defect this PR fixes on the live path.

To be clear about scope, this is not a fourth copy of the header reader and
the PR is not wrong to leave it: parseJsonlRollout reads the whole file and
folds turn_context records, with deliberate documented fallbacks (path-derived
session id, turn_context.cwd), so it cannot delegate to a first-line reader.
It never reads session_id at all. But LLP 0143's consequence bullet says
"Blank now means blank-after-trim at both callers", and there is a third site
where it does not. Same follow-up as finding 2; the honest fix is one shared
non-blank string helper used at all three.

Deliberately left alone

neutral-reconciler and others added 2 commits July 30, 2026 01:08
…#465 review round 2)

Two review findings from round 1, both on the `cwd` that gates `.hypignore`:

1. `src/core/codex/rollout_session_meta.js` accepted any non-blank string as a
   `cwd`, so a relative one reached the matcher, whose first act is
   `path.resolve(cwd)`. That silently supplies the *daemon's* process cwd as the
   base, so a header saying `../elsewhere` produced a confident `.hypignore`
   verdict governed by a file under wherever the daemon was started. Same defect
   shape as #459, reached through a different field.

2. The codex backfill still used the looser `stringValue` for `session_meta.cwd`
   and its `turn_context.cwd` fallback, and feeds them to the same gate, so
   LLP 0143's "blank means blank-after-trim" held at two of three sites. A
   whitespace-only cwd reached `path.resolve` and was stamped on the row.

Rule 3 (unconfirmable is unresolvable) now covers both for `cwd`:
`sessionMetaCwd` requires a non-blank *absolute* path, byte-identical, else
`undefined`. It is exported because the backfill reads whole rollout files and
folds `turn_context`, so it cannot delegate to the first-line reader, but it can
and now does share the one predicate. Only `cwd` is path-tested; the ids stay
opaque provider tokens (LLP 0066 R5).

Refusing is not a symmetric trade, which is why this is a rule and not a
judgement call. Refusing costs the ordinary "no cwd" fail-open (`cwd = NULL`),
a state the system already models (LLP 0049 R1 as extended by LLP 0085) and
already the documented answer for an absent rollout (LLP 0083). Accepting is
wrong in both directions at once: it can drop a session nothing covers, and it
can record a session whose real directory IS ignored, because the verdict was
computed for another directory. The repo already applies this rule to the
sibling case in LLP 0045 (a relative `settings_file` is refused, not returned
for later re-resolution against `process.cwd()`). The rollout's own location
cannot supply the missing base either: rollouts live under
`<CODEX_HOME>/sessions/YYYY/MM/DD/`, unrelated to where the session ran.

No legitimate rollout loses its cwd: Codex writes it from the process working
directory, always absolute. The `hyp session` caller is unaffected; it compares
against an absolute invocation cwd, so a relative value never matched.

LLP 0143 gains the rule as `#usable-cwd`, and its consequence bullet now says
"every site that feeds the gate" instead of "both callers". The projector's
in-band cwd (LLP 0083's fast path) is called out there as still outstanding: a
different source, and its value is also stamped for workspace/git enrichment
rather than only consulted for the gate.

Tests, each verified to redden on the matching mutation:
- reverting `sessionMetaCwd` to `metaField` reddens 4 named tests
- reverting the backfill to `stringValue`/`firstTurnString` reddens
  `a blank or relative rollout cwd never reaches the usage-policy gate`

Co-Authored-By: Claude <noreply@anthropic.com>
The interface is the contract consumers read; the absoluteness rule belongs
in it, not only in the reader that enforces it.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Verdict: approve with findings. Round 2 of 2 (final review round).

Reviewed at 438cad9e453f210f24c4079508326965fde9f52c, the head this round was
assigned. Both findings round 1 left open turned out to be fixable on defensible
documented grounds, so I fixed both, with tests and an LLP 0143 rule, and pushed
two commits to fix/issue-465:

  • 19078c8 - the fixes (code + tests + LLP 0143 #usable-cwd).
  • 1f7fdac - the same rule stated on the CodexRolloutSessionMeta interface.

One finding is left, and it is a new one I turned up while settling finding 1.
It needs a human decision, stated precisely below.


Round 1's two open findings

1. src/core/codex/rollout_session_meta.js - a relative cwd reached the policy matcher. FIXED. Low (reachability), high blast radius.

Round 1 declined this as "a design choice with a privacy cost in both
directions, which is a human call". I reached the opposite conclusion, and the
reason is that the two directions are not symmetric. Setting out the options
was what settled it:

option verdict
accept the relative value wrong in both directions at once. It can drop a session no .hypignore covers (data lost), and - the failure the control exists to prevent - it can record a session whose real directory is ignored, because the verdict was computed for a different directory. It also stamps a non-null bogus cwd on the row, so the value looks like a real answer to everything downstream that keys on cwd.
resolve against the rollout file's own directory eliminated, not deferred. Rollouts live under <CODEX_HOME>/sessions/YYYY/MM/DD/, which has no relationship to where the session ran, so this is a second wrong base rather than the missing right one.
refuse (chosen) costs the ordinary "no cwd" fail-open: cwd = NULL, the projector's if (cwd) skips the check. That is a state the system already models and compensates for (LLP 0049 R1 as extended by LLP 0085) and is already the documented answer for an absent or unreadable rollout (LLP 0083).

Two further things make this a rule rather than a judgement call:

  • It is rule 3, not a fourth rule. Rule 3 is "unconfirmable is
    unresolvable... A caller that needs it must refuse; none of them may guess."
    Nothing on the line says what a relative cwd is relative to, so honoring
    it means guessing a base, and path.resolve guesses the daemon's process
    cwd. The rule already forbade this; the reader just was not enforcing it for
    cwd.
  • The repo already decided the sibling case. LLP 0045 refuses a relative
    client settings_file rather than returning it, in as many words: handing it
    back "would return something re-resolved against process.cwd() at read time
    instead of the value validated at call time". claude/src/hook_command.js:128
    is the same shape in code (path.isAbsolute or the value is dropped).

And nothing legitimate loses coverage: Codex writes session_meta.cwd from its
own process working directory, which is always absolute, and the hyp session
caller compares meta.cwd against an absolute invocation cwd
(session_command.js:524), so a relative value never matched there anyway -
refusing only makes that refusal explicit and earlier.

The fix. sessionMetaCwd (rollout_session_meta.js:176) is now the one
cwd predicate: non-blank and absolute, byte-identical, else undefined.
Only cwd is path-tested; threadId/sessionId stay opaque provider tokens
(LLP 0066 R5) and a uuid is not an absolute path, so path-testing them would
refuse every real header. LLP 0143 #usable-cwd records the rule and the
options table above.

Verified, by execution both ways. Before, with a .hypignore planted where
the relative path lands from the process cwd:

reader cwd          : "../elsewhere"
rollout-cwd resolver: "../elsewhere"
policy verdict      : {"class":"ignore","governedBy":"/tmp/daemonhome-XXXXXX/elsewhere/.hypignore",...}

After: undefined / undefined, and the projector's if (cwd) takes the
documented cwd = NULL path.

New tests, and each fails without the fix (reverting sessionMetaCwd to the
old metaField reddens 4 named tests, checked by mutation, not assumed):

  • test/core/codex-rollout-session-meta.test.js::a relative session_meta.cwd is no cwd, not a path resolved against the daemon (../elsewhere, ./repo,
    repo, repo/sub, ' /repo', and the id on the same line unaffected)
  • ...::an absolute cwd still resolves, and the ids are never path-tested
  • ...::sessionMetaCwd is the one cwd predicate, usable by a caller that cannot delegate
  • test/plugins/codex-rollout-cwd.test.js::a relative session_meta.cwd is no cwd: the matcher would resolve it against the daemon - the seam test, which
    also asserts the wrong-directory verdict is real rather than hypothetical, so
    the test cannot pass vacuously.

2. hypaware-core/plugins-workspace/codex/src/backfill.js:541 - the third reader's cwd predicate. FIXED. Low.

Round 1 asked whether the looser predicate can actually admit a bad cwd into
the gate. It can. Demonstrated, not inferred
- a rollout whose
session_meta.cwd is ' ', run through the real provider with a spying
resolver:

cwds handed to the usage-policy resolver: ["   "]
row cwd stamped:                          ["   "]

path.resolve(' ') is <process.cwd()>/ , so the ancestor walk starts one
level below the invoking directory and hits any .hypignore at or above it.
Both directions again: a false ignore for a session with no real cwd, and a
false full for a session whose true directory is ignored. A relative
metaPayload.cwd does the same, resolved against wherever hyp backfill was
invoked.

Round 1 was right that backfill cannot delegate to the reader (parseRolloutFile
reads whole files and folds turn_context, with documented fallbacks). So it
keeps its own file walk and now shares the one predicate:

cwd: firstString(sessionMetaCwd(metaPayload.cwd), firstTurnCwd(turnPayloads)),

Note the turn_context.cwd fallback is covered too (firstTurnCwd), which
round 1's write-up did not mention: without it the fallback would smuggle back in
exactly what the session_meta branch refuses. LLP 0143's consequence bullet now
reads "every site that feeds the gate" rather than "both callers", which is the
claim that is actually true.

Verified: reverting only this line to stringValue(...) /
firstTurnString(..., 'cwd') reddens exactly
test/plugins/codex-backfill.test.js::a blank or relative rollout cwd never reaches the usage-policy gate, which asserts the resolver was asked nothing
(asked is empty), that the session still backfills, and that the row records no
cwd rather than a bogus one.


New finding

3. hypaware-core/plugins-workspace/codex/src/exchange-projector.js:120 - the live projector's in-band cwd has the same latitude. NOT FIXED. Low. Needs a human decision.

const cwd = firstString(codexContext?.cwd, readRecordedCwd(reqBody))
  ?? (codexContext?.session_id ? rolloutCwd?.resolve(codexContext.session_id) : undefined)
if (cwd) { const policy = resolver.resolve(cwd) ... }

The third branch is now predicated. The first two are not: a blank-ish or
relative cwd arriving in x-codex-turn-metadata or the request body
(LLP 0083's fast path) reaches the identical resolver.resolve at line 123 and
the identical path.resolve. Same defect, different source.

I did not fix it, deliberately, and this is the decision to make:

The decision: either (a) apply sessionMetaCwd to the in-band branches too,
accepting that a client sending a relative cwd loses enrichment as well as the
gate, or (b) leave in-band as-is on the grounds that a client volunteering a bad
cwd is the client's defect and the enrichment value should be preserved verbatim.
I lean (a) for the gate specifically, because the argument in finding 1 does not
depend on where the string came from - but it is a projection change and belongs
in its own issue, not slipped into a review round. It is recorded as still
outstanding
in LLP 0143's consequences so it cannot be lost.


Round 1's own fix: re-verified, no regression

  • llp/0143-...:5 still reads **Systems:** Core, Plugins, Gateway, Sources, Usage-Policy. Round 1's 438cad9 is intact and my commits did not touch that
    line.
  • No @ref broke. All 9 @ref LLP 0143 sites resolve, including the 4 new
    #usable-cwd ones against the new {#usable-cwd} anchor. Every ./NNNN-*.md
    link in LLP 0143 resolves to a real file, and every explicit {#anchor} it
    targets exists. I did fix one broken link I introduced mid-edit
    (](#requirements) pointing at LLP 0143 itself rather than at LLP 0049);
    it is correct in what was pushed.
  • Round 1's two reproducing tests and all 8 mutation results were re-checked as
    part of running the suites above; nothing regressed.

Checks

  • npm test: 2900 tests, 2891 pass, 8 fail, 1 skipped. The 8 are exactly the
    known-unrelated test/core/leave-command.test.js set; +5 tests, +5 passing,
    no new failures. CI green on both pushed commits, 19078c8 and the new head 1f7fdac (test 22/24, typecheck 22/24, 8/8 pass).
  • npm run typecheck clean; npm run build:types clean.
  • Hermetic smokes backfill_codex_fixture, gateway_codex_capture,
    session_optout_capture_drop: all ok.
  • CLAUDE.md on everything I touched: no semicolons, no em dashes on any added
    line (checked over the added-lines diff), no @typedef, no inline import()
    types, JSDoc only. sessionMetaCwd's @ref carries a relation and a gloss.

Deliberately left alone (unchanged from round 1, still correct)

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Neutral triage (rung: triage, LLP 0017) at head 1f7fdac.

The 2-round review budget was exhausted before round 2's two fix commits
(19078c8, 1f7fdac) were ever seen by a review pass — the round that
requested them spent the budget requesting them. The ladder routes this head
to triage, not back to review, so no human or review agent has read
those two commits
. I read and verified them myself as the terminal gate in
place of a review round, and I judged this whole head (not only the
unreviewed delta) for ship-safety. Findings below.

Round 2's fix, verified

Round 2 made sessionMetaCwd (in src/core/codex/rollout_session_meta.js)
require a rollout-stated cwd to be non-blank and absolute before it
reaches the .hypignore gate, shared by the codex backfill's session.cwd
and turn_context.cwd fallback, and documented the rule as LLP 0143
#usable-cwd.

  • Citations check out. LLP 0049 R1 (extended by LLP 0085) and LLP 0083
    do establish "cwd unknown → cwd = NULL → fail open" as an already-modeled,
    already-accepted state — LLP 0083's own rollout-cwd.js docstring already
    says a genuinely absent rollout "fail[s] open... matching the nullable cwd
    column," predating this PR. Treating an unconfirmable (relative) cwd the
    same way is a narrow, well-precedented extension of that existing rule, not
    a novel judgement call dressed as a citation. LLP 0045's cited sibling rule
    (refuse a relative settings_file rather than re-resolve it against
    process.cwd() later) is real and says what the commit claims.
  • What cwd = NULL does at the gate: RECORDED (fail open), not dropped.
    Confirmed directly in code at both sites that feed the gate:
    hypaware-core/plugins-workspace/codex/src/exchange-projector.js:122
    (if (cwd) { ... } — falsy cwd skips the check entirely, row proceeds to
    be written) and hypaware-core/plugins-workspace/codex/src/backfill.js:241
    (session.cwd ? resolver.resolve(session.cwd) : null — same skip). So
    refusing a relative cwd does convert that specific row into a recorded one.
    But the alternative round 1 shipped (accept and let path.resolve silently
    substitute the daemon's cwd) was not a safer baseline for the
    should-be-ignored case: it resolves to an unrelated directory, so it
    protects the real target only by coincidence, while also being able to
    wrongly drop unrelated legitimate sessions and stamping a bogus non-null
    cwd that poisons downstream folder/git attribution. Refuse-and-NULL
    strictly dominates accept-and-guess: same fail-open exposure for the
    should-be-ignored case, minus the false-drop failure mode and minus the
    bogus stamped value. And round 2's factual premise — Codex always writes
    session_meta.cwd from its own absolute process.cwd(), so a relative
    value is not a real-traffic shape — is consistent with existing code
    comments predating this PR and is the reason this is a defensive rule for a
    near-nonexistent input shape, not a live-traffic tradeoff.
  • Backfill predicate tightening: no under-capture found. path.isAbsolute
    correctly accepts any real POSIX absolute path (including unusual-but-legal
    ones); only genuinely relative or blank/whitespace-led values are refused.
    Full suite passes unchanged (2907/2915, only the 8 pre-existing
    leave-command.test.js failures, unrelated — ERR_MODULE_NOT_FOUND from a
    bare worktree with no node_modules).
  • Tests are load-bearing. Reverted sessionMetaCwd and the backfill's use
    of it back to round 1's shape and reran the suite: all 5 new tests reddened
    (3 folded into one file-load failure in
    test/core/codex-rollout-session-meta.test.js from the now-missing
    sessionMetaCwd export, plus 2 named failures —
    a blank or relative rollout cwd never reaches the usage-policy gate and
    a relative session_meta.cwd is no cwd: the matcher would resolve it against the daemon).

Verdict: round 2's fix is sound. Not a blocker.

Residual finding, assessed

LLP 0143 documents one gap on purpose: the live projector's in-band cwd
(codexContext?.cwd / readRecordedCwd(reqBody),
hypaware-core/plugins-workspace/codex/src/exchange-projector.js:120) is
never passed through sessionMetaCwd or any absoluteness check, and it does
reach the actual policy gate at exchange-projector.js:122-123
(if (cwd) { const policy = resolver.resolve(cwd); ... }) — not only
row-content enrichment (it also feeds resolveRecordedContext at line 164,
so it does double duty). Same relative-cwd-resolves-against-daemon-cwd hazard
as the one just fixed, reached through a different, client-supplied source.

This code is unmodified by PR #466 (git diff across the whole PR range
for exchange-projector.js is empty) and is already live on master today,
independent of whether this PR merges. Filed as
#471 for tracking; not gating this
PR, since merging or not merging it has no effect on that pre-existing
exposure either way.

Verdict: non-blocking, tracked separately.

Outcome

Everything found is non-blocking. Follow-up: #471.

@philcunliffe
philcunliffe marked this pull request as ready for review July 30, 2026 01:54
@philcunliffe philcunliffe 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
Contributor Author

🤖 neutral: the "strictly dominates" argument that cleared this PR has a documented exception

This PR is still held with neutral:approved and nothing about its head has changed. This comment does not withdraw that. But the reasoning neutral used to clear it has since been shown incomplete, and you should have that before you merge.

What the triage argued

This PR's triage cleared the sessionMetaCwd predicate (refuse a non-blank, non-absolute cwd) on the grounds that refuse-and-NULL strictly dominates accept-and-guess: same fail-open exposure for the should-be-ignored case, minus the false-drop mode, minus the bogus stamped cwd. On that basis the residual fail-open was judged acceptable.

The exception, found while reviewing PR #474

PR #474 applies the same predicate to the in-band cwd path, and its review found a case where the old accept-and-guess behaviour was not worse:

When the daemon's own process cwd happens to sit under an ignoring .hypignore, the old path.resolve guess reached the right verdict by accident, and refusing now records instead.

That is not hypothetical. No installer renders a working directory, so the daemon's cwd is $HOME for a systemd --user unit, or the shell's directory for a foreground start. A user whose $HOME (or launch directory) is covered by an ignoring .hypignore was, by accident, getting sessions with unusable cwds dropped. After this change and #474, those sessions are recorded.

So "strictly dominates" is wrong as stated. The accurate claim is: better in every case but one, and the exception is a narrow, accidental loss of coverage rather than a wrong verdict.

Why neutral is not withdrawing the approval

  • The exception is an accident of the daemon's launch directory, not a designed guarantee anyone was relying on.
  • The alternative it replaces was genuinely worse in the common case: a verdict computed against the wrong directory plus a bogus stamped container poisoning folder and git attribution.
  • The predicate itself is correct. What is at issue is only whether an unconfirmable cwd should be recorded (today's precedent, LLP 0049 R1 as extended by LLP 0085) or dropped.

Mechanically the label also still holds: neutral:approved tracks the current reviewed-clean head (LLP 0030), and this head is unchanged. Stripping it on the strength of a comment would be unfounded churn.

What this means for you

This PR and #474 together encode a deliberate choice: an unusable cwd fails open. If that matches your intent, merge as-is; the coverage exception above is the price and it is now written down. If you would rather an unresolvable cwd fail closed, neither PR delivers that, and the cwd = NULL precedent they both cite would need revisiting as its own decision.

PR #474's body has been corrected to state the exception accurately. This comment corrects the record here.

philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
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>
@philcunliffe philcunliffe removed the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label 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

Copy link
Copy Markdown
Contributor Author

Triage rung (LLP 0017), head 81f9a7f8.

This PR was approved at an earlier head (1f7fdacf). Since then master moved
under it (#475, #458, #461, #468 all merged), and a neutral worker resolved
the resulting conflict in a large merge commit that no human or review-rung
worker has read
(81f9a7f, second parent 183d38b). The review budget was
already spent, so this triage is the only gate that commit passes through. I
read and executed the whole result rather than trusting the merge commit's
own claims about itself. Per-claim verdict:

  1. The conflict (readRolloutMeta in session_command.js /
    test/plugins/ai-gateway-session-status.test.js): confirmed this PR's
    two-line delegation to src/core/codex/rollout_session_meta.js survives
    and all three of hyp session ignore names the session container Codex drops on, not a thread id (#453) #458's merged behaviours survive, each with a
    dedicated, passing test:

    • the container (session_id), never the thread, is the answer -
      'a Codex SUBAGENT thread resolves the session container, and that is the id the gateway actually drops' (line 444)
    • CODEX_THREAD_ID is a selector, not an answer -
      'CODEX_THREAD_ID selects the live rollout without the mtime proxy, then the container is read from it' (line 559)
    • a legacy or blank session_id refuses rather than falling back to the
      thread id - 'a legacy rollout with no session_id field REFUSES...'
      (line 498) and 'a BLANK session_id is as unusable as an absent one...' (line 601)
      No regression here. PREFERENCE-tier only: none found.
  2. cwd: undefined passthrough: verified the argument holds. The
    cwd-matching path (resolveSessionIdForCli) excludes an unusable cwd via
    if (meta.cwd !== args.cwd) continue (args.cwd is always a string), so
    undefined never matches and never reaches resolver.resolve. The
    stated-thread path (resolveFromStatedThread) never reads meta.cwd at
    all. The pinning test
    ('a stated thread still resolves from a rollout whose cwd is unusable: that path never reads cwd', line 685) is load-bearing: it asserts BOTH
    that the stated-thread path succeeds on a blank/relative/empty cwd AND
    that the cwd-matching path refuses on that same rollout with no env
    stated. Ran it directly (node --test), passes.

  3. Four corrections: checked each against the current tree.
    sessionId's "no consumer yet" comment is gone from readRolloutMeta
    and LLP 0150 now states it gained a consumer when hyp session ignore names the session container Codex drops on, not a thread id (#453) #458 landed
    mid-review. The cwd-comparison claim is now correctly scoped to "the
    cwd-matching path" only, not both paths (LLP 0150 #usable-cwd,
    "Its CODEX_THREAD_ID path never consults cwd at all"). Rule 3 no
    longer reads as a blanket "callers refuse" - LLP 0150 states "a caller
    then decides what an undefined means for its own question" and
    documents the backfill's differing answer explicitly. No overreach found
    past what hyp session ignore names the session container Codex drops on, not a thread id (#453) #458 actually changed.

  4. Single-reader invariant: grepped the whole tree for
    payload.id/payload.cwd/payload.session_id/session_meta reads.
    Only hypaware-core/plugins-workspace/codex/src/backfill.js still parses
    session_meta itself (whole-file walk folding turn_context, provably
    cannot delegate to a bounded first-line reader). It imports and shares
    sessionMetaCwd for the one cwd predicate, and deliberately does not
    share rule 3's id refusal - buildSession falls a legacy rollout's
    sessionId back to the thread id, documented in LLP 0150's Consequences
    as intentional (a backfilled row must land in some partition; the CLI
    verb can refuse, a partition key cannot). rollout-cwd.js also now
    delegates cleanly to readRolloutSessionMeta. No second copy of the
    rules survives.

  5. The renumber: llp/0150-one-reader-for-codex-session-meta.decision.md
    exists, all three of its anchors (#placement, #usable-cwd, #bounded)
    resolve and are the only LLP 0150#... targets referenced anywhere. No
    surviving reference to 0143 for this document; llp/0143-openclaw-...
    (the human's own llp: OpenClaw full capture — plugin-steered shadow providers replace the settings edit (LLP 0142–0149) #475) is untouched.

  6. LLP corpus hygiene: illustrative @refs are marked and no gloss uses an em dash (#463 items 2 and 3) #468 hygiene sweep: diffed the merge result against LLP corpus hygiene: illustrative @refs are marked and no gloss uses an em dash (#463 items 2 and 3) #468's tip
    (183d38b) directly - every file outside this PR's own touched set
    (session_command.js, backfill.js, rollout-cwd.js, the new
    rollout_session_meta.js/tests/LLP) is byte-identical to master. Nothing
    from LLP corpus hygiene: illustrative @refs are marked and no gloss uses an em dash (#463 items 2 and 3) #468 was reverted.

Residual: #471 (in-band cwd at the live projector's .hypignore gate,
exchange-projector.js:120-123) is still open and correctly not duplicated;
#474 is filed against it. Nothing new to defer from this pass - #471 already
covers the only outstanding non-blocking gap.

Tests: npm test run in a fresh worktree at this head: 2905/2913 pass; the
8 failures are all in test/core/leave-command.test.js and reproduce
identically on a pristine origin/master worktree with the same
node_modules symlink, so they predate and are unrelated to this PR. The
targeted suites for the touched code
(ai-gateway-session-status, codex-rollout-session-meta,
codex-backfill, codex-rollout-cwd) pass 90/90.

Verdict: no true blockers. The unreviewed merge commit's claims about
itself all check out against the tree and against execution; #458's privacy
fix is intact on both its paths. Marking this triaged and safe to merge.

@philcunliffe philcunliffe 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 added a commit that referenced this pull request Jul 30, 2026
…s Codex never sends (#464) (#467)

* Codex lineage reads the durable body client_metadata (#464)

The Codex live projector derived a turn's thread, session and parent thread
from request headers, three of whose names Codex has never emitted, while the
authoritative ids sat unread in the request body.

Read against Codex's source, Codex projects one `CodexResponsesMetadata`
snapshot onto three surfaces per HTTP request. The flat body `client_metadata`
map (a top-level field of `ResponsesApiRequest`) is built unconditionally and
always carries `session_id` and `thread_id`; the `x-codex-turn-metadata` blob
only rides along for request kinds that carry turn metadata; and
`compatibility_headers` emits exactly four names. `thread-id`, `session-id`
and `parent-thread-id`, the three bare names the projector read, are not among
them, so they could never supply a right value and could supply a wrong one:
any hop setting `thread-id` dictated `conversation_id`, the scope of the row's
fallback `message_id`.

- Lineage now resolves body-map-first, turn-metadata-blob-second, with the real
  `x-codex-parent-thread-id` header (previously misspelled) last.
- A Codex-owned `client_metadata` map is itself a sufficient signal that an
  exchange is Codex, so the API-key route's generic `/v1/responses` no longer
  needs a Codex header to be recognized.
- The three fictional header names are gone; the real ones are named constants.
- `attributes.codex.lineage_source` records which surface stated the identity,
  so a future Codex version dropping one is queryable rather than a silent
  `conversation_id` drift.

Nothing re-keys: the blob's `thread_id` and the body map's `thread_id` are the
same field of the same snapshot, and the removed header names never matched
real traffic, so `conversation_id` (and the `message_id` / `part_id` scoped on
it, LLP 0030) is unchanged for every shape already recorded. Already-recorded
rows are left alone; LLP 0143 states why no backfill.

LLP 0143 is the decision doc. LLP 0083 and LLP 0141 carried the disproved
premise that `x-codex-turn-metadata` is Codex Desktop behavior and that the
subscription route states its session in a `session-id` header; both are
corrected, and the two `codex-rollout-cwd` fixtures that rested on that header
now use the shape Codex really sends.

Co-Authored-By: Claude <noreply@anthropic.com>

* Renumber LLP 0143 to 0144 to clear a duplicate number with PR #466

PR #466 (fix/issue-465) independently minted llp/0143-one-reader-for-codex-session-meta
on its own branch at the same time this branch minted
llp/0143-codex-lineage-from-body-client-metadata. Two documents cannot share a
number, and duplicate LLP numbers are exactly the corpus defect issue #463
tracks, so this branch takes 0144 (deterministic tie-break: the lower PR number
keeps the original).

Purely a renumber: every @ref anchor, cross-link, and Related entry follows the
move, and the document's own content is unchanged. Verified no residual "0143"
reference remains on this branch and all six referenced anchors still resolve in
the renamed document.

Co-Authored-By: Claude <noreply@anthropic.com>

* Codex lineage: record a body/blob disagreement instead of silently preferring one

The body-map-first precedence rests on Codex projecting one metadata snapshot
onto both the flat `client_metadata` map and the `x-codex-turn-metadata` blob,
so that the two are equal whenever both are present. That is a claim about
another program's internals which HypAware cannot verify, and the body-wins
tie-break discarded the counter-evidence without trace: a row whose surfaces
disagreed was indistinguishable from a row whose surfaces agreed.

- `attributes.codex.lineage_conflict` now names the lineage fields the two
  surfaces state differently (`thread_id`, `session_id`, `turn_id`,
  `parent_thread_id`), absent when they agree or only one spoke. The row still
  keys on the body, so this adds a signal and moves no identity.
- `lineage_source` now resolves in the same order as the values it describes
  (`thread_id` before `session_id`, body before blob). It previously answered
  "did the body state anything at all", which mislabelled a turn whose
  `thread_id` came from the blob while only its `session_id` came from the body
  as `body_client_metadata`, though `conversation_id` keys on `thread_id`.
- LLP 0144 gains `#lineage-conflict` and states why the assumption gets a
  continuously checked signal rather than a one-time assertion.

Co-Authored-By: Claude <noreply@anthropic.com>

* Codex lineage: an ambiguous flat client_metadata pair is not evidence of Codex

`readCodexClientMetadata` accepted a body `client_metadata` map on either of two
signals: an `x-codex-*` prefixed key, or the flat `session_id` + `thread_id`
pair. Only the first is Codex-exclusive. The second is a shape any agent
framework may send, and the projector's matched path set includes the fully
generic `/v1/responses` and `/v1/chat/completions`, so an unrelated client that
posted that pair was stamped `client_name: 'codex'` and dictated the row's
`conversation_id` and `session_id` (the partition key, LLP 0030). That is the
same defect class as the fictional `thread-id` header this branch removed,
reached through the body instead of a header, and in a capture product a misfiled
client is a privacy question.

The flat pair is now honoured only when the transport already identified the
exchange as Codex independently of the body (`hasCodexTransportSignal`: the
`chatgpt` upstream, the `/backend-api/codex/` namespace, an `x-codex-*`
compatibility header, or a `codex`-prefixed user-agent product). Real Codex loses
nothing: `client_metadata` carries `x-codex-installation-id` and
`x-codex-window-id` on every request, so the strict branch alone covers all known
Codex traffic, and the corroborated pair still covers a build that stopped
writing them. A non-Codex client's row now comes out byte-identical to the same
request with no `client_metadata` at all.

`isCodexExchange` is replaced by `hasCodexTransportSignal` plus the body check at
the single decision point in `resolveCodexContext`, so the corroboration flag
cannot drift between the two callers.

Also pins the assumption that keeps the outer `match` gate (path and
turn-metadata header only) consistent with the body being a Codex signal: a test
asserts every route Codex posts to passes the gate, so a body-only Codex request
is never dropped before the body is read.

LLP 0144#body-is-a-codex-signal and #body-is-authority are amended in the same
commit.

Co-Authored-By: Claude <noreply@anthropic.com>

* Renumber LLP 0144 to 0151 to clear the collision with the OpenClaw block

PR #475 merged llp/0144-shadow-provider-per-api-shape.decision.md to master as
part of the OpenClaw 0142-0149 block, so this branch's
llp/0144-codex-lineage-from-body-client-metadata.decision.md would have put two
documents at 0144. Because the filenames differ, git reported no conflict and CI
stayed green, so nothing on the rung ladder would have caught it before merge.

0151 verified free across master and every remote branch. 0150 is held by
fix/issue-465, renumbered there from 0143 for the same reason.

Purely a renumber: the document's content is unchanged, and the rename carries
every reference with it (12 @ref annotations in exchange-projector.js, 1 in the
gateway_codex_capture smoke, 14 in codex-exchange-projector.test.js, 1 in
codex-rollout-cwd.test.js, plus the cross-links and Related entries in LLP 0083
and LLP 0141, and the heading). No residual 0144 reference remains on this
branch and all six referenced anchors resolve in the renamed document.

The human directed this renumber explicitly, accepting that moving the head
strips neutral:approved and re-opens the review ladder. It resolves this one
collision and sets no precedent for issue #469, where the general
renumber-versus-qualified-citation convention is still open.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: neutral-reconciler <neutral@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: neutral-reconciler <neutral@hyparam.com>
Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com>
@philcunliffe
philcunliffe merged commit 1555f13 into master Jul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-465 branch July 30, 2026 04:04
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…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>
philcunliffe added a commit that referenced this pull request Jul 30, 2026
…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>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
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>
philcunliffe pushed a commit that referenced this pull request Jul 31, 2026
…st LLP 0083 provenance

Two review findings on PR #513, both in the round's own scope.

R1 (test hygiene): the new equivalence test was the only case in
`codex-exchange-projector.test.js` that built a projector with no injected
resolver, so the shared matcher walked the REAL ancestors of `/work/repo`
looking for a `.hypignore`. A file above the checkout on the machine running
the suite would turn `projection.cwd` into a `USAGE_POLICY_DROP` sentinel and
redden an assertion that has nothing to do with the gate. Inject a resolver
whose fs holds no list anywhere, matching how every neighbouring case in the
file works, and hoist the projector out of the loop.

Re-checked the mutation after the change: loosening `usableInBandCwd` back to
`cwd.trim().length > 0` still reddens the test on the "repo" case, so the
hermetic resolver did not make it vacuous.

R2 (doc accuracy): the LLP 0083 bullet claimed the local copy was written
"while 0150 was still unmerged". It was not. LLP 0150 / `sessionMetaCwd`
merged in #466 on 2026-07-29; the copy landed in #474 on 2026-07-30, and that
commit's own docstring cited LLP 0150 `#usable-cwd` by anchor while explicitly
declining to borrow it, on the scoping argument the bullet already states.
Attributing the copy to timing rewrites the recorded rationale. Say what
actually changed instead: the weight given to drift, not the scoping.

Co-Authored-By: Claude <noreply@anthropic.com>
philcunliffe added a commit that referenced this pull request Jul 31, 2026
)

* Codex live projector reads the cwd predicate from core, not a copy (#478)

`usableInBandCwd` inlined its own `trim() && isAbsolute()` copy of the
rule core exports as `sessionMetaCwd`. PR #474 added it deliberately and
temporarily, while LLP 0150 was unmerged; #466 landed that predicate, so
the copy now has an owner to defer to.

Behaviour is unchanged. Only a non-empty string reaches the in-band seam
(`readStringKey` and `firstString` refuse the rest upstream), and over
that whole domain the two copies already agreed byte for byte. What
changes is that there is one place left for the rule to drift from,
which is the whole reason LLP 0150 exists: this exact rule, stated
twice, shipped the wrong answer twice (#453, #459).

The wrapper survives only for the refusal diagnosis: `error_kind` needs
blank told apart from relative, and the shared predicate's single
`undefined` cannot carry that. It now decides nothing.

Docs: LLP 0083's bullet said the checks are restated locally and that
0150's predicate is not borrowed; LLP 0150's consequences listed the
in-band path as "Still outstanding ... no such predicate", which #474
already falsified. Both now describe the shared predicate, and 0150
keeps its scoping caveat: sharing the rule was LLP 0083's call, not an
invariant 0150 imposed.

Co-Authored-By: Claude <noreply@anthropic.com>

* Review round (521cf88): hermetic resolver for the new seam test, honest LLP 0083 provenance

Two review findings on PR #513, both in the round's own scope.

R1 (test hygiene): the new equivalence test was the only case in
`codex-exchange-projector.test.js` that built a projector with no injected
resolver, so the shared matcher walked the REAL ancestors of `/work/repo`
looking for a `.hypignore`. A file above the checkout on the machine running
the suite would turn `projection.cwd` into a `USAGE_POLICY_DROP` sentinel and
redden an assertion that has nothing to do with the gate. Inject a resolver
whose fs holds no list anywhere, matching how every neighbouring case in the
file works, and hoist the projector out of the loop.

Re-checked the mutation after the change: loosening `usableInBandCwd` back to
`cwd.trim().length > 0` still reddens the test on the "repo" case, so the
hermetic resolver did not make it vacuous.

R2 (doc accuracy): the LLP 0083 bullet claimed the local copy was written
"while 0150 was still unmerged". It was not. LLP 0150 / `sessionMetaCwd`
merged in #466 on 2026-07-29; the copy landed in #474 on 2026-07-30, and that
commit's own docstring cited LLP 0150 `#usable-cwd` by anchor while explicitly
declining to borrow it, on the scoping argument the bullet already states.
Attributing the copy to timing rewrites the recorded rationale. Say what
actually changed instead: the weight given to drift, not the scoping.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral 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.

Two independent readers of Codex session_meta enforce the same privacy-relevant invariant; it has silently drifted twice already

1 participant