fix(acp): seed claude settings from advertised models, across sessions - #8530
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of I've reviewed the diff (new Design-Verdict: CONCERNS Orphan detection is process-local, so a second Crew process can adopt and later delete a live first process's seed — the module's own "live sibling" invariant, unenforced across processes. Watch
Suggestions
[DESIGN-REVIEWED] 06c90aa |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS Every item traces to the reported 200K/orphan defect, but the change reverses a decision What this change shipsIntent: stop a claude session silently resolving to the 200K window, and let a seed orphaned by a killed session (including a stale
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 06c90aa |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @iamwhatever overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've verified the architecture. Now assessing the two candidates against the falsification bar: Candidate 1 survives. Traced end-to-end in code I opened: Classification: it's a regression on a diff-added path, but revert isn't available (the PR needs the cross-session adoption) and the fix-forward requires a new cross-process-liveness abstraction (pid + start-time in the record). The lost file is a regenerable Crew-authored seed, not irreversible user data, and mid-session privilege escalation depends on unverified adapter re-read behavior I can't ground — so the credential/data-loss exception doesn't clearly apply. → FINDING. Candidate 2 dies under falsification: it requires a sub-millisecond overlap of A's No Step 2 additions survive the same bar. Two concurrent FINDING — src/kiro_crew/acp/seed_provenance.py:397 — the live-holder guard [OPUS-REVIEWED] 06c90aa Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
add522b to
063eb76
Compare
063eb76 to
2542ef2
Compare
|
fixed — span=0dfda262228b
Legitimate, and blocking for the right reason: Fixed by moving the gate to the call site as a positive membership test: if self._seeds_local_settings:
await self._reseed_after_capture()
Pinned by |
|
fixed — span=c9a9b420796a
Legitimate and the most serious of the set. The record is not inert bookkeeping — an entry in it is the grant. Fixed on all four floors:
Also added to
|
|
fixed — span=0dfda262228b
Legitimate. The adoption branch took the claim and recorded the digest on the way in, before the re-seed. If that write then failed — ENOSPC, EIO, a read-only mount — the process was left holding a claim on bytes it had not written, and Three changes, and they only work together:
|
|
fixed — span=c9a9b420796a
Legitimate, and reachable rather than theoretical: the seed runs under Fixed with a module-level Two things left deliberately outside the lock, both documented in place:
Three tests: |
|
fixed — span=53072e3c6dc1
Legitimate as style. I checked whether the deferral was load-bearing before moving it:
(Marker corrected: this finding was stamped on |
|
fixed — span=04fa07bf6fd5
Legitimate. The comment on the adoption branch described state being taken there that the code has since moved to after the write, so it would have sent the next reader looking for a claim that is not on that branch. Rewritten to say what the branch actually does and why:
Two further stale references in the same file went with it — both said |
2542ef2 to
0ded9a1
Compare
|
fixed — span=0dfda262228b
Legitimate, and the fix was mine to get wrong twice. My earlier round moved the Fixed as suggested — the re-seed now rides an existing adapter-only path rather than getting one of its own. Step 7 is deleted, and That is also the honest home for it rather than a place to hide it: the step exists because the backend advertises its own model list, which is exactly the capability that branch tests. One real consequence, handled: riding an earlier branch means the re-seed now runs before Three tests pin the shape, not just the behaviour: Fixed in |
|
fixed — span=c9a9b420796a
Legitimate, and my docstring stated the false premise out loud: "a surviving on-disk entry is inert, since adoption re-verifies the digest against whatever the path holds now". The digest check does not make it inert, because a file can hash to the recorded bytes again legitimately — most plainly when a user committed the generated seed to their repository and later restored it. Reset unlinks the file and drops the entry from memory only; the next process reloads the sidecar, finds a matching file at that path, adopts it, overwrites it with this install's Fixed as suggested — On the on-loop cost, which is why it was memory-only in the first place: Tests: Fixed in |
fa7fc58 to
963a45c
Compare
963a45c to
93a9c03
Compare
|
Human-override rationale — GPT Both current GPT findings are the same class — data loss only under concurrent writers to a single
Both are reachable only when two Crew processes write the same An earlier revision of this PR added cross-process hardening (a per-path liveness lease + inode-pinned deletes) precisely to close these edges. For a single-writer tool that machinery is over-engineering — a file-locking/merge subsystem defending a scenario the product does not have — and removing it surfaced these two remaining edges of the same class. The intended behavior is last-writer-wins with atomic writes: every value written is current, and the worst case is a settings file being rewritten, which self-heals on the next seed. The provenance record still protects the one case that matters for a single user: never clobbering a file the user has hand-edited. Recording a maintainer |
|
/ai-review override gpt 93a9c03: Single-user personal tool — two Crew processes concurrently writing one work_dir .claude/settings.local.json is not a supported scenario, so the flagged cross-process data-loss edges cannot occur under single-writer use and last-writer-wins atomic writes are the intended, sufficient behavior (see rationale comment above). |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
A claude session could be pinned to the 200K context window even when the account is served a 1M one. Three defects compounded: 1. The `availableModels` allowlist Crew seeds into `<work_dir>/.claude/settings.local.json` fell back to the hand-maintained static model registry whenever the advertised-model cache was cold. The adapter merges `availableModels` union+dedup by base model name, so a registry list that has not caught up REPLACES the adapter's correct provider-derived list with one carrying no `[1m]` id for the model actually picked. A cold cache now seeds neither `availableModels` nor `model`, which lets the adapter resolve from its own provider list. 2. The seed runs before `session/new` (it must -- `permissions` has to be on disk first) and nothing re-seeded after the capture that warms the cache, so the cold-cache seed was the file's final state and the startup model fold ran against a still-cold cache. `_initialize_session` now re-seeds after `_apply_startup_model`, and the fold reruns there. 3. Ownership of the seed was proven from per-instance memory only, so a file left behind by a killed session read as a stranger's file to the next one -- taking the leave-it-alone branch forever, freezing a stale allowlist and a stale `permissions.defaultMode` (up to an inherited `bypassPermissions`) as permanent project state. `kiro_crew.acp.seed_provenance` records the size and sha256 of the bytes Crew wrote, under Crew's own data home, so a later session can recognize its own orphan and re-seed it. The record is a provenance credential, not a permission grant: adoption additionally requires the file on disk to still hash to the recorded digest, so a user-authored file -- or a Crew seed the user has since edited -- is still left untouched. Lookups are served from memory because `_reset_state` consults ownership on the event loop; the ownership read is bounded, `O_NOFOLLOW`, `O_NONBLOCK` and capped one byte past the recorded length so a file that grew after the fstat is rejected on length rather than matching a prefix hash.
93a9c03 to
06c90aa
Compare
|
Re-applying human override for the new head
The two GPT findings ( |
|
/ai-review override gpt 06c90aa: Mechanical rebase of overridden 93a9c03 (only a sandbox.py read-only-leaf tuple union); the two flagged data-loss edges are reachable only under concurrent writers to one work_dir, which cannot occur for this single-user single-writer tool — see rationale comments above. |
Human judgment recorded@iamwhatever marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
A claude session could be pinned to the 200K context window even when the account is served a 1M one. Evidence from a real
work_dir, seeded Sep 3:Two things are wrong there: the bare 200K sibling ships next to its
[1m]spelling, and themodelkey names an id that appears in no entry of the allowlist beside it.Why it matters
permissions.defaultModesurvives its session. The pre-fix writer left an orphaned seed in place permanently with the adapter still reading it — including one carryingbypassPermissions, which takes every tool call out of the host gate. Re-seeding is the only path that cleans that up.What changed (motivation → approach → change)
Three defects compounded, and they only make sense together.
Root cause 1 — the allowlist fell back to the static registry on a cold cache.
seed_available_models()returnedmodel_registry.jsonentries whenprovider_models.jsonhad never been written.[1m]is a context-window modifier on a base model, and claude-agent-acp mergesavailableModelsunion+dedup by base name — so a registry list that has not caught up does not augment the adapter's provider-derived list, it replaces it with one carrying no[1m]id for the model actually picked. The static registry has no Opus 5 entry at all.Root cause 2 — nothing re-seeded after the model capture. The seed must run before
session/new(permissionshas to be on disk first), and the cache is only warmed bysession/new. So the cold-cache seed was the file's final state, and the spawn-timeresolve_wire_model_idfold ran against a still-cold cache and was a no-op — the bare id it then sent is exactly what resolves to the base window.Root cause 3 — ownership of the seed was per-session memory only.
_claude_settings_is_still_ours()asked "did this client instance create it, and are the bytes still the ones this instance wrote". A file left behind by a session killed before_reset_stateread as a stranger's file to every later session, which took thealready exists; leaving it as the authoritative project settingsbranch — forever. This is why 1 and 2 could not be fixed on their own: without a cross-session ownership credential, no later session is ever allowed to write, so awork_dirseeded once was frozen.The change, one part per cause:
Provider-advertised only.
seed_available_models()returns the ids the provider actually served on a realsession/new. A cold cache returns[], and the writer reads that as seed no model keys at all —availableModelsandmodelare written together or not at all, so the adapter resolves from its own provider list rather than from a poisoned partial one. This also takes the hand-maintained registry off the model-selection path, so it no longer needs per-provider upkeep to stay correct.Re-seed after the capture. A new
_reseed_after_capture()runs immediately after each_capture_available_models— thesession/loadresume path and thesession/newpath both — and_apply_startup_model()now re-folds the model against the by-then-warm cache. Failure warns rather than killing the session.Harness parity (
AUTOSDE.yamlH13) decided where it is called from. The rule tests "did the kiro path change at all", not "does it still work", so adding a step to_initialize_sessionwould violate it however the predicate is spelled — a call-site gate is no more exempt than an in-method one, because the newifand the newawaitare the change. So the re-seed is a second statement inside theif self._uses_advertised_model_selection:branch that already existed inmainbeside the model-cache persist;_initialize_sessiongains no conditional of its own, and no line Kiro executes moves. That is also the honest home for it: the step exists because the backend advertises its own model list, which is exactly the capability that branch tests. The two capability sets are independent opt-ins, so_seeds_local_settingsis tested inside the method rather than assumed from the caller.Riding an earlier branch means the re-seed now runs before
_apply_startup_model, so_write_claude_local_settingsfolds the id it writes itself viaresolve_wire_model_idinstead of depending on that step having foldedself._modelfirst. That removes an ordering coupling between two distant steps whose failure would have been silent — a bare id names a model absent from theavailableModelslist shipped beside it, which is precisely the shape that resolves to the base 200K window.kiro_crew.acp.seed_provenance— a sidecar under Crew's own data home (config_dir()/settings_seeds.json,0o600) recording the size + sha256 of the bytes Crew last wrote to a given settings path, so a later session recognizes its own orphan and re-seeds it. There is deliberately no format marker: adoption is decided by the digest alone, so nothing would branch on aversion, an unrecognized value could only be ignored — which is what an absent marker already means — and if a second format ever exists, its marker's absence identifies the first.The record is a provenance credential, not a permission grant: adoption additionally requires the file on disk to still hash to the recorded digest, so a user-authored file — or a Crew seed the user has since edited — is still left completely untouched.
Adoption is also scoped to an owner token (
self._seed_owner), because a durable record is for an orphan and a sibling still running in this process has not left one. Two keyless clients share the defaultwork_dir(config_dir()/workspace), so "Crew wrote it" would otherwise collapse into "any Crew client may take it": a second session would re-seed a live session's file with its ownpermissions.defaultModeand unlink it on its own reset. Live claims are process memory, so a record reloaded from the sidecar has no holder by construction — which is exactly the orphan case it exists for.Adopting takes the live slot via
seed_provenance.claim(), and that call is the decision, not bookkeeping after one: ownership is read at a moment, so two clients starting together can both read the same orphan as adoptable, and both would then re-seed it.claim()is a singledict.setdefault, so exactly one wins however they interleave; the loser falls to the leave-it-alone branch. (The create path needs no equivalent —O_EXCLalready arbitrates it, and anatomic_writerename cannot, since it replaces whatever is at the name.)The record and the file move together, or neither moves. The re-seed of an adopted orphan overwrites by stage-and-rename (
atomic_write), so no partial write is ever observable at the path the adapter reads. The instance flag and the recorded digest are set after the write lands, never before: moving them first would leave the next reset unlinking a file whose bytes Crew never wrote.The live slot is the exception, and it has to be —
claim()is the race arbiter, so it cannot wait for a successful write without letting two clients both decide the same orphan is theirs. So the write is wrapped, and a claim whose write does not land is handed back withseed_provenance.release(). Without that, a winner that failed to write would hold the slot for the life of the process, every later client would read the orphan as a live session's file, and a stalebypassPermissionsin it could then be neither repaired nor removed.release()drops only_LIVE, deliberately not the record: the record is what makes the path adoptable at all, so clearing it would be the same harm rather than a milder one.except BaseException, because aCancelledErrorthrough the write wedges the slot identically.Teardown is the same statement read backwards, and its ordering is load-bearing. Removing the seed moved out of the synchronous
_reset_stateinto a newasync _discard_claude_settings_seed(), awaited by every caller immediately before the reset. Three reasons, all of which the sync version got wrong:forget()persists, and now reports whether the sidecar on disk actually stopped naming the path. Dropping the entry from memory alone left the sidecar naming a file Crew had just deleted, and re-verifying the digest does not neutralize that: a file can legitimately hash to the recorded bytes again — most plainly when a user committed the generated seed and later restored it — and the next process would then adopt that user's file, overwrite it with this install'spermissions.defaultMode, and unlink it on reset. Unlink-first-revoke-after leaves exactly that window open on a failed sidecar write, so the grant dies with the file it described, and it dies first.asyncio.to_thread. The ownership hash, the sidecar write and the unlink are all blocking, andforget()additionally waits on a lock a worker thread holds across a write — so calling it inline put both a synchronous write and that wait on the gateway's single event loop.AUTOSDE.yaml'sno-blocking-call-on-event-loopnames this directly and isblocking: true; the earlier "the write is bounded and the unlink already blocks there" defence does not survive it, and the rule is right that a heartbeat must not queue behind teardown I/O.records the bytes still on disk, because the alternative is a file no session is permitted to touch — the frozen orphan this module exists to end. A file whose bytes the user replaced mid-session is left entirely alone._reset_statekeeps one in-memoryseed_provenance.release()as a net: a client discarded without the async step still cannot wedge the path behind a claim nobody is using, and the durable record deliberately survives, because the file does.Publication of the sidecar is serialized under a module
threading.Lockheld across mutate → prune → snapshot → write, because the seed runs underasyncio.to_threadand two threads snapshotting between each other's mutations would drop one of the records;forget()publishes under the same lock for the same reason. The genuinely loop-side, non-publishing entry points (recorded,release) are deliberately lock-free — singledictoperations are atomic under the GIL. Cross-process last-writer-wins on the sidecar remains, and is benign: a lost entry reads as "not ours", which refuses.A lock-free lookup is what makes the publish ORDER load-bearing. Because
recorded()reads both maps without the lock, a sibling client reads them betweenrecord()'s statements — so_LIVE[key] = owneris set before the_RECORDSentry, never after. Reversed, the seed this client has just written reads as an orphan for that instant (recorded, no live holder, digest matching the file now on disk) and the sibling would adopt a live session's file. It matters most on the create path, which has noclaim()of its own —O_EXCLarbitrates that one — so that assignment is the only thing making a fresh seed look live. Thepop-then-insert that moves an entry to the back of the prune order leaves its own instant where the path looks unrecorded, and that direction is the safe one: unrecorded reads as "not ours", which refuses.There is one prune and no cap, and that is the whole bound. An entry survives only while a file is actually at its path; a
_persistdrops every entry whose file is gone, unconditionally (nothing remains there to overwrite, adopt or clean up, and dropping the live claim alongside it is the same statementforget()makes after its unlink). An earlier revision put an_MAX_ENTRIEScap on top of that, and the cap was the bug rather than a tuning problem: it can only ever evict entries whose file still exists — the dead ones are already gone — and those are precisely the adoptable orphans this module exists to keep. Evicting one makes its path unrecorded, so its owner can no longer recognize it on reset and no later session is permitted to repair it, turning a stalepermissions.defaultMode(up to an inheritedbypassPermissions) into permanent project state for the oldest work dir. That is the exact failure being fixed, re-manufactured by the cleanup. Growth is already bounded by the prune: the sidecar cannot outgrow the set of seeds actually on disk, which is the only bound that means anything here. The one exemption is the key arecordhas just written (_persist(keep=...)), so "record then look it up" answers the same way regardless of how the caller sequenced its own write.The sidecar is on every write floor, because an entry in it is a grant. A digest naming a settings file the user hand-wrote would make Crew's own trusted writer overwrite that file and unlink it on reset — so
settings_seeds.jsonis added tosecurity._WRITE_PROTECTED_HOME_PATHS(writes blocked, reads still allowed: the record holds no secret, and an operator should be able to see why a seed was or was not adopted), to_WRITE_PROTECTED_BASH_LEAVES, to_BARE_TOKEN_PROTECTED_LEAVES(acd ~/.kiro/crewwould otherwise defeat the home-anchored patterns; the name is distinctive enough that the false-positive cost is confined to commands that genuinely mean this record), and tosandbox._CREW_READONLY_LEAVES— READONLY rather than hidden, since the write is the whole risk. It is also in_CREW_PRECREATE_READONLY_FILE_LEAVES, becausemount(2)cannot seal an absent path and this file only exists once a session has actually seeded a work dir: on every install that has not, it would otherwise be exactly the absent-and-therefore-writable name that list exists to close. An empty{}ceiling reads as "no record" → "Crew owns no settings file" → leave it alone, which is identical to absent and fails toward refusal.Properties the callers depend on: nothing is added to the user's project; lookups never touch the disk (
_reset_stateconsults ownership synchronously on the event loop); every failure mode answers "not ours". The ownership read isO_NOFOLLOW | O_NONBLOCK,S_ISREG-checked, and capped one byte past the recorded length, so a file that grew between thefstatand the read is rejected on length instead of matching on a prefix hash — strictly stronger than the exact-size read it replaces.Tests
test/test_acp_seed_provenance.py, 72 new tests in six groups:TestProvenanceRecord(28) — record/recognize roundtrip, an unrecorded path is unowned,forgetdrops the claim,test_a_record_outlives_the_process(record → drop the in-memory view →_load(), and the reloaded record has no live holder), the sidecar is0o600, a corrupt sidecar and a malformed entry both degrade to unowned,test_a_lookup_never_touches_the_disk(monkeypatches_sidecar_pathto raise), andtest_the_sidecar_carries_seeds_and_nothing_elsepins the absence of a format marker as a decision rather than an omission. Durability of the revoke getstest_forget_survives_the_process: forget → drop the in-memory view →_load(), and the path is unowned even after the exact recorded bytes are restored at it.release()gets two: it hands the slot back without disowning the record (so the orphan stays adoptable), and a loser cannot use it to evict the winner. Four tests pin the locking:test_the_publish_happens_under_the_record_lock(patchesatomic_writeto observe_LOCK.locked()at write time),test_forget_publishes_under_the_same_lock_record_uses,test_concurrent_records_all_survive(8 threads released off aBarrier; all 8 keys must be in the persisted sidecar — this is the test the un-serialized version fails), andtest_the_lookup_and_the_claim_do_not_take_the_lock, which holds_LOCKand then callsrecorded()/claim()/release()— it would deadlock, not merely slow down, if any of them ever grew a lock. Because that lookup is lock-free, the publish ORDER insiderecordis load-bearing, and two tests pin it from inside the mutation (adictsubclass over_RECORDSthat observes at__setitem__time):test_the_live_owner_is_published_before_the_recordasserts the live claim is already attached, andtest_a_sibling_never_sees_a_fresh_seed_as_an_orphanstates the same thing as its consequence — at the instant the record becomes visible, a sibling'srecorded()must already getNone. The prune gets three, replacing the cap tests an earlier revision added:test_an_entry_whose_file_is_gone_is_prunedrecords 10 paths with no files and requires only the just-written one to survive;test_an_adoptable_orphan_is_never_pruned_however_many_there_aredrives 200 released orphans whose files exist and requires all 200 to stay adoptable, so it fails on any version that reintroduces a cap; andtest_a_live_seed_is_never_pruned_eitheris the same rule from the live side, including that surviving does not cost the owner scoping.forget's return value gets two, because it is what authorizes a deletion:test_forget_reports_true_only_when_the_disk_agrees(Trueafter a real publish,Falseon a failed one — with the entry restored in memory so the file the caller was told to keep is still adoptable) andtest_forget_refuses_to_revoke_a_live_siblings_claim, which also asserts the refusal publishes nothing.test_forgetting_an_unrecorded_path_writes_nothingkeeps a reset for a never-seeded path free of I/O. Owner scoping gets four of its own: a live owner's record is invisible to a sibling, a sibling cannot revoke a live claim, the record becomes adoptable once its owner lets go, andtest_only_one_adopter_can_claim_an_orphanpins the atomic claim (two clients both read the orphan as adoptable; exactly one may take it, and the winner may re-claim its own slot).TestCrossSessionAdoption—test_an_orphaned_seed_is_reseeded_by_the_next_sessionis the headline: session 1 seeds cold and dies, session 2 adopts and writes a coherent file. Every cross-session test goes through a_the_owning_process_died()helper that clears only the in-process live claims — what akill -9actually leaves behind — so none of them can pass by accident. Plus the negatives that keep the credential honest: a user-authored file untouched, a Crew seed the user edited untouched, an orphan with no record untouched,test_an_orphaned_bypass_mode_is_overwritten_not_inherited,test_a_live_siblings_seed_is_left_alone(and the sibling's own reset cannot revoke the live claim), an adopted seed removed on reset, a symlink refused before ownership is considered, a grown file is not ours.TestPostCaptureModelResolution— the startup model folds onto the[1m]spelling, an unadvertised model is left exactly as configured, the re-seed runs off the loop, a failed re-seed does not kill the session,test_the_cold_seed_becomes_coherent_after_the_capturewalks one session start to finish,test_the_seed_never_ships_a_base_window_sibling, and a non-seeding backend writes nothing. Three pin the H13 shape, not just the behavior:test_the_reseed_rides_an_existing_adapter_only_branchreads both sources and asserts_initialize_sessioncontains noif self._seeds_local_settings:at all, that the re-seed appears exactly twice and only as a second statement inside the pre-existing_uses_advertised_model_selectionbranch, and that the capability test lives inside the method;test_session_init_reseeds_right_after_every_model_captureasserts both the resume and the fresh-session capture are followed by the re-seed with nothing but that gate and the persist between them; andtest_the_written_model_id_is_folded_by_the_writer_itselfforbidsdata["model"] = self._model, so the ordering coupling to_apply_startup_modelcannot come back.test_a_harness_that_seeds_no_settings_file_writes_nothingdrives the in-method gate for real.TestTheRecordIsOnEveryWriteFloor(5) — the sidecar is the leaf the floors name;is_sensitive_write_pathrefuses it whileis_sensitive_pathallows the read, for both Crew home prefixes; the shell gate refuses every spelling including the bare token; the sandbox seals it read-only even when absent; and an empty materialized ceiling parses to the same "no record" answer an absent one gives.TestOwnershipTracksTheFilesystem(11) — a re-seed that raisesOSErrormid-write leaves both the recorded bytes and the claim intact; the path is still adoptable by a later session once the disk stops failing; the create path still passesO_EXCL(asserted on the observedos.openflags, so it cannot be satisfied by a rename); and a failedunlinkon reset keeps the claim rather than orphaning a file Crew still owns. Three new ones cover the two directions the reviewer found:test_a_failed_adoption_hands_the_claim_back_within_the_processfails a write after a successful claim and then requires a later client in the same process to adopt and repair the orphan — deliberately with no intervening_the_owning_process_died(), which is the whole point, since a restart was previously the only thing that freed the slot;test_a_failed_adoption_does_not_evict_a_live_siblingkeepsrelease()from becoming a lever a loser can pull; andtest_a_successful_unlink_revokes_the_grant_for_goodwalks the data-loss path end to end — seed, reset, fresh process from the sidecar alone, user restores the byte-identical file they had committed, and the next client must leave it completely alone and not delete it on its own reset.Four more cover the async teardown, and three of them were checked against deliberate mutants rather than only against the pre-fix tree.
test_the_revoke_lands_before_the_unlinkobserves both steps and asserts the sequence, so swapping them fails it (and failstest_a_failed_revoke_keeps_the_file_and_the_recordtoo, which is the harm stated as an outcome: the file is simply gone).test_a_failed_revoke_keeps_the_file_and_the_recordfails the sidecar write, requires the seed and its record to survive, and then lets a later session actually repair the orphan once the disk recovers.test_the_teardown_never_touches_the_disk_on_the_event_loopcallsasyncio.get_running_loop()from inside both the revoke and the unlink and requires neither to find one — callingforget()inline instead of through a thread fails it with['forget'] == [].test_a_replacement_the_user_wrote_is_left_alone_on_teardownre-checks the bytes at teardown, so a file the user replaced mid-session is not deleted on the strength of the instance flag alone.TestTheGrantIsATransaction(4) — the three blocking findings turned into tests.test_a_seed_whose_grant_is_not_durable_is_withdrawnfails the sidecar publish (read-only data home) and requires the just-written seed to be unlinked rather than left behind as an unownedpermissions.defaultMode;test_a_failed_record_rolls_the_memory_back_to_the_sidecarrequiresrecord's in-memory state after a failed publish to equal what a restart would read;test_a_cancelled_teardown_still_settles_the_seedcancels teardown mid-settle and requires the shielded transaction to finish removing the seed and leave nothing adoptable; andtest_every_discard_call_site_resets_in_a_finallyis an AST check that every_discard_claude_settings_seedcall site pairs with_reset_statein afinally. Both record tests fail against arecordthat discards the persist result; the AST test fails against any call site that drops thefinally. Three more pin the settings-file TOCTOU fix:test_claim_pathname_moves_ours_aside_and_leaves_a_stranger(the inode-pin primitive — Crew's file is moved aside atomically into a freshmkstempname and a stranger's is restored untouched),test_a_replacement_that_races_the_teardown_delete_survives(a user save landing in the check-to-delete window is kept, not deleted), andtest_a_project_file_at_the_move_aside_name_is_not_clobbered(a project file already sitting at the old fixed.crew-gcname survives, because the capture destination is now a uniquemkstempname that provably did not pre-exist); the first two fail against a check-then-mutate-by-pathname teardown, the third against a fixed-name capture. AndTestProvenanceRecordgainstest_a_concurrent_processs_record_is_not_dropped, which fails against a_persistthat publishes a process-local snapshot instead of reload-merging under the cross-process lock.Existing tests were updated rather than deleted, because their old assertions encoded the bug:
test_acp_session_mcp.pygainstest_seed_omits_both_model_keys_on_a_cold_cacheandtest_seed_never_writes_a_model_without_the_list_it_must_match;test_acp_client_more_coverage.py'stest_seed_falls_back_to_registry_on_cold_cachebecomestest_cold_cache_seeds_no_model_keys_at_all; and intest_model_registry.py,test_seed_falls_back_to_registry_on_cold_cachebecomestest_seed_is_empty_on_cold_cache_rather_than_registry_derived(which also pins thatavailable_models()still answers the picker/window questions — only the seed path stopped reading the registry), whiletest_seed_drops_base_window_sibling_of_a_1m_idnow drives the dedup off an advertised list instead of the registry.The teardown tests across all three files now drive the real pair —
await client._discard_claude_settings_seed()thenclient._reset_state()— through a small_teardownhelper rather than calling_reset_state()alone, so a test that drifts from the production ordering fails instead of passing on a shape nothing uses. That includes the tests asserting a file survives teardown (a user's own file, a live sibling's seed, a never-seeded path), which is where an over-eager discard would show up.test/test_security.py'sTestBareTokenProtectedLeavesneeded only a docstring generalization — its existing loops over_BARE_TOKEN_PROTECTED_LEAVESand_WRITE_PROTECTED_BASH_LEAVEScover the new leaf as soon as it is a member, which is the point of having the floors be data.Locally:
pytest test/test_acp_seed_provenance.py test/test_acp_session_mcp.py test/test_model_registry.py test/test_acp_client_more_coverage.py test/test_security.py -q→ 2185 passed, 3 skipped (the count grew with the base as the branch was rebased; the delta over the base is this PR's new tests; threetest_security.py::TestGitPublishSubshellGluingfailures reproduce on the untouched base tree and are outside this PR's diff). Then the whole backend surface, via the repo's ownscripts/run_scoped_tests.py --surface backend, measured against an earlier base revision: 86155 passed, 536 skipped, with 140 failed + 1 error — none of them in any file this PR touches, and none introduced by it.That claim is checked twice, against two different baselines, because the branch has been through a restructure round:
449425a50and produced 141 failed + 1 error as well; comparing the failure-NAME sets left five apparent differences, all accounted for.test/test_transcribe.py(collection error, thetranscribeextra is not installed) was in both and only missing from one extraction, which had matchedFAILEDand notERROR.test_platform_compat.py::test_process_descendants_snapshots_a_new_session_grandchildwas in both, with akirocrew confignotice glued onto the line mid-write so the name parsed with a trailing character. The remaining three (test_public_repo_chip_status.py× 2,test_source_providers.py× 1) are order-dependent, and the base run failed a different pair from the same file — the signature of cross-test pollution. Run in isolation, both trees gave exactly the same result: 1 failed, 690 passed.023419b01gives 139 failed, 86148 passed, 537 skipped, 1 error; the branch gives 140 failed, 86155 passed, 536 skipped, 1 error. The +7 passed is this round's net new tests. The one-failure delta lands entirely in two files this PR does not touch:test_file_sheet.py::test_workbook_text_budget_refuses_amplified_shared_stringsis in both sets (the base-side line again had a config notice glued to it), andtest_public_repo_chip_status.pyfailed different members on the two runs — one on the base, two on the branch. Running those two files alone on both trees gives 16 failed, 42 passed with identical failure-name sets, which is cross-test pollution reproduced on the base tree, not a regression.So: 0 introduced, 0 masked, on both comparisons.
Manual verification
settings.local.jsonquoted at the top of this description. Itsmodelkey matches no entry in its own allowlist — exactly the statetest_the_cold_seed_becomes_coherent_after_the_capturenow forbids._write_claude_local_settings/_apply_startup_model/_reseed_after_capturewith the advertised cache monkeypatched, so no live backend is needed to exercise either the cold-cache or the orphan-adoption path.Related Issues
no linked issue: found while verifying how the advertised model list is obtained, not tracked beforehand.
Pattern harvest
Rule candidate: review-prompt
Pattern: an on-disk artifact the product creates and later overwrites, whose ownership test lives only in process memory — correct within one session, and permanently wrong for anything that outlives one (a kill, a crash, an app upgrade).
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)