CRW-127 · 역할별 모델 정책과 기존 부모 전환 검증 - #78
Merged
Merged
Conversation
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.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
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.
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.
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.
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
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.
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.
…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
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.
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.
…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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/highpassed 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 beforeledger.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_capabilitiesreports 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 whatintegrations.mdwas 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 declarerecordand 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.pyopens by recording as having returneddangerFullAccessfor aworkspaceWritetask, 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
notLoadedthread carriesechoIndependence: "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
roleon all three mutating tools;SettingsContractis built from the authorized pair, so the host-observed comparison that withholds a prompt is already against the role's pair.recordexpectation.fake.calls == []plus an unknown ledger id, which is structural rather than an assertion about an exception type.active,idleandnotLoadedare distinguished and recorded; a transition is confirmed by reading, never by an accepted request.Activation, and what merging does not do
Nothing here is enforced until an operator declares a
rolessection. Until then a cited role is refused and role-bound relay deliveries are withheld asrole_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 ispresence_onlytoday.doctorreports 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 fromget_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
resume_paramsalso 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.Validation
scripts/ci/packages.pycannot run in this sandbox: it refuses a temporary directory inside a Git work tree, and the sandbox creates a read-only empty/tmp/.gitthat 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
Update, 2026-09-21
Jun's decision moved every project parent from
devin/swe-2atmaxtoxai/grok-4.6atxhigh. No source changed, which is the clearest evidence for the thing this pull requestargues: 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
xhighunder different models, so a wrong-effort regression written against twonames that match would prove nothing, and the superseded pair is the case where they differ.
Issue children stay on
anthropic/claude-opus-5atxhigh; the supervisor staysuser-selected. One residual is not mine to close:
crw-run/references/start-policy.md, owned byanother 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.