Skip to content

CRW-122 Phase 1 · 유휴 감독 전달: 스레드 승인 정책을 가정하지 말고 보존한다 - #65

Merged
thisisjun786 merged 26 commits into
devfrom
codex/crw-122-hierarchy-delivery
Sep 20, 2026
Merged

thisisjun786 merged 26 commits into
devfrom
codex/crw-122-hierarchy-delivery

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Sep 19, 2026

Copy link
Copy Markdown
Owner

The defect

A parent sending a recovery or merge-turn return to an idle supervisor whose approvalPolicy
is on-request was refused with unsupported_approval_policy before turn/start, so the
report could never be delivered. Steering an active supervisor worked, which is why documents
and manual recovery survived while idle-supervisor report-then-resume did not.

Reproduced against a real isolated app-server (codex-cli 0.154.0, its own CODEX_HOME, no
credentials — nothing live was touched):

status              failed
rpcError.code       unsupported_approval_policy
findings[0]         expected "never", returned "on-request"
attemptedEffects    ["thread/resume"]
turn/start count    0

Cause

The approval policy was a constructor default that no caller could set
(settings.py approval_policy="never"). Two separate faults followed:

  • resume_params() transmitted approvalPolicy: "never" on every resume that requested
    anything. That is asking to set the policy of a thread this bridge did not create.
  • findings() judged the host's answer against that same assumed never, so any thread that was
    not already never was refused before a turn could start.

The change

The caller now declares the policy it believes the thread is on, and the resume carries no
approvalPolicy at all
.

  • Preservation is a property of the request. A parameter that is never sent cannot move a
    policy, whichever host is on the other end. ThreadResumeParams.approvalPolicy is optional;
    ThreadResumeResponse requires approvalPolicy and approvalsReviewer, so the truth is
    always observable without asking for anything.
  • The guard is intact and now honest. Its predicate moves from "observed is not never" to
    "observed is not the declared one", which is how a supervisor whose state moved under the
    caller gets caught rather than written to. Omitting the key declares never, so every existing
    caller keeps exactly its previous behaviour, including its previous refusal.
  • Delivery is not approval servicing. The bridge still answers every server-to-client request
    with a JSON-RPC error, which declines to decide; answering in the approval vocabulary
    (approved/decline) would write a verdict in the approver's own type and make the bridge the
    approver. Those refusals are now recorded, keyed by threadId/turnId, so "this bridge
    granted nothing" is evidence rather than an assurance.
  • delivery is derived in reconcile from attemptedEffects, never pre-written. A death
    between the turn/start frame and its response can therefore never leave a receipt that says
    outcome_unknown beside not_delivered — which would tell the next caller it is safe to resend.

Nothing is relaxed: no policy is forced to never, no guard is deleted, no sandbox or approval
setting is widened, and create_thread/create_worktree_thread still set never on the threads
they create.

Measured, not assumed

The whole design rests on "omitting the parameter preserves the policy". That was originally
inferred from schema optionality; the architect review flagged it, so it was measured on
codex-cli 0.154.0 in both directions with isolated CODEX_HOMEs:

thread policy config.approval_policy resume with no parameter
on-request never reports on-request
never on-request reports never

Read twice each; nothing persisted. Omission preserves and does not inherit the config
default. Separately measured: a resume transmitting never against an on-request thread comes
back on-request and does not persist, so on this host the old transmission was an ill-shaped
request rather than a live relaxation. Both facts are stated separately and neither is overclaimed.

Tests

12 new tests, red-green verified against the original source: declared delivery to an
on-request idle thread; the guard still refusing when nothing is declared; a policy that changed
under the caller; a granular policy that can be observed but never declared; an explicit null
declaration refused locally; the resume never carrying approvalPolicy; an approval request
refused and never decided; a refusal preserved as not_delivered with zero turn/start; and
recovery processing one logical message exactly once across replayed refusals, a corrected
declaration and repeated ACKs.

conftest.py gains a fake that acts on the resume parameter. Against it, the old resume would
have moved an on-request supervisor to never and then reported success, because the value it
found was the value it had just written. The default fake models the measured host, which ignores
the parameter — a fake that could only do that would have assumed the conclusion.

Validation

The one check the Protect dev ruleset requires is dev-gate, and it is success on the current
head. That ruleset also requires every review thread to be resolved (all five are) and requires the
branch to be current with dev at merge time — it is 4 commits behind with zero file overlap, which
is an integration step for whoever merges, not a defect here.

The external GitHub Codex reviewer is not a required gate under Jun's current policy, which
disabled it. It is not requested or re-requested on new heads, and an absent, pending or skipped
Codex review is recorded as not required by user policy rather than as a passing check. Devin
review and all repository-required CI are unaffected.

CI is green on the current head 4e3c7c8, base 763ce15: run to be recorded below, attempt 1, event pull_request
validate, tests (3.10), tests (3.13), packages (3.11), packages (3.13), secrets and
dev-gate all success; release-gate skipped.

Locally: scripts/ci/validate.py, scripts/ci/contracts.py, runtime_install.py verify-definition
and git diff origin/dev --check pass; bridge suite 198 passed, relay suite 1239 passed / 1 skipped.

Two local results are sandbox artifacts rather than code, and CI covers both:

  • scripts/ci/tests reports one error — test_a_tilde_settings_path_is_absolute_once_the_hook_opens_it
    writes to $HOME, which is read-only in this sandbox. This PR touches no CI test file.
  • scripts/ci/packages.py cannot run locally: /var/tmp is read-only and /tmp contains a stray
    .git, exactly the condition its own temporary_root() docstring warns about. The 38
    test_worktree.py failures it causes reproduce identically on the original source. Both
    packages legs pass in CI.

scripts/crw_runtime/components.json: only the codex-thread-bridge entry's three digests are
re-derived. The codex-session-relay entry is untouched — it belongs to CRW-120 / #58.

Review round

Five findings, all real, all fixed and each answered in thread with evidence before its thread was
resolved. Two came from the external Codex reviewer, which Jun has since disabled; that changes
nothing about them. A reported defect is an obligation regardless of who reported it, so none of
these was auto-resolved or discarded when the reviewer went away, and no unrelated improvement was
absorbed alongside them.

  • Null policy became never (Devin, marked resolved). The selection tested the value instead
    of the key's presence, so an explicit null silently became never — the same "a discarded key
    looks like a setting never requested" failure the unknown-key refusal beside it exists to stop.
    Keyed on presence now, with a regression test.
  • Trailing whitespace (Devin, marked resolved). Real, and so was the methodology error behind
    it: the original git diff --check ran after committing against a clean tree, so it compared
    nothing and this description repeated that empty result as a pass. Re-checked against the base.
  • Stale guidance (Codex P1 + P2). The package README still said the policy is always never
    and interactive threads are always refused, and the bridge reference omitted the key entirely —
    so a caller following either would have kept hitting the original failure. Both updated.
  • Contradictory approval claim (Codex P2). APPROVAL_LIMITS asserted categorically that a
    refused approval is never shown to the thread's approver, while get_capabilities said
    host-side forwarding is unverified — two opposing claims in one object, and the categorical one
    was the unmeasured half. The claim is now limited to what is established: this bridge has no
    forwarding route and nothing it does turns such a request into an approval.

Scope

This PR now covers both phases of CRW-122. Phase 2 was gated on CRW-120 while that PR was in
flight; CRW-120 has since landed on dev and the gate was released, so the relay half is
implemented here rather than deferred.

Phase 2, in packages/codex-session-relay, built against the landed linkage.py:

  • delivery.resolve_recipient reads linkage.up(relationship_id=...) instead of taking the
    recipient off the relationship row, which freezes its parent at registration. It verifies
    rather than overrides: a linkage owner that disagrees with the row is refused as
    relation_owner_drift, so a completion arriving after the project changed hands is never filed
    as the new owner's result. An unreadable store is refused as relation_unreadable and never
    falls back to the frozen row; nothing found is unregistered_scope; two live owners is
    duplicate_scope_owner and is not resolved by picking. A resolved state that still carries
    contention is not treated as a resolved owner either — that is the handover window where the
    project binding can still name the outgoing parent while the issue edge names the incoming one.
  • AssignmentView.project_state reads a project's completion from its children via
    linkage.attached() and outstanding(). It returns a reading, not a verdict: the strongest
    state is complete_candidate, because integration and verification are the parent's judgment.
    Partial Done cannot close it (the outstanding set must be empty, not small), a shared project
    cannot be closed by one parent (the set is the project's), and a child's own goal status is
    never consulted.
  • linkage.py itself is untouched; both consumers only read it, and both take the linkage as an
    optional argument so every pre-existing caller keeps its behaviour.

22 targeted tests across test_delivery_relation.py and test_project_completion.py, red-green
verified: against dev's relay source the files cannot even import.

Known limits, recorded rather than closed

  • Whether a refused approval reaches the owning client is unverified. This bridge has no
    forwarding route — the protocol offers no method by which a second client hands an approval
    request to the client that owns the thread. Whether the host independently surfaces it is not
    established: measuring it needs a model actually requesting approval, and the isolated
    CODEX_HOME has no credentials (measured 401). Left unverified everywhere it is mentioned.
  • codex-session-relay still hardcodes never (settings.py:190, :222;
    schema/delivery-attempt.json:63). It belongs to CRW-120 / CRW-115: move a manual install to the plugin install, and own update, failure, disable and removal #58, so the claim here is scoped to
    direct bridge callers, and the relay change is returned to the coordinator as a follow-up.
  • The declared policy is a caller-side assertion, not an operator-controlled one. Routing it
    through ExecutionPolicy is a deliberate Phase 1 non-goal: the declared value is never
    transmitted, so the most a caller can do is correctly name the policy of the thread it addresses.

A parent's recovery or merge-turn return to an idle supervisor whose
approvalPolicy is on-request was refused with unsupported_approval_policy
before turn/start, so the report could never arrive. Reproduced against an
isolated app-server on codex-cli 0.154.0: expected "never", returned
"on-request", attemptedEffects ["thread/resume"], zero turn/start.

The policy was a constructor default no caller could set. The resume
transmitted it, which asked to SET the policy of a thread this bridge did not
create, and findings() judged the answer against it, which refused every
thread that was not already never.

Now the caller DECLARES the policy it believes the thread is on and the resume
carries none at all. A parameter never sent cannot move a policy, whichever
host is on the other end; measured in both directions, omitting it reports the
thread's own policy and does not inherit the CODEX_HOME config default. The
guard is intact and its predicate is honest: it refuses when the observed
policy is not the declared one, so a supervisor whose state moved is caught
rather than written to. Declaring nothing declares never, so every existing
caller keeps exactly its behaviour.

Delivery is not approval servicing. This bridge grants no approval, and its
refusals are now recorded rather than asserted. It has no route to the
thread's own approver and says so instead of implying one.

delivery is derived in reconcile from what actually went out, so a death
between the turn/start frame and its response can never leave a receipt
claiming the message is safe to resend.
Only that component's subdirectoryTree, packageTree and sourceDigest move.
The codex-session-relay entry is left exactly as it is; it belongs to CRW-120
and PR #58 under the standing peer agreement.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 19, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-19T21:29:25.332213Z 0525971 New commits
🔒 Security Review Completed 2026-09-19T21:35:41.014506Z 0525971 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

devin-ai-integration[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

Three findings from PR review.

An explicit approval_policy of null became "never" instead of failing, because
the value was tested rather than the key's presence. A key that was written
must never read as a key that was not; that is the failure the unknown-key
refusal beside it already exists to stop. Keyed on presence now, and refused
locally before any RPC.

One test line carried trailing whitespace. The earlier "git diff --check
passes" reading was taken against a clean working tree after committing, so it
proved nothing; checked against the base now.

The package README still said the approval policy is always never and that
every resumed interactive policy is refused, and the operational bridge
reference omitted approval_policy from expected_settings. A caller following
either would have kept hitting the original failure.
The README lives inside the package subtree, so its correction moves
subdirectoryTree as well. The codex-session-relay entry is still untouched.
APPROVAL_LIMITS said categorically that an approval refused here is not shown
to the thread's approver, while get_capabilities said in the same breath that
host-side forwarding is unverified. Two contradictory claims in one object, and
the categorical one is the unmeasured half.

What is actually established is narrower and is all that is claimed now: this
bridge has no forwarding route, and nothing it does turns such a request into
an approval. Whether the host independently surfaces the same request to the
owning client is left unverified in every place that mentions it -- the
receipt, get_capabilities, the tool docstring, the package README and the
bridge reference.
The codex-session-relay entry stays untouched.
@thisisjun786

Copy link
Copy Markdown
Owner Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review

Security review completed. No security issues were found in this pull request.

Reviewed commit: 0525971f9f

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Brings in #52 (CRW-100) and #66 (CRW-138) so #65 stops being behind.
No file this branch touches was touched on dev, so nothing here resolves a
conflict; the Phase 1 change and its digests are revalidated on the merged tree.
Second merge this turn: dev moved from 0f44f95 to 578925d while the first
merge's validations were running, as #64 (CRW-121) landed. Still no file this
branch touches was touched on dev, so nothing here resolves a conflict.
Brings in #58 (CRW-115), whose files are the relay package and disjoint from
this branch's bridge surface. components.json is touched by both sides, but in
different component entries: dev re-derived codex-session-relay's three digests
and this branch holds codex-thread-bridge's. Both are kept as their owners
produced them.
Brings in CRW-120's landed relation contract, which lifts the Phase 2 gate.
components.json is touched by both sides in different component entries; each
owner's values are kept.
enqueue took its recipient off the relationship row, which fixes its parent at
registration. A completion arriving after the project changed hands was
therefore addressed to the parent that had stepped down, and it passed
assert_assignment_delivery because that check compares against the same frozen
row. The stale answer was also the self-consistent one.

resolve_recipient asks the landed linkage who owns the scope now. It verifies
rather than overrides: a recipient that would disagree with the authorized
direction is not substituted, the disagreement is the finding and it is
refused, so a late report is never filed as the new owner's result.

The reader's three answers stay three answers. An unreadable store is refused
as unreadable and never falls back to the frozen row, because a store that
could not be read has said nothing about the owner. Nothing found is reported
as nothing found. Two live owners are refused without choosing between them.
Each gets its own reason, since collapsing two is indistinguishable from
failing to detect one.

The linkage is an optional constructor argument, so every caller written before
it keeps its exact behaviour, and it is only ever read.
Phase 2 touches the relay, so its three digests move. The
codex-thread-bridge entry is untouched.
devin-ai-integration[bot]

This comment was marked as resolved.

A parent's completion is supposed to be computed from the approved scope's real
child criteria, verification and integration. Nothing computed it: outstanding()
was consumed only by handover, and no code asked whether a project was done.

project_state lifts this class's own per-assignment derivation one level, using
the landed linkage to enumerate the project's live assignments. It returns a
reading rather than a verdict, and the strongest thing it says is
complete_candidate, because integration and verification are the parent's
judgment and are not visible here.

None of the non-answers becomes completion. An unreadable store says so and
does not report an empty outstanding set. A project with nothing attached is
unregistered, which is not every assignment being finished. Two live owners is
ambiguous and is not resolved by picking. Partial Done cannot close a parent
because the outstanding set has to be empty rather than small, work parked on
another parent still counts because the set is the project's and not one
parent's, and a child's own goal status is never consulted at all.
Brings in #47 (CRW-111), which raises the tests job budget from 5 to 10
minutes. That is the gate this branch was blocked on: tests (3.13) needed
about 315s against a 300s limit. No ci.yml change is carried here; the edit
belongs to #47.
devin-ai-integration[bot]

This comment was marked as resolved.

…ding up

Three review findings, all real.

up() turns its state ambiguous only for competing owners, so every other
inconsistency it reports arrived with a resolved state and was delivered
through. owner_drift is the one that matters: the linkage doc says it is
reported at both ends and that the handover sequence passes through it on
purpose, because each assignment moves to the incoming parent before the scope
does. In that window the project binding can still name the outgoing parent, so
the owner equality check agreed with the frozen row and delivered to the parent
stepping down - the exact case this path exists to stop. A resolved state
carrying contention is no longer treated as a resolved owner.

project_state answered unreadable for every production caller, because the
Services property built AssignmentView without a linkage. The reading existed
only in its own tests. It is wired now.

owners() sat outside the error boundary, so a store failure there raised a
database error at a caller asking about completion instead of returning the
documented unreadable shape. It reads the same store as the two queries above
it and is required to classify the answer, so it belongs inside.
devin-ai-integration[bot]

This comment was marked as resolved.

My previous fix refused on any contention up() reported. That list also carries
every retained linkage_conflicts row for the scope, and nothing ever deletes
those: they exist to remember a refused write. So one historical rejected
mutation would have blocked this scope's deliveries permanently.

The two are distinguishable in the record rather than by guesswork. A walk
finding carries a "contention" key - owner_drift, competing_owners,
competing_parents, scope_cycle, instruction_conflict, ambiguous_scope - and an
audit row carries "reason" and no "contention". Only the live findings refuse.
The owner_drift protection is unchanged, including when a drift arrives
alongside an audit row.
devin-ai-integration[bot]

This comment was marked as resolved.

project_state expanded outstanding() into per-assignment states outside its
error boundary. outstanding() returns ids; naming each one's state reads the
same registry and store again, so a failure there raised at a caller asking
about completion instead of returning the promised unreadable reading. Same
escape as the owners() one, one line further down.
devin-ai-integration[bot]

This comment was marked as resolved.

project_state shipped with no caller but its own tests, so the reading existed
without anyone able to ask for it. linkage-completion sits beside the other
linkage query commands, takes --project, and is listed in OFFLINE_COMMANDS
because like the rest of that surface it reads the store and never calls a host.

It prints the four answers distinctly rather than reducing them to a yes:
unreadable, unregistered, ambiguous, incomplete with the unfinished rows named,
and complete_candidate. It never prints "complete" - integration and
verification are the parent's judgment and are not visible to this reader.
Moving the unfinished expansion inside the first error boundary also moved it
before the ambiguity branch, so a project with two live owners plus a failing
state() read answered unreadable instead of ambiguous - replacing the specific
fact with the vaguer one.

Two live owners is establishable from owners() alone, and an unattached project
from attached() alone. Both classifications now run first, and the expansion
keeps its own boundary afterwards so a state() failure is still classified
rather than raised at a caller asking about completion.
@thisisjun786
thisisjun786 merged commit 27cb3d1 into dev Sep 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant