Skip to content

Codex live projector: rank the workspace-key guess below the rollout cwd - #515

Merged
philcunliffe merged 4 commits into
masterfrom
fix/issue-480
Jul 31, 2026
Merged

Codex live projector: rank the workspace-key guess below the rollout cwd#515
philcunliffe merged 4 commits into
masterfrom
fix/issue-480

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The defect

resolveCodexContext folded the selected workspaces key into
codexContext.cwd, so the key satisfied the first ?? of the gate
expression and resolveRolloutCwd was never reached:

const cwd = usableInBandCwd(firstString(codexContext?.cwd, readRecordedCwd(reqBody)), ctx)
  ?? resolveRolloutCwd(rolloutCwd, codexContext)

On the ChatGPT-subscription route the request states no cwd, so
selectCodexWorkspace substitutes workspacePaths[0], a guess about a
directory the session may never have run in. That guess outranked
session_meta.cwd, the line Codex itself writes at session start, which is the
exact value the rollout fallback (LLP 0083) exists to supply. Adding a
workspaces header to an exchange that was correctly .hypignore-dropped
turned the drop into a record, and stamped the row with a directory from an
unrelated tree.

Privacy-relevant: an opted-out tree's exchange is persisted.

Reproduced first

test/plugins/codex-rollout-cwd.test.js, new test
a workspaces key does not preempt the rollout session_meta.cwd (#480): one
governing .hypignore (class ignore) at /work/ignored through the real
shared matcher, a call-counting rolloutCwd stub resolving the thread to
/work/ignored/real, no in-band cwd, and a workspaces map naming
/work/clean/proj.

Before the fix:

not ok 4 - a workspaces key does not preempt the rollout session_meta.cwd (#480)
  error: the opted-out tree the session actually ran in decides the verdict
  + actual - expected
    { + attributes: { + codex: { ...     <- a projected row, not USAGE_POLICY_DROP
  a declared workspaces map must not skip the rollout lookup: 0 !== 1

After:

ok 4 - a workspaces key does not preempt the rollout session_meta.cwd (#480)
ok 5 - the rollout cwd is stamped on the row while the workspace key still enriches it (#480)
ok 6 - the workspace key still gates when there is no rollout to outrank it (#480)

The fix

The patch shape worked out as decision D1 in PR #477's round-1 review, per
the maintainer's instruction on the issue.

  • resolveCodexContext returns cwd: inBandCwd and a sibling
    workspace_cwd: inBandCwd ? undefined : workspace?.path.
  • The gate expression gains a third term:
    ?? usableInBandCwd(codexContext?.workspace_cwd, ctx).

So the order is in-band, rollout, key.

Two properties held deliberately:

usableInBandCwd is applied to the key too, as a separate call, so the #471
shape checks that used to guard it (it arrived folded into the in-band value)
still do. Warn counts are unchanged: the second call receives undefined
whenever the first one had a value to refuse.

Docs

llp/0083-codex-live-cwd-from-rollout.decision.md: the stated-limit paragraph
naming this issue is trimmed and the bullet now carries a
{#workspace-key-ranks-last} anchor stating the three-source order, per the
maintainer's instruction. Two paragraphs that described the predicate as seeing
the key instead of the request's cwd are corrected (it now sees both,
separately), in the LLP and in usableInBandCwd's JSDoc. The symlink paragraph
is narrowed rather than deleted: a declared symlinked key now loses to the
rollout, whose value came from getcwd(2) and is canonical, but still reaches
the gate uncanonicalized when no rollout is found (#479).

One limit is newly written down rather than fixed: a key outranked by the
rollout is discarded silently, because
usage_policy_workspace_cwd_refused compares the key against a value the
request stated. Widening it would put a warn on every turn of an ordinary
session whose session_meta.cwd is a subdirectory of its workspace, which is
the frequency objection already open against that signal (PR #477 F4/D4), so the
two want settling together.

Checks

npm test: 3081 pass, baseline on origin/master is 3078 pass, so +3 and
nothing regressed. The 8 failures are pre-existing and unrelated (the leave
org-attach block); verified identical with the change stashed.

Overlap

Scoped tightly to the precedence bug. fix/issue-478 is editing
usableInBandCwd's body in parallel; this change only edits that function's
JSDoc and its call sites, so the two should reconcile cleanly.

Fixes #480

A `workspaces` turn-metadata key was folded into `codexContext.cwd` before the
gate expression ran, so it satisfied the first `??` and the rollout was never
consulted. On the ChatGPT-subscription route (the route the rollout fallback
exists for, LLP 0083) the request states no cwd, `selectCodexWorkspace`
substitutes the first declared workspace, and that guess outranked the
`session_meta.cwd` Codex itself wrote at session start. Adding a `workspaces`
header to an exchange that was correctly dropped by `.hypignore` turned the drop
into a record, and stamped the row with a directory from an unrelated tree.

Carry the key apart from the in-band value as `workspace_cwd` and rank it last:
in-band, rollout, key. The key keeps its enrichment role and remains the gate cwd
when no rollout is found, so the subscription route keeps `.hypignore` coverage.
It is offered only when the request stated no cwd at all, not merely when what it
stated was unusable, so a substitution still never stands in for a stated value
(#476 case c).

LLP 0083's stated-limit paragraph is trimmed accordingly, and the two doc
paragraphs that described the key as arriving folded into the in-band value are
brought back in line.

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

Two review findings on the workspace-key demotion.

LLP 0083's #481 limit ("a row recorded where it used to drop") described only
the in-band case. Demoting the key below the rollout widens that same
over-record to the whole subscription route: an ignored declared workspace now
also loses to a clean rollout `cwd`, where it used to decide the verdict and
drop. Say so, rather than leaving a privacy limit narrower on paper than in the
code.

The demotion also moved the workspace key onto its own `usableInBandCwd` call,
and nothing covered it: every #471 case states its bad value in-band, so they
all exercise the FIRST call. Replacing the third term with a bare
`?? codexContext?.workspace_cwd` passed the entire suite while handing a
relative key to the matcher, whose first act is `path.resolve` against the
daemon's process cwd. Pinned with a relative key and the governing `.hypignore`
at exactly that mistaken base; verified it fails on that mutation.

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

Copy link
Copy Markdown
Contributor Author

🤖 neutral review round 1 — e46ea60

Verdict: APPROVE with two findings, both fixed in-branch (a21b3ab). The
precedence change is correct, the fix is the shape the maintainer instructed
(D1), and I could not construct an input where it resolves a cwd the old code
refused. Both findings are about what the change leaves unpinned and
unwritten, not about the change itself.

No codex binary and no code-review/dual-review skill on this host, so the
diff was audited by hand, plus a source-only revert to reproduce the claimed
failure and a mutation to test the new test.


1. Precedence: correct, and not a fail-open

inBandCwd = firstString(readRecordedCwd(reqBody), readStringKey(metadata, 'cwd'))
(exchange-projector.js:783) is a strict superset of readRecordedCwd, so
gate term 1's argument, firstString(codexContext?.cwd, readRecordedCwd(reqBody)),
now evaluates to exactly inBandCwd. And workspace_cwd is set iff inBandCwd
is falsy (:900), i.e. iff term 1's argument is undefined. That makes the two
branches disjoint and the whole change reduce to one thing:

  • inBandCwd present — term 3's argument is undefined. Byte-identical to
    master: old term 1 was firstString(inBandCwd, ws.path) = inBandCwd. This
    is what keeps Codex live projector: the workspace-key substitution can feed the .hypignore gate a directory the session never ran in #476 case c green (relative cwd: 'sub' + ignored key
    /work/ignored/proj must not drop): I ran it, green, and the
    usage_policy_workspace_cwd_refused warn still fires. The key is offered only
    on no statement at all, never on an unusable one, exactly as claimed.
  • inBandCwd absent — old term 1 was usableInBandCwd(ws.path, ctx); new
    term 3 is usableInBandCwd(ws.path, ctx). Same predicate, same argument.
    Only the position moved, from ahead of the rollout to behind it.

So the answer to "is there an input where term 3 resolves a cwd the old code
would have refused" is no: term 3's accept-set is literally the old term 1's
accept-set on that branch. The only reachable behaviour delta is the intended
one — no in-band cwd, usable key, rollout resolves → the rollout now wins, and
the rollout is independently guarded by sessionMetaCwd (LLP 0150).

usableInBandCwd is still applied to the key, as its own call (:151), so
the #471 shape checks travelled with it. Warn counts are unchanged as claimed:
the two branches are disjoint, so exactly one of the two calls ever receives a
non-undefined value.

codexContext.cwd has no consumer other than :149 (grepped), so narrowing it
from firstString(inBandCwd, ws.path) to inBandCwd has no second-order
effect. resolveRecordedContext takes the resolved cwd as a parameter.

npm test: 3081 pass / 8 fail at e46ea60. The 8 are the known
test/core/leave-command.test.js block (#512) and nothing else.

2. Tests: fail-then-pass reproduced

Reverted only exchange-projector.js to origin/master, kept the tests:

not ok 4 - a workspaces key does not preempt the rollout session_meta.cwd (#480)
  error: the opted-out tree the session actually ran in decides the verdict
  +   cwd: '/work/clean/proj'        <- a projected row, not USAGE_POLICY_DROP
  a declared workspaces map must not skip the rollout lookup: 0 !== 1
not ok 5 - the rollout cwd is stamped on the row while the workspace key still enriches it (#480)

Both pass with the source restored. Test 6 (the floor) passes on both sides, as
a regression guard should. .hypignore goes through the real
createUsagePolicyResolver with an injected fs, not a hand-rolled stub; only
the injected dependency (rolloutCwd) is faked, which is the correct seam —
the thing under test is the projector's ranking.

FINDINGS

F1 — test/plugins/codex-rollout-cwd.test.js (new call site unpinned).
Severity: medium (regression-proofing on a privacy path).
Demoting the key moved it onto its own usableInBandCwd call at
exchange-projector.js:151, and nothing in the suite covered that call. Every
#471 case (codex-exchange-projector.test.js:147/168/195) states its bad value
in-band, so all three exercise the first call. I mutated term 3 to a bare
?? codexContext?.workspace_cwd and the entire 3081-test suite still passed,
while a relative workspace key (workspaces: { sub: {} }, which
selectCodexWorkspace accepts — it only filters key.length > 0) reached the
matcher, whose first act is path.resolve against the daemon's process cwd.
That is the #471 defect re-opened on a new line, with no test standing in the
way. Fixed: added "a relative workspace key is refused rather than resolved
against the daemon (#480, #471)"
, which puts the only governing .hypignore at
exactly that mistaken base and asserts the cwd_not_absolute refusal. Verified
it fails under the mutation and passes without it.

F2 — llp/0083-codex-live-cwd-from-rollout.decision.md:261 (a privacy limit
left narrower on paper than in the code).
Severity: low (doc accuracy).
The #481 limit still read "a row recorded where it used to drop (clean in-band
cwd
, ignored declared workspace)". This PR widens exactly that case: with no
in-band cwd, an ignored declared workspace used to be the gate input and
dropped; it now loses to a clean rollout cwd and the exchange is
recorded. That is the same over-record, extended from the in-band route to
the whole subscription route — which is the route with the most traffic under
this fallback. Correct behaviour (the rollout is what Codex wrote at session
start; the key was a guess) and consistent with the shipped #476 decision, but
it must not be discovered by re-reading the diff. Fixed: the parenthetical now
names both sources and says the demotion is what widened it.

3. LLP 0083 — accurate, anchors resolve, edits permitted

{#workspace-key-ranks-last} is at :230, on a list item, which is the
corpus's documented placement for {#...} (per the ref-check skill: 25 such
markers across 8 docs). All three new @refs (exchange-projector.js:136,
:898, codex-rollout-cwd.test.js:175) resolve to it; the untouched
#decision / #container-fallback-gap refs still resolve. llp-ref-hygiene
passes in the full run.

Doc matches shipped behaviour: the stated-limit paragraph is trimmed as
instructed, "three limits" still counts three, and the symlink paragraph's
narrowing ("loses to the rollout, whose value came from getcwd(2)… still
reaches the gate uncanonicalized whenever no rollout is found") is exactly what
the code does.

Immutability: these edits are permitted. CLAUDE.md mandates the opposite
of immutability — "Living docs. Update the LLP when the design changes: land
the doc edit in the same commit as the code."
Only Superseded / Tombstoned
retire a doc. LLP 0083 is Status: Accepted (not Active, as the brief said;
71 of the corpus's docs are Accepted), and it already carries in-place
amendments in the same bullet ((amended, #476)) plus a ## Correction: section
for #459. Amending in place is the convention here, not a violation of it.

4. The silent discard — acceptable, not a finding

Judged acceptable, and the reasoning is worth recording because the
frequency objection (D4 / PR #477) is the only thing keeping it that way:

  • When a key outranked by the rollout produces a record, both values land on
    the row — cwd = the rollout, attributes.codex.workspace = the key. The
    divergence is fully queryable after the fact; it is not lost, just not
    pushed.
  • When it produces a drop, nothing is written, but a drop is the
    privacy-safe direction and usage_policy_drop still logs cwd_sha256.

So no privacy-relevant decision is hidden: every outcome is either observable on
the row or observable in the log. What is missing is only the attribution
("your key was outranked"), and widening
usage_policy_workspace_cwd_refused to supply it would warn on every turn of an
ordinary subdirectory session — a signal that fires constantly attributes
nothing. Settling it together with D4 is the right call. Now written down at
:254, so it is a known limit rather than an absence.

5. House rules

Clean. No semicolons, no U+2014 (checked the full diff), JSDoc only, no
@typedef, no inline import() types, no new type-import specifiers needed.
npm run typecheck passes.


What I pushed — a21b3ab

npm test after: 3082 pass / 8 fail (+1 test, the same 8 baseline
leave-command failures, #512). npm run typecheck clean. Verified on
origin/fix/issue-480, not just locally.

Conflict: `usableInBandCwd`'s "what this does not reach" JSDoc paragraph in
`exchange-projector.js`, rewritten by both sides. Resolved by keeping both:
this branch's "it bounds the SHAPE, never the provenance; the ranking at the
call site is what keeps a guess from deciding" framing, master's "#476 closed,
a request that states a `cwd` no longer reaches here through the key" and its
`@ref LLP 0160#corrections-0083`. Master's closing clause "the key still
outranks the rollout fallback (#480)" is the thing this branch changes, so it
now reads as the ranking the demotion established.

#491's behaviour is untouched: `workspaceCoversCwd`/`isEqualOrDescendant` and
the narrowed warn-site comment merged clean and stand.

Two prose reconciliations where each side's text asserted the other's
pre-merge state:

- `llp/0083` #481 limit said widening the refusal to the rollout was blocked on
  a frequency objection "already open". #491 settled that objection with the
  ancestor test, so the limit now says widening is unblocked but must reuse
  0160's predicate rather than a byte comparison.
- `llp/0160` §corrections-0083 called #480 "the open ranking question". It is
  answered here, so it points at 0083 §workspace-key-ranks-last.

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

Copy link
Copy Markdown
Contributor Author

Review round 2 - APPROVE, no findings

Round 2 audit of e3a169b (the post-merge head). No actionable findings. The
merge with the moved master did not unpin anything.

1. Round 1's two fixes survived the merge

2. Both mutations still redden on the MERGED tree (re-run here, not taken on trust)

Run against e3a169b, worktree restored clean afterwards and re-verified green
(95 pass / 0 fail across codex-rollout-cwd.test.js +
codex-exchange-projector.test.js).

# Mutation Result
A third term to a bare ?? codexContext?.workspace_cwd RED - not ok 7 - a relative workspace key is refused rather than resolved against the daemon (#480, #471)
B cwd: inBandCwd back to firstString(inBandCwd, workspace?.path) RED - not ok 4 - a workspaces key does not preempt the rollout session_meta.cwd (#480), not ok 5 - the rollout cwd is stamped on the row while the workspace key still enriches it (#480)

I added a third mutation the round-1 set did not cover, since the merge touched
the same construct:

# Mutation Result
C workspace_cwd: inBandCwd ? undefined : workspace?.path to an unconditional workspace?.path RED - not ok 39 - a refused workspace substitution is logged with hashed paths, not silently applied (#476 case c)

So all three of the guarantees this PR asserts (the key is shape-checked, the
key ranks below the rollout, and the key is offered only when the request stated
no cwd at all) are individually pinned by a test that fails without them.

3. #491 survived intact

git diff origin/master HEAD -- '*exchange-projector.js' is exactly three hunks,
all of them this PR's own edits, nothing else. Confirmed present and unreverted:

  • isEqualOrDescendant still imported from src/core/usage-policy/index.js,
    workspaceCoversCwd still defined as the ancestor test.
  • refused_workspace_cwd: workspace && inBandCwd && !workspaceCoversCwd(...) -
    unchanged predicate.
  • The narrowed warn-site comment block (the @ref LLP 0160#decision [constrained-by] plus the nearest-governs non-monotonicity note) is verbatim.
  • Codex workspace-cwd refusal is an ancestor test, not a byte test (#481, finding 2 of 2) #491's own tests are green: #481 ancestor cases 43/44/45 and #476 cases
    39/40/42 all ok.

4. The reconciled prose is accurate, and the llp/0160 edit was warranted

5. Precedence is correct end-to-end

Traced the whole chain rather than the gate line alone, because "the request
stated no cwd at all" is the load-bearing condition and it is enforced one
function away from where it is read. inBandCwd
(exchange-projector.js:789) is firstString(readRecordedCwd(reqBody), readStringKey(metadata, 'cwd')), and readRecordedCwd itself unions the body's
cwd, metadata.cwd and metadata.user_id.cwd. So workspace_cwd is
suppressed for every way the request can state a cwd, not just the turn-metadata
one - there is no back door where a body-stated-but-unusable cwd lets the key
stand in. The call site passes the same reqBody object it handed
resolveCodexContext, so the gate's firstString(codexContext?.cwd, readRecordedCwd(reqBody)) is redundant but harmless, not a second source.
Order is in-band, then rollout, then key. #476 case c green.

6. House rules

Clean. No em dashes in any changed file, no statement-terminating semicolons in
added JS, @import at file top, no @typedef, no TypeScript annotations.

Checks

Informational, not a finding

The PR body's Docs section still says the widening "wants settling together"
with the PR #477 F4/D4 frequency objection, which llp/0083 now supersedes by
pointing at LLP 0160. The doc is the authority and the doc is right; the body is
just older than the merge. Not touching the body per this round's scope.

@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 06:40
@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 31, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: #513 and #515 both edit llp/0083, and LLP 0160 quotes the bullet

Both PRs are now held, green, reviewed and neutral:approved. Flagging one interaction so whoever merges second is not surprised.

1. Direct overlap. #513 rewrites the llp/0083 provenance bullet (correcting a rationale that was factually false: the duplicated predicate was not written "while 0150 was unmerged" - #466 merged 2026-07-29, the copy landed in #474 on 2026-07-30). #515 edits the same file's privacy-limit paragraph and adds {#workspace-key-ranks-last}. A textual conflict is likely for the second merge; it should be a straightforward keep-both.

2. The subtler one. llp/0160 #corrections-0083 quotes the exact 0083 bullet #513 rewrites, verbatim. Today the quoted sentence survives #513's edit intact, so the correction still lands on real text - but that is closer to luck than design. #515 also touches llp/0160 (its conflict resolution updated §corrections-0083 so it stopped calling #480 "the open ranking question"). After both merge, it is worth one glance that LLP 0160's quotation still matches the text it claims to quote.

Neither is a defect in either PR, and neither blocks a merge. Order does not matter for correctness here - only for how much conflict resolution the second one costs.

Separately, and unrelated to this pair: PR #525 (the 201-file em-dash sweep) should merge last of everything open. It moved 90 heading-anchor slugs, so a branch adding an @ref to an old slug merges cleanly and then fails ref-check - a semantic conflict git cannot flag.

@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 31, 2026
# Conflicts:
#	llp/0083-codex-live-cwd-from-rollout.decision.md
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage: safe to ship (branch a)

Neutral triage rung, PR #515, head d0c91a0372f3eedd81748f108db198e7a2842feb.

Both review rounds are accounted for:

  • Round 1 (e46ea60) raised F1 and F2, both fixed in-branch at a21b3ab and
    confirmed still present at head.
  • Round 2 (e3a169b) reported zero findings, with F1/F2 re-verified by
    mutation against the merged tree.

After round 2, the branch merged origin/master again, landing at the current
head d0c91a0 (pulling in PR #513 among others), and that merge was never put
through a review round. I verified it directly rather than taking the round-2
"clean" verdict as still covering it:

Residual findings at head: zero. Nothing is a blocker, and nothing new is
even a preference-level nit. Follow-up #540 records this closure and
backlinks the one already-tracked, explicitly-accepted non-finding (#492, the
rollout-outranked silent discard), so this thread doesn't need to carry that
context forward on its own.

@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 31, 2026
@philcunliffe
philcunliffe merged commit 68f5cd6 into master Jul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-480 branch July 31, 2026 21:41
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.

Codex live projector: a workspaces turn-metadata header preempts the rollout session_meta.cwd, turning a correct .hypignore drop into a record

1 participant