session adopt: refuse when the candidate set is incomplete
Commit
The ownership guard authorized against a candidate set it could not prove it had read completely, and the direction of that failure is authorize-on-error: fewer candidates means fewer rivals, and a missing candidate is exactly the nearer owner whose absence lets a bare environment claim win uncontested.
The reachable half is not the one it looks like. A session store that cannot be opened at all is loud โ every later read fails the same way โ and measured, a mode-000 store directory already failed the adoption at the mutation step, where adoptFromExternalSessionStore re-reads the same path. Nothing was ever exploitable there. The half that matters is a single unreadable file: StateStore.List skips a state file it cannot read or parse, deliberately, so that one corrupt file does not blind every caller to the rest of the store. That leaves the listing SUCCEEDING with one candidate quietly absent, which no error channel reports. Measured on the real binary: one target state file at mode 000 had a matching CLAUDE_CODE_SESSION_ID adopt silently, and had entire session list print "No sessions." over a store that held one.
So reporting the listing's error โ the obvious fix โ would have covered only the half that already fails safe. session.StateStore.ListWithSkipped reports what a listing could not read; plain List delegates to it and keeps the lossy contract on purpose, because a caller that only wants to display sessions is right not to care. Each skip is also logged at Warn, which is new: it was previously a bare continue.
ResolvedSession.Incomplete carries that out of the resolver, stamped in one place per entry point (resolveSessionTiers exists so a tier added later cannot answer without it). It is deliberately NOT folded into SessionResolution.IsCaller(): the resolution is still the best available answer and still worth displaying, and only mutation has to be conservative. Anything that acts on a session checks both โ they answer different questions and neither implies the other.
adoptSourceIsOwned therefore checks both listings, and orders incompleteness ahead of the verdicts rather than among them, because it undermines them rather than competing with them: the one branch it invalidates is the one that authorizes. Refusing is not walling the user out โ refuseForeignAdoption asks wherever there is a terminal, and --allow-foreign-session covers the rest โ so a repo carrying one stale corrupt file is not stranded.
The display side says so rather than refusing, since nothing is mutated: session list and session current warn on stderr, keeping --json stdout valid. In session current the warning precedes the not-found branch, because "no active session" over a store that could not be read fully is the misreading, not the answer.
Also removes a stale section of the architecture doc describing sourceOwnerIsNearest, strategy.CallerOwnerIsNearest and two tests, none of which have existed since the combined-candidate-set change earlier in this series. The cross-repo motivation it carried is kept.
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com Entire-Checkpoint: 01M25YAMZ8A4J5B2RQSGDFHB2J