Skip to content

CRW-127 · 역할별 모델 정책과 기존 부모 전환 검증 - #78

Merged
thisisjun786 merged 61 commits into
devfrom
codex/crw-127-role-model-policy
Sep 20, 2026
Merged

thisisjun786 merged 61 commits into
devfrom
codex/crw-127-role-model-policy

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Sep 20, 2026

Copy link
Copy Markdown
Owner

The defect

CRW runs three levels and each is meant to run on a different model and reasoning effort. Nothing in the code knew that. The execution policy asked two questions of every creation and resume — was a pair stated, and does this host allow it — and both can be answered correctly by a task that is still on the wrong model. A project parent created on gpt-6-astra/high passed every check that existed.

The same gap produced the mirror failure on delivery. A child reported to its parent under the pair recorded when that parent was created; the user had since changed it in the UI, so the host answered with something else, and a correct message was withheld as settings_not_preserved — a diagnosis describing the host's answer rather than the record being out of date. Retrying with the new model but the old effort was refused again, because effort names belong to their model and nothing maps one onto another.

What this adds

A third question, is it this role's pair, asked at the two boundaries that can answer it.

The bridge can compare a stated pair against the pair a role runs on. A caller names the role it is creating for, and the answer is checked before any RPC — validate_fresh() runs before ledger.begin(), so a refusal leaves no ledger row, no thread and no worktree. Naming a role is opt-in and the argument is appended to a request only when supplied, so callers that name none behave exactly as before and every retained receipt still replays.

The relay can compare a task's recorded authorization against the role it actually holds, which the bridge cannot: it reads no bindings and only ever knows what a request claimed. A recipient whose record has fallen behind the policy for its own role is refused before any transport call, naming re-recording from a user-attributed source as the recovery. A task created citing one role and registered as another is refused at whichever of registration and binding happens second.

No pair is shipped in code. Values live only in the operator's execution policy file, which the bridge server reads once from its own environment and no tool argument can reach. A cited role the policy does not declare is refused rather than defaulted, because a default in code would be the second source of truth this exists to remove. get_capabilities reports the declared roles so callers state what the host expects instead of what they remember, and the skills now read that rather than carrying a literal pair — which is exactly what integrations.md was doing when the failure happened.

The supervisor is the one role with no declared pair. Its model is the user's own selection, so the policy carries {"expectation": "record"} and a file that tries to pin it does not load. No other role may declare record and exempt itself.

The observed transition asymmetry

Three project parents were asked to resume on a new pair. The one the host had not loaded came back reporting the new pair; the two it had loaded came back reporting the old one, and their messages were withheld.

The cause is not established, and nothing here generalizes from it. Residency was not recorded at the time, and it is the only unrecorded variable that reconciles two measurements already in this repository which disagree about whether a resume sets or detects. The one free experiment that would isolate adoption is a bare resume — which the relay's own settings.py opens by recording as having returned dangerFullAccess for a workspaceWrite task, so it is not run and no variant of it ships.

What this does instead is install the instrument that was missing and behave safely under either answer. The runtime status is recorded before every resume; a notLoaded thread carries echoIndependence: "not_established", so an agreeing echo is reported as agreement and never as preservation. Where a role was named and its pair was not compared against that role's declared pair — a supervisor, or any request citing an exception, which exists to skip that comparison — the send is refused rather than resumed, because transmitting an unchecked pair could restore a value the user has since changed. The fakes are parameterised over both host behaviours and the suites assert safety under each, rather than reproducing a hypothesis.

Preservation

Nothing here writes a task id, a child relation, a project scope, a worktree, a goal, a workflow, a permission or a merge window. Every refusal is pre-RPC, pre-send or pre-binding, so no turn is started and none is interrupted; an active recipient is deferred before the settings gate is reached and is left completely alone. No sandbox or approval setting is transmitted, widened or bundled with a model change.

Acceptance criteria

  1. Role pairs stated at the call and checked against the host's answer. role on all three mutating tools; SettingsContract is built from the authorized pair, so the host-observed comparison that withholds a prompt is already against the role's pair.
  2. The supervisor's setting is not propagated. A policy pinning it fails to load; no other role may borrow its record expectation.
  3. Regressions with zero creation RPCs. Missing, blank, wrong model, wrong effort, wrong role, host-reported mismatch, effort-name substitution in both directions, and default-model inheritance. Proof is fake.calls == [] plus an unknown ledger id, which is structural rather than an assertion about an exception type.
  4. Reuse, resume, recovery and retry. Retry re-authorizes instead of inheriting the first decision; active, idle and notLoaded are distinguished and recorded; a transition is confirmed by reading, never by an accepted request.
  5. The asymmetry is reproduced under both candidate host behaviours, its cause is left unestablished with the reason stated, and an unapplied transition records the UI action without bypassing a guard.
  6. A user exception applies only to its named role and directory, and now must name the role a request cites; an exception with no role may be cited only by a request naming none, which is every caller that existed before.
  7. Evidence stays separate. This PR is source and CI. It installs nothing, declares no roles on any host, and proves nothing about what a provider served.

Activation, and what merging does not do

Nothing here is enforced until an operator declares a roles section. Until then a cited role is refused and role-bound relay deliveries are withheld as role_policy_unconfigured — a retry-safe pre-send hold on the ordinary cadence, so configuring the policy and restarting resumes every held delivery with nothing lost and no turn started meanwhile. This host is presence_only today.

doctor reports the digest this process resolved, because the daemon, the CLI and the hook are different processes and two of them reading two different files would be a second policy source by deployment that neither package can see on its own. It does not fetch the bridge's: that digest comes from get_capabilities, an MCP tool of the bridge server, while this adapter's transport speaks App Server RPC, so the comparison is named and delegated rather than claimed.

Stated limitations

  • A send naming no role is not guarded by the bridge. It reads no binding and cannot tell an unnamed supervisor from a task with no role, and refusing both would stop unrelated work on any host that declared a role for something else. The relay resolves the binding and owns that refusal.
  • The residual wrong-role window is creation to registration, which is the coordinator's own sequencing.
  • resume_params also transmits sandbox, approval policy, cwd and workspace roots. Under an adopting host a stale record could revert a user-tightened permission. That exposure predates this change, which adds no field and widens none; it is now characterized and carried to a follow-up.
  • Applying a model change to an existing task is the UI action plus a re-record. No apply path is built, because that would need the residency evidence above.

Validation

packages/codex-thread-bridge   235 passed   (tests/test_worktree.py excluded locally: this sandbox denies git worktree operations under /tmp, which is environmental and runs in CI)
packages/codex-session-relay  1468 passed, 1 skipped, 81 subtests
python3 scripts/ci/validate.py    Validated 7 skills, local link paths and Python syntax
python3 scripts/ci/plugin.py      Package 0.2.0, 141 files
python3 scripts/ci/contracts.py   ok: true (component digests re-derived after the dev merge)
ruff check on the changed files   All checks passed

scripts/ci/packages.py cannot run in this sandbox: it refuses a temporary directory inside a Git work tree, and the sandbox creates a read-only empty /tmp/.git that its enclosing-checkout guard reads as one while git itself reports no repository there. The underlying suites are run directly with an explicit basetemp; CI on a hosted runner has neither obstacle.

Ten independent review rounds across the two packages closed fourteen blocking findings before this was opened.

Linear: CRW-127


Devin Review

Update, 2026-09-21

Jun's decision moved every project parent from devin/swe-2 at max to xai/grok-4.6 at
xhigh. No source changed, which is the clearest evidence for the thing this pull request
argues: no pair is written in code, so a role's pair moves by editing the operator's policy file
and restarting. What moved here were the documents that record the decision and the fixtures that
name a pair.

The superseded pair is kept rather than deleted, as a fixture proving it is refused for its role
like any other wrong pair. It earns that twice over: the parent and the child now share the
effort name xhigh under different models, so a wrong-effort regression written against two
names that match would prove nothing, and the superseded pair is the case where they differ.

Issue children stay on anthropic/claude-opus-5 at xhigh; the supervisor stays
user-selected. One residual is not mine to close: crw-run/references/start-policy.md, owned by
another task and merged from dev, still restates the superseded pair, and its owner is being told
rather than having this pull request edit their file.

CRW runs three levels that are meant to run on different models, and nothing in
the code knew that. The bridge asks whether a pair was stated and whether the
host approved it; neither question is whether the pair belongs to the role the
task is being created for.

This document is the reader's copy of that decision and, more importantly, says
where the enforced values are: the roles section of the operator-owned execution
policy file, read once from the bridge server's own environment. Neither package
ships a default pair, because a default in code would be a second source of truth
and would answer the approval question on a host that configured nothing.

It also writes down the two limits this work does not remove: on a thread the
host reports as notLoaded, a resume that transmits a pair cannot tell
preservation from adoption, and a supervisor in that state is not resumed at all
because its pair is the one the policy does not derive.
The execution policy asked two questions of every creation and resume: was a
model and an effort stated, and does this host allow that pair. Both can be
answered correctly by a task that is still on the wrong model, because CRW runs
three levels and each is meant to run on a different pair. A project parent
created on someone else's model passed everything that existed.

So a caller may now name the role it is creating for, and a named role is
checked against the pair this host's policy declares for it. The values live in
the operator's file and nowhere else: a pair written into code would be a second
source of truth, and it would answer the approval question on a host that
configured nothing, which is what PRESENCE_ONLY refuses to do. A role the policy
does not declare is refused rather than defaulted.

Naming a role is opt-in, and the argument is appended to the request only when
supplied, so every receipt retained before roles existed still replays. The
refusal is decided in validate_fresh, which runs before ledger.begin and before
any RPC, so a wrong role leaves no ledger row, no thread and no worktree.

Three smaller rules fall out of it. The supervisor declares no pair, because its
model is the user's own selection, and a policy file that tries to pin one does
not load. An exception is now scoped by role as well as by directory, since a
directory is not a task identity. And the send path records the runtime status
the host reported before the resume, plus a note when that status was notLoaded:
a thread the host has to materialize may echo back the pair it was sent, so
agreement there is recorded as agreement and never as preservation.

The fake host grew both behaviours rather than one. Nobody has established which
one the real host has, so the suite asserts the bridge is safe under either.
The role question had two ways around it and one path where asking it was not
enough.

A parent or child could declare expectation "record" and opt out of its own
pair; on a host with no allowlist that authorized anything at all, which is the
failure this file exists to prevent. A supervisor could declare "pair" and have
it silently rewritten into the thing the operator did not ask for. Both
directions are now refused at load.

The second is sharper. Every other role's pair is derived from policy, so a
resume that transmits it lands the thread where policy says it belongs even on a
host that applies what it is sent. A supervisor's pair is the user's own
selection and the transmitted pair is whatever was recorded -- which is exactly
what goes stale when the user changes it. So resuming a supervisor the host has
not loaded could quietly put it back on the model they moved it off. That case
is refused before thread/resume rather than verified afterwards.

Role enforcement was also only regression-tested through create_thread, so
deleting the argument from the other two mutating paths would have left the
suite green. Each now has its own case, and the retained-receipt claim is
asserted against the ledger's fingerprint instead of through a replay that would
have passed either way.

roleExpectation is carried only when a role was cited, and two comments that
still described the resume as a settled detector now say which measurement that
came from and what it does not cover.
…r's word

The supervisor guard asked whether the request said role="supervisor". A sender
that left the argument off got no protection at all: the review reproduced it by
creating a supervisor, simulating a user change in the UI, marking the thread
notLoaded and sending without a role. The send was accepted, resumed the thread
and restored the stale pair. A guard you can skip by not mentioning the risk is
a convention, not a guard.

This bridge cannot read scope bindings, so it cannot know which role a thread
really holds; it only knows what a request claimed. The question it can answer
honestly is where the pair being transmitted came from. A pair checked against a
declared role pair lands the thread exactly where policy says it belongs, so a
host that applies it is doing the intended thing. A pair that was not checked
that way is whatever the caller happened to record, and restoring it could undo
a change the user made -- which is a supervisor's permanent condition, since its
pair is the user's own selection and policy declares none.

So a notLoaded thread is refused unless this request's pair was verified against
a declared role pair. The restriction is scoped to hosts that declared roles, so
a host that never opted in behaves exactly as it did and merging this changes
nothing until an operator turns it on. Authoritative role resolution belongs
with the relay, which can read the binding; that lands with the relay work.

The retained-receipt claim now watches the parameters the bridge hands the
ledger rather than observing a replay that would have passed either way.
…ing what cannot be known

Two defects in the previous guard, both found by review reproduction.

It asked whether the NAMED ROLE declares a pair, not whether this request's pair
was actually compared against one. An exception exists precisely to skip that
comparison, so a role-scoped exception sailed through whenever its role happened
to declare a pair: an exception carrying Astra/high reached a thread whose
declared parent pair was SWE-2/max, and the adopting host took it. Authorization
now records WHICH question approved a pair, and the guard reads that instead of
re-deriving it. An exception is never evidence of a role-pair comparison, and an
exception written for a supervisor would be exactly the stale operator value the
guard exists to keep off the wire.

The second was worse in practice. Scoping the refusal to "this host declared any
role" meant that declaring one role blocked every unnamed notLoaded send on the
host, including tasks with no role at all. That stops unrelated work to guard a
case the bridge cannot even identify: it cannot read scope bindings, so an
unnamed supervisor and a task with no role look identical to it. The guard now
fires only where a role was named and its pair went unverified, which is what
this bridge can honestly answer for itself. The unnamed case belongs to the
relay, which can read the binding, and a test records that boundary rather than
leaving it as something a reader has to infer.
The MCP description and the canonical document both promised that every
unloaded supervisor is refused. The guard fires only where a role was named,
because the bridge reads no scope binding and cannot tell an unnamed supervisor
from a task with no role at all. Promising the stronger thing hid the relay's
binding check, which is the part that actually closes the unnamed case.

The refusal message also told a caller to re-record the authorization, which
cannot clear this refusal: provenance is a property of how a pair was
authorized, not of what it says. It now names the two recoveries that work.
The README explained the execution policy as two questions: was the pair stated,
and was it approved. The third one shipped without an entry, so an operator
reading this file would not learn that a roles section exists, that declaring it
does not force an allowlist onto every other task, or that a supervisor is the
one role whose pair the file may not pin.
The bridge can ask whether a stated pair is a role's pair. It cannot ask whether
a task IS that role: it reads no bindings and only ever knows what a request
claimed. This package holds the bindings, so the half the bridge cannot answer
lives here now.

Two failures made the gap concrete. A task created citing one role and
registered as another passed everything, because nothing compared the two. And a
child reported to its parent under the pair recorded when that parent was
created; the user had since changed it, so the host answered differently and a
correct message was withheld with a diagnosis that described the host's answer
rather than the record being out of date.

The role is a consistency check ON the recorded authorization, never a
replacement for it. A send still verifies against what was actually authorized,
because policy is not approval and a value observed on a host is evidence of
what a thread is running rather than a new approval. What changes is that a
record which has fallen behind the policy for its own role is a named refusal
decided before any transport call, and its recovery says to re-record from a
source attributable to the user.

The two orders both check. Registration compares when the binding already
exists; binding_plan compares when the settings do, deciding while a refusal is
still a returned value rather than after a row is written. Neither undoes the
other's write, and the tests assert per writer rather than a joint atomicity
nobody implements.

Absence of a policy in this process refuses instead of passing quietly: a role
check that silently does nothing when its policy is missing is the original
failure with a green suite on top. It is a pre-send withhold on the ordinary
cadence, so declaring the policy and restarting resumes every held delivery with
nothing lost. doctor compares its digest with the bridge's, because two
processes reading two different files is a second source of truth by deployment
that nothing inside either package can see.
…pair

The instruction layer is where this defect was born. A coordinator created a
project parent on a model it remembered, and another retried a withheld send by
changing the model and keeping the previous effort, because the canonical
document carried a literal pair and a literal pair is something you can carry
away with you.

So the child default no longer states one. It says the pair comes from the role
policy, read from get_capabilities at the time of the call, and the new section
under the role contract records who decides each level's setting: the
supervisor's is Jun's own selection and is never propagated downward, the
parent's and the child's come from policy. An undeclared role is a blocker to
report rather than something to fall back from, because a fallback is a default
and a default is the second source of truth this removes.

bridge.md and relay.md gain the role argument, what the notLoaded observation
does and does not establish, and the rule that a user's model change is followed
by re-recording the authorization from a user-attributed source before the next
send. The task packet's return block now asks which role a pair was checked
against and whether that check was actually made, so a report cannot read as
verified when nothing was compared.
Both packages changed, so the recorded subdirectory trees, package trees and
source digests no longer describe this checkout and verify-definition fails on
all six. Re-derived from the merged tree, which is the only tree the answer is
about.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 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
🔒 Security Review Completed 2026-09-20T14:44:59.514805Z 4f22a2d PR opened
ℹ️ 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.

Six ways the first draft of the role gate returned clean, found by review
reproduction rather than by reading.

A policy declaring only some roles exempted the rest. Missing an entry is not
"nothing to check": the bridge refuses a cited role it has no entry for, and a
parent-only policy quietly passing every child here was the same hole on the
other side. An undeclared bound role now refuses.

The two-writer guarantee had a race. record_settings read and compared the
binding before opening its write transaction, so a concurrent binding could
commit in between and the contradiction was recorded as clean. The read and the
comparison moved inside the same transaction as the upsert.

Relationship resume reactivated archived child bindings through a direct update
that never passed binding_plan, so archiving a child binding, recording the task
as something else and resuming restored it under a role its creation never
cited. That path now asks the same question.

A re-record that named no role deleted the cited one. The cited role is a fact
about how a task was created, and a re-record after a user transition is the
ordinary case that carries none; dropping it there turned a task with a known
creation into one with none, which then bound cleanly to anything. It is carried
forward when the new write does not restate it.

bound_role picked one row out of contradictory state with LIMIT 1 and ignored
superseded_by, so it could compare a record against an arbitrary role or treat a
former owner as current. One task holds one role, so more than one live row is a
store contradicting itself and is refused rather than resolved by recency.

And doctor called get_capabilities through the App Server transport, where it
does not exist: it is an MCP tool of the bridge server, so every ordinary run
would have reported unreachable, which reads as a broken bridge rather than as a
question this surface cannot ask. It now reports this process's own digest and
says plainly where the other side's has to be read from.
devin-ai-integration[bot]

This comment was marked as resolved.

The guard went into the bridge's tool path, and a relay delivery never takes
that path: it resumes through its own transport, so a supervisor the host had
not loaded was still being sent its recorded pair. Review reproduced it. The
same predicate now runs in the relay's pre-send gate, using the runtime status
the delivery already observed.

The rule is the one the bridge applies, for the same reason. Where a role's pair
comes from policy the record has already been compared against it, so a host
that applies what it was sent lands the thread where policy says it belongs. A
supervisor's pair is the user's own selection and the transmitted pair is
whatever was recorded, which is exactly what goes stale when they change it.

That argument also makes an operator exception unsafe to transmit blind, and it
exposed the opposite bug: a task the bridge legitimately created under a
role-scoped exception carries a pair its role's policy does not declare, so the
relay was refusing to register exactly what the operator approved. The cited
exception now travels with the settings and is verified against the same policy
file rather than believed — an id nobody wrote, one written for another role, or
one whose pair does not match exempts nothing.

The new check returns a refusal or None like every other check in the module
rather than a bare boolean, which also keeps it out of the plugin-owned
contention sweep's boolean partition. That file stays untouched.
devin-ai-integration[bot]

This comment was marked as resolved.

Two defects from review reproduction, both in how the check applies rather than
in what it decides.

The reactivation guard ran on every status write, so archiving or cancelling a
relationship whose child record disagreed with the policy was refused. That
leaves the relationship and its binding both live with no way to close or repair
them: a check meant to prevent a wrong owner became the thing preventing its
removal. It now runs only on a transition INTO a live status, which is the
reactivation it was written for. Taking ownership away is never blocked.

settings-show handed the Contested sentinel to check_record, which treated it as
a role name and produced a recovery telling an operator to declare a role named
after a Python object's memory address. A store saying one task holds two roles
is its own finding, reported as one, with the roles listed.

Two comments and the module docstring still said doctor compares the bridge's
digest, which it no longer does and cannot from that transport. They now say
what it reports and where the other side has to be read from. The remaining
broad excepts in the new tests assert on the refusal reason instead.
…nance provenance

Two ways the relay reached a different answer than the bridge about the same
document, both found by review reproduction.

The exception check compared the id, the role and the pair and skipped the
directory, which the bridge checks. So a record naming an exception written for
another checkout was approved here and refused there — and the test I added to
prove the exemption worked was itself recording /parent against an exception
bound to a temporary directory, which means it asserted the hole rather than the
rule. The accessor is replaced by a predicate, exception_covers, that checks
every field authorize() checks against the same values. Handing the entry out
would have disclosed the operator's directories and left the comparison to be
written a second time, which is how a second reader ends up approving what the
first refuses.

The relay's unloaded rule reduced provenance to pair equality, so an exception
authorizing the same values the role pair declares passed here while the bridge
refused it on provenance. An exception that happens to agree still authorized
the pair AS an exception, and the comparison it skipped is the one this rule
depends on. The relay now reads the cited exception, and the refusal carries the
same code the bridge's tool path reports so one situation does not read as two
different causes depending on which surface refused it.
devin-ai-integration[bot]

This comment was marked as resolved.

The bridge evaluates a cited exception before it looks the role up, so an
exception written for a role the roles section does not declare authorizes a
creation there. The relay refused the same record for having no entry for that
role. One document, two readers, two answers. The exception is now checked
first in both relay checks, and a missing role with no exception behind it still
refuses.

The second was worse in effect than in appearance. A role-less re-record always
carried the cited exception forward, so an exception-authorized task could never
reach the role-pair route the refusal itself recommends: re-recording onto the
declared pair kept the exception, and the unloaded guard kept refusing. The
exception is now carried only while it is still doing work — once a record
states the pair the role's policy declares, there is nothing left for it to
authorize and it is dropped. The test that proved the passing case by deleting a
dictionary key now reaches that state through the supported recorder, which is
the only way an operator could.

The two helpers this needed return the pairs rather than a verdict, which also
keeps them out of the plugin-owned contention sweep's boolean partition.
…air disagrees

Every comparison after the citation could succeed without ever looking at it. A
record whose pair happens to equal the declared role pair returns clean on that
equality alone, so an id nobody wrote was stored beside it unverified.

It is not inert once stored. The unloaded guard reads the citation and withholds
a delivery whose pair had perfectly good role provenance, on the strength of
that id — so an unverified citation could take a legitimate recipient out of
service. The citation is now checked first, whenever one is present, against the
same predicate the bridge authorizes with, and a record citing something this
policy does not authorize is refused rather than kept.

The existing unknown-exception case reached the verification only because its
pair differed from the role pair; the new case uses the declared pair and the
same unwritten id, which is the short circuit.
…state chooses

A message states the RECIPIENT's authorized pair, and this file never said so.
Copying the recipient's pair into the sender is how a correction to one level
spreads to another, which is the failure a receiver-settings change invites.

The recipient's runtime state also chooses the mechanism, not only the values.
An idle or unloaded recipient is resumed and carries settings; an active one is
steered into the turn it is already running, and a steer carries no model or
effort at all. A send accepted on a resume and a steer accepted into a live turn
are different facts, and a report that collapses them says more than it saw.
…e of

The document named declaring the roles section as an operator step and stopped
there, which leaves the part that actually goes wrong unwritten: the variable
has to reach every process that asks a role question, they are separate
processes with separate environments, and each holds one snapshot so an edit
changes nothing until a restart.

It now gives the four steps in the order that matters and says plainly that
nothing before the read-back is evidence. A file written is not a process
reading it, a process reading it is not the other process reading the same one,
and either is a different fact from a provider serving the model a host
recorded.
…el-policy

# Conflicts:
#	plugins/crw/skills/crw-plan/references/integrations.md
devin-ai-integration[bot]

This comment was marked as resolved.

Two claims in the new receiving-side paragraphs that the code contradicts, both
found in review.

It said to state the pair the recipient's role declares. An exception authorizes
one specific model and effort for one role and directory, and a task created
under a currently valid one legitimately differs from that pair; stating the
declared pair for it would either fail verification or ask the host to change
what the task runs on. What a send verifies against is the RECORDED
authorization, so that is what the instruction now names, with the ordinary case
and the exception distinguished.

And it said an idle or unloaded recipient is resumed. An unloaded one is resumed
only when its pair derives from the declared role pair, because a resume can
apply what it transmits while the host materializes the thread. A supervisor's
record-based pair and an exception-authorized pair are refused there, so the
instruction now says to wait until the host has the task loaded and read again,
and that the refusal is retry-safe.

A held correction is now named as its own outcome beside a send and a steer,
since a report that has only two of those words will use the wrong one.
devin-ai-integration[bot]

This comment was marked as resolved.

codex and others added 4 commits September 21, 2026 04:21
An exception can stop applying without the pair moving at all: the operator
removes it and the user confirms the task stays where it is. Keying the
carry-forward on pair equality restored a citation the policy no longer
authorized, and the write then refused itself -- leaving the task undeliverable
with no command able to release it, which is the opposite of what a recovery is
for.

record_settings now takes an explicit CLEAR_EXCEPTION and settings-record takes
--clear-exception, so dropping a citation is said rather than derived from a
value that cannot carry the meaning.
devin-ai-integration[bot]

This comment was marked as resolved.

…n unspellable

A declared role pair is still asked the allowlist question, so a file declaring a role on a
pair its own allowed list omits described a role nobody could create: the request matched its
role and then failed execution_not_allowed at the first creation attempt. It is refused when
the policy is read instead, naming the role and the pair rather than the allowlist. Neither
section wins -- letting roles authorize its own pair would make editing it a way to widen the
allowlist. The bridge README's own example had this shape.

CLEAR_EXCEPTION was the string "__clear__", and any non-empty string is a legal exception id,
so a creation receipt citing an exception the operator actually named that was read as the CLI
asking to drop a citation. It is a private object now, so every string arriving stays an
identifier, and asking to cite one and to drop it in the same command is refused rather than
silently resolved.
…el-policy

# Conflicts:
#	scripts/crw_runtime/components.json
devin-ai-integration[bot]

This comment was marked as resolved.

The derivation reads HEAD, so re-deriving while a merge is only staged records
the pre-merge subtree: this file carried HEAD^1's codex-session-relay tree and
every install refused the checkout it was meant to describe.
…eption

Jun authorized task 01a0b98e to receive idle callbacks on ollama-cloud/glm-5.3 at
xhigh in /home/jun/code. Written as a role-scoped, directory-scoped exception id, so
the parent pair stays xai/grok-4.6 at xhigh for every other project parent and a
reader asking what a project parent runs on still gets the role's answer.

Nothing new is mechanised: the role key, the cwd list and the overriddenBy receipt
field are already there and already tested. What was missing was the worked example
showing that moving one task is an exemption from the role's answer rather than an
edit to it.
devin-ai-integration[bot]

This comment was marked as resolved.

Line 37 still named swe-2 at max, which Jun superseded for every project parent on
2026-09-21. It reads xai/grok-4.6 at xhigh now, with the old pair kept as what it
actually is -- a migration fixture for reproducing the transition and for recognising
a superseded value as superseded.

The named exception gets its own paragraph so nobody reads one task's authorization
as a new default: it covers the task it names, is not propagated to other parents or
down to children, and is recorded rather than inferred from an observed pair. The
paragraph also points at the roles section of the host's own execution policy, which
is the value a run actually reads. Fields, precedence and re-adjudication untouched.
…blish

With --project the registration binds the relationship's child task to its issue scope
as a child, so a citation naming parent for that task is a contradiction. The
prevalidation compared the cited role against itself, which answered a different
question and passed: the relationship and the binding both committed, and only the
settings write after them refused. The refusal was reported and a live wrong-role
assignment stayed behind it -- exactly what this function exists to prevent.

It now reads the binding each task already holds, falls back to the role this write
would establish, and only then to the cited role, so the pair check it already
performed for an unbound task is unchanged. Contested bindings are refused here for
the same reason record_settings refuses them.

Three review corrections to the prose: the transition recipe named a command that does
not exist (codex-session-relay settings-record, not relay settings record); the
exception section claimed a scope the mechanism does not enforce, since the policy file
has no task-identity field and scopes by role and directory; and the worked example
published a task id and a personal checkout path, which are operational values read
from the host policy and the Linear record rather than from a document.
devin-ai-integration[bot]

This comment was marked as resolved.

…s replayed

binding_plan ran the role check before reading the existing binding, so it also asked
it of a call with nothing to establish: a task already live on a scope, re-claiming it
with the same arguments, started failing after an unrelated edit to the parent pair.
These APIs promise that repeating a claim converges on the existing record, and a
record going stale against a new policy already has its own refusal at send time; it is
not a reason to break an idempotent recovery.

The check moves into _binding_refusal, which only the insert, reactivate and handover
paths run, so a live replay never reaches it and every path that establishes a binding
still adjudicates against the policy in force.

settings-show gains "deliverable" beside "usable". They are different questions and
folding them together loses one: "usable" is about the record's own completeness and is
paired with "missing", so a complete record refused for its role has to be able to say
both. A preflight written before roles read "usable", got true, and went on to a send
the relay withholds.
devin-ai-integration[bot]

This comment was marked as resolved.

…olicy clear

The snapshot was supposed to be the process's, and a lazy first read made it the
snapshot of whenever a role question first came up. A daemon could start under one
version of the file, serve unbound work for hours, then adopt an edit the bridge had
never seen -- two processes enforcing different policies with neither restarted, which
is the second policy source this is built to keep visible. main() now resolves it
before any work, the way the bridge builds its own policy in its main(). It cannot fail
startup: an unreadable or absent policy resolves to Unresolved, which withholds.

settings-show reported deliverable: true for a bound task whose process cannot read a
policy, because an Unresolved policy is falsey and the check was simply skipped.
Delivery refuses that same state, so the command disagreed with the only consumer that
acts on its answer. It now carries the role_policy_unconfigured finding: not checkable
is not the same as checked and fine.

And the registration preflight now asks require_usable too, of every settings value
rather than only the ones naming a role. An incomplete file used to pass here, commit
the relationship, and be refused over the top of it -- the same partial registration
the role check closed, arriving through a second door.
@thisisjun786
thisisjun786 merged commit 0922cdc 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.

2 participants