Skip to content

Codex live projector reads the cwd predicate from core, not a copy - #513

Merged
philcunliffe merged 3 commits into
masterfrom
fix/issue-478
Jul 31, 2026
Merged

Codex live projector reads the cwd predicate from core, not a copy#513
philcunliffe merged 3 commits into
masterfrom
fix/issue-478

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Root cause

usableInBandCwd in hypaware-core/plugins-workspace/codex/src/exchange-projector.js
carried its own copy of the usable-cwd rule:

if (cwd.trim().length > 0 && isAbsolute(cwd)) return cwd

That is the same rule core exports as sessionMetaCwd
(src/core/codex/rollout_session_meta.js, LLP 0150 #usable-cwd). PR #474 added
the copy deliberately and temporarily, because #466 had not landed yet. #466 has
since merged, so the copy now has an owner to defer to.

This is a no-behaviour-change refactor, and here is the proof

Stating it plainly, as the review discipline asks: no test can distinguish the
two implementations
, so none was written to fail. Only a non-empty string can
reach usableInBandCwd (readStringKey requires typeof value === 'string' && value.length > 0, and firstString requires the same), and over that entire
domain the two predicates compute the same conjuncts in the same order and return
the surviving value byte-identical. The one place they differ, a non-string
input, where sessionMetaCwd returns undefined and the old copy would throw on
.trim(), is unreachable through the typed call site.

So the test proves equivalence instead, driving one table through both paths:
the shared predicate directly, and the projector's in-band seam via project().

test/plugins/codex-exchange-projector.test.js
  'the in-band cwd seam answers exactly as the shared sessionMetaCwd predicate does'

The table is the whole of what the seam can be asked: absolute, absolute with a
trailing slash, unnormalized absolute, bare relative, ./, ../, whitespace-only,
tabs/newlines, and the one case the trim alone would wave through (' /work/repo',
non-blank but not absolute).

It passes before the fix, which is the point

$ node --test --test-name-pattern="in-band cwd seam answers exactly" \
    test/plugins/codex-exchange-projector.test.js     # BEFORE the fix
ok 1 - the in-band cwd seam answers exactly as the shared sessionMetaCwd predicate does
# pass 1
# fail 0

And it is not vacuous: mutation-checked

A test that passes both ways proves nothing unless it would catch the drift it
exists to prevent. Dropping the isAbsolute conjunct from the old local copy,
which is exactly the shape of the drift that shipped wrong in #453 and #459,
reddens it:

$ # local copy mutated to `if (cwd.trim().length > 0) return cwd`
not ok 1 - the in-band cwd seam answers exactly as the shared sessionMetaCwd predicate does
    "repo": the in-band seam must not be looser or stricter than sessionMetaCwd
# pass 0
# fail 1

After the fix

$ node --test test/plugins/codex-exchange-projector.test.js \
    test/core/codex-rollout-session-meta.test.js
# tests 75
# pass 75
# fail 0

What changed

  • usableInBandCwd calls sessionMetaCwd and trusts its return value as the
    verdict. The node:path isAbsolute import is gone; the core import replaces it.
  • The wrapper survives only for the refusal diagnosis, as the issue asks: the
    error_kind split (cwd_blank vs cwd_not_absolute) needs blank told apart
    from relative, and a single undefined cannot carry that. The trim() in the
    log line now decides nothing, and a comment says so, since the refusal is
    already settled above it.
  • Docstring rewritten: it claimed the checks are restated locally and that
    LLP 0150's predicate is "not borrowed".
  • @ref LLP 0150#usable-cwd [implements] added alongside the existing
    @ref LLP 0083#decision.
  • LLP 0083 bullet: described the two checks as restated locally in
    usableInBandCwd. Now describes the shared predicate, and keeps the scoping
    caveat, since sharing it was LLP 0083's call, not something 0150 imposed.
  • LLP 0150 consequences listed the in-band path as "Still outstanding ...
    reaches the same resolver.resolve with no such predicate". PR Codex live projector: an unusable in-band cwd is a miss, not a path (#471) #474 already
    falsified that; this makes it honest. The scoping caveat is kept there too:
    0150 still does not claim the in-band path as its mandate.

Checks

npm test: 3079 pass, 8 fail. The 8 are pre-existing on origin/master
(verified by stashing this diff and re-running: same count, same leave/attach
tests, unrelated to codex or cwd). npm run typecheck clean.
npm run smoke -- gateway_codex_capture ok.

Scoped tightly per the issue: no other behaviour touched, and nothing in the
turn-metadata header area that #480 is working in.

Fixes #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>
…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

Copy link
Copy Markdown
Contributor Author

🤖 neutral review round 1 (head 521cf88): approve after two fixes, both pushed

Verdict: APPROVE. The central claim holds, and I verified it by execution
rather than by reading. Two minor findings, both fixed on this branch in
d4255de; neither touches the behaviour under review.

No codex binary and no dual-review/code-review skill on this host, so this
is a direct audit of the diff.


The equivalence claim: verified, and stronger than the PR argues

sessionMetaCwd(value) is typeof value === 'string' && value.trim().length > 0
(metaField, rollout_session_meta.js:138) then path.isAbsolute(cwd)
(:176-179). Both sides use node:path, so isAbsolute is the same function,
not a posix/win32 divergence. Restricted to strings that is character-for-character
the old local copy, conjuncts in the same order, and the surviving value is
returned by reference in both. The claim is correct.

The reachability argument is under-stated in the PR body, in the PR's favour.
It leans on readStringKey and firstString "upstream". The load-bearing gate
is narrower than that and needs no reasoning about upstream at all: the sole call
site is

// exchange-projector.js:134
const cwd = usableInBandCwd(firstString(codexContext?.cwd, readRecordedCwd(reqBody)), ctx)

and firstString is values.find(v => typeof v === 'string' && v.length > 0)
(response-items.js:269). That is a total filter applied at the argument
position: whatever codexContext?.cwd or readRecordedCwd return, and however
they change later, usableInBandCwd receives non-empty-string-or-undefined.
Empty string is excluded by the same expression, and undefined takes the early
return above the predicate. usableInBandCwd is module-private with exactly one
caller (git grep across the repo: definition at :1245, call at :134, and
two LLP prose mentions). So the divergence the PR concedes (non-string input) is
unreachable, and stays unreachable under upstream churn. No behaviour change.

The case table is exhaustive over the interesting domain: absolute, trailing
slash, unnormalized absolute, bare relative, ./, ../, whitespace-only,
tabs/newlines, and ' /work/repo'. '' is correctly absent (unreachable, and
the docstring says why).

Mutation checks: re-run, not taken on trust

The PR's claimed mutation reproduces exactly. On origin/master's pre-fix copy
with the new test file dropped in:

mutation site result
(none) origin/master copy ok 1, passes before the fix, as claimed
drop the isAbsolute conjunct (if (cwd.trim().length > 0) return cwd) origin/master copy not ok on "repo", the author's claim, confirmed
sessionMetaCwd(cwd)cwd.trim().length > 0 ? cwd : undefined this branch not ok on "repo"
strip a trailing slash off the surviving value this branch not ok on "/work/repo/"

So the test is not vacuous in either direction: it catches the fail-open drift
shape from #453/#459 and it pins byte-identity of the accepted value, which
the loosening mutation alone would not have shown. Re-ran the loosening mutation
after my own change to confirm the hermetic resolver did not defang it.

One structural limit, by design and worth naming: the test compares the projector
against sessionMetaCwd itself, so it can only catch drift in the projector,
never in the shared predicate. That is the right split (test/core/codex-rollout-session-meta.test.js owns the predicate), but it means this test is an
agreement pin, not a specification of the rule.


Findings

F1 (minor, test hygiene, fixed) test/plugins/codex-exchange-projector.test.js:257

The new test was the only case in the file that built createCodexExchangeProjector()
with no injected resolver. Every neighbouring case injects one
(ignoringResolver / clampingResolver, :14-45). Without it the real shared
matcher runs: existsSync/readFileSync walk the actual ancestors of
/work/repo (/work/repo, /work, /) hunting a .hypignore, plus a real
realpathSync and volume case probe. A .hypignore of class ignore anywhere
above the checkout makes project() return the USAGE_POLICY_DROP sentinel,
whose .cwd is undefined, reddening an assertion that has nothing to do with
the gate. It passes here, but it is environment-coupled.

Fixed: added a governsNothingResolver() helper beside the other two (a real
resolver over an fs that holds no list anywhere) and hoisted the projector out of
the loop. Mutation re-verified after the change.

F2 (minor, doc accuracy, fixed) llp/0083-codex-live-cwd-from-rollout.decision.md:85

The new bullet said the local copy was written "while 0150 was still unmerged".
That is not what happened, and the PR body repeats it ("because #466 had not
landed yet"
).

So the copy was a deliberate scoping call made with the owner already available,
not a timing artefact. Attributing it to timing rewrites the recorded rationale,
which is the one thing an LLP is for. Rewritten to say what actually changed: the
weight given to drift, not the scoping. (I did not edit the PR body; the same
sentence there is now the only place the claim survives.)


Everything else checked, no findings

The surviving wrapper. Correct. The trim() at :1271 is reached only after
sessionMetaCwd already returned undefined, i.e. only when the value is blank
or non-absolute, and the refusal is settled by the return above it. It
decides nothing; it classifies. error_kind is right for every input class,
including the interesting one: ' /work/repo' trims non-empty, so
cwd_not_absolute, which is exactly what it is. The retained inline comment says
this and is honest.

LLP edits.

Scope vs #480 / PR #515. Clean. This diff's only code hunks are the import
block and usableInBandCwd (:1206, :1244). It does not touch
resolveCodexContext, readCodexTurnMetadata, selectCodexWorkspace, or the
gate expression at :134, the :723 / :130-131 lines PR #515 rewrites. Note
for whoever merges second: both PRs edit llp/0083, and #515's fix is expected
to trim the #476 "one limit of the rule" sentence that this PR reflows, so a
textual conflict in that bullet is likely. Code-wise they are disjoint.

House rules (CLAUDE.md). No semicolons, no U+2014 anywhere in the diff, JSDoc
only, no @typedef, no inline import('...') types. The new import
'../../../../src/core/codex/rollout_session_meta.js' is the repo-root-anchored
.js form and is byte-identical to how backfill.js:6 already imports the same
symbol. The node:path import is fully removed with no orphaned isAbsolute
reference left behind.

Checks

Pushed as d4255de. Not merging, not labelling, not touching the PR body.

# Conflicts:
#	hypaware-core/plugins-workspace/codex/src/exchange-projector.js
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 (merged head 4325b4c) - APPROVE

Round 1 approved at 521cf88; a resolve-conflict rung then merged origin/master
(#491, #502, #505). This round re-audits the merged tree, because the merge is the
new risk: the conflict sat in the import block of the one file this PR changes.

Verdict: clean. No actionable findings. Nothing changed, nothing pushed.

1. Both mutation checks still redden on the merged tree

Baseline npm test on 4325b4c: 3111 pass / 8 fail, all eight in
test/core/leave-command.test.js (the known #512 breakage, fix held in #527).
npm run typecheck: clean.

(a) Drop path.isAbsolute from the shared predicate
(src/core/codex/rollout_session_meta.js:178) - reddens 6, up from the 4 the
resolver recorded pre-merge (fail 14 vs baseline 8):

  • a relative session_meta.cwd is no cwd, not a path resolved against the daemon
  • sessionMetaCwd is the one cwd predicate, usable by a caller that cannot delegate
  • a blank or relative rollout cwd never reaches the usage-policy gate
  • a relative session_meta.cwd is no cwd: the matcher would resolve it against the daemon
  • project() computes no .hypignore verdict from a RELATIVE in-band cwd
  • project() logs an unusable in-band cwd rather than skipping the gate silently

The count went up, and for the right reason: the last two are projector tests
that only redden because the projector now delegates. Before this PR its local
copy would have absorbed the mutation. So the merge did not unpin the guarantee -
delegation widened the blast radius of breaking it, which is the point.

Correctly, the new seam test does not redden here. It asserts agreement, and
under (a) both sides move together. That is the shape an agreement test should have.

(b) Let the wrapper drift loose again (trim-only, bypassing sessionMetaCwd,
at hypaware-core/plugins-workspace/codex/src/exchange-projector.js:1262) -
reddens exactly 3, as claimed, including this PR's own seam test:

  • project() computes no .hypignore verdict from a RELATIVE in-band cwd
  • project() logs an unusable in-band cwd rather than skipping the gate silently
  • the in-band cwd seam answers exactly as the shared sessionMetaCwd predicate does

(c) Extra, unasked: I also drove the wrapper the other way (always
undefined, i.e. stricter than the shared predicate). The seam test reddens there
too. Agreement is pinned in both directions, not just against loosening - which
matters, since the .hypignore gate is harmed by either drift.

2. isAbsolute is genuinely dead in the merged file

grep for isAbsolute, node:path, and from 'path' in
exchange-projector.js returns nothing. #491's workspaceCoversCwd
(exchange-projector.js:1340) needs no path primitive of its own: it delegates to
isEqualOrDescendant, which imports path inside
src/core/usage-policy/matcher.js. Dropping the import was correct, and the
resolution kept both of master's additions (isEqualOrDescendant on line 4).

Import shape is consistent with the three sibling readers of the same module
(rollout-cwd.js:6, backfill.js:6, session_command.js:9), correct depth to
repo root, and alphabetically ordered against the usage-policy import. There is no
hypaware/core/codex export subpath, so the relative specifier is the only form
available.

3. Round 1's two fixes survived the merge

  • governsNothingResolver() is present at
    test/plugins/codex-exchange-projector.test.js:57 and used at line 260. I
    verified the injection is real rather than a silently-ignored option:
    createUsagePolicyResolver destructures existsSync
    (src/core/usage-policy/matcher.js:121), and with localOnlyListPath omitted
    the machine-local list is inert too. The test is fully hermetic - no real
    ancestor walk of /work.
  • The corrected provenance bullet survives at
    llp/0083-codex-live-cwd-from-rollout.decision.md:84-86: "sessionMetaCwd was
    already on master when that copy landed". The false "written while 0150 was
    unmerged" rationale is gone. Dates line up (LLP 0150 is dated 2026-07-29).

4. The auto-merged docstring is coherent

I read usableInBandCwd's block end-to-end
(exchange-projector.js:1188-1257) and diffed the contested paragraph against
origin/master. Master's "One thing this does NOT reach" paragraph is byte-identical
to master's version, including its trailing @ref LLP 0160#corrections-0083; this
PR's two new paragraphs sit above it. No duplicated or contradictory claim - the two
halves actually reinforce each other, since master's paragraph already ends on
"rollout-cwd.js reads it through readRolloutSessionMeta, which applies
sessionMetaCwd", which is the same fact this PR now asserts for the in-band side.

All three @refs resolve:

  • LLP 0150#usable-cwd -> llp/0150-...md:115 {#usable-cwd}
  • LLP 0160#corrections-0083 -> llp/0160-...md:171 {#corrections-0083}
  • LLP 0083#decision -> llp/0083-...md:55 ## Decision

One thing I checked that could easily have broken and did not: LLP 0160's
#corrections-0083 section quotes the LLP 0083 bullet this PR rewrites. The quoted
sentence is preserved verbatim in the rewrite, so the correction still lands on real
text.

House rules hold across the diff: no semicolons, no U+2014, no @typedef, no inline
import() types, JSDoc only.

5. Noted, not fixed

PR #515 also edits llp/0083-codex-live-cwd-from-rollout.decision.md. Whichever of
#513 / #515 merges second will hit a textual conflict in that file. Flagged in round
1, re-flagged here; not this PR's to resolve.

Nits (non-blocking, deliberately not fixed in a round-2 approve)

  • exchange-projector.js:1274-1276: the "the trim below re-tests a conjunct"
    sentence is appended to the "One gap, deliberate" comment block with no break, so
    one block now carries three unrelated topics. It also restates what the docstring
    already says at :1219-1222. Readable, just slightly muddy.
  • llp/0083-...md:89 uses "behaviourally"; the corpus is mixed on that spelling
    (36 files use "behavior"), so this is pre-existing drift, not a regression.

What I ran

npm test (baseline + three mutations, each reverted and positively verified clean
via git status --porcelain), npm run typecheck, plus manual anchor and import
verification. Working tree returned to pristine 4325b4c. No commits, no push.

@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 06:42
@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 merged commit 10f4aa0 into master Jul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-478 branch July 31, 2026 18:00
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's usableInBandCwd should reuse sessionMetaCwd now that #466/LLP 0150 landed

1 participant