Skip to main content

pitboard_core/switch/
journal.rs

1//! Finishing what an interrupted run started.
2//!
3//! A switch writes this record before creating the park item it names. Whether an install
4//! landed is decided by asking Anthropic who owns the live credential, an answer that
5//! survives Claude Code rotating the token. When any fact cannot be read, recovery changes
6//! nothing and keeps the record.
7
8use super::{Error, Result, identity_of_document};
9use crate::context::Context;
10use crate::in_use::{self, InUse};
11use crate::provider::{self, LoginKind, Provider, ProviderId};
12use crate::service::Permit;
13use crate::state::{Account, Key, Park, ParkKind, State};
14use crate::{atomic, home, park, state, store};
15use serde_json::Value;
16use std::path::PathBuf;
17
18#[derive(serde::Serialize, serde::Deserialize)]
19pub(super) struct Journal {
20    /// Which tool's login moved. Both sides of a switch are always the same tool's: a
21    /// switch replaces one tool's live login with another account of that same tool.
22    ///
23    /// Absent from a record written before there was a second tool, and every one of those
24    /// was Claude Code's.
25    #[serde(default = "claude")]
26    pub(super) provider: ProviderId,
27    pub(super) started_at: i64,
28    pub(super) from_label: String,
29    /// The accounts' ids, under the names a record has always given them.
30    #[serde(rename = "from_uuid")]
31    pub(super) from_id: String,
32    pub(super) to_label: String,
33    #[serde(rename = "to_uuid")]
34    pub(super) to_id: String,
35    pub(super) park_service: String,
36    /// The park being installed, so recovery consumes exactly that copy.
37    pub(super) incoming_service: String,
38    /// The refresh tokens on each side, as fingerprints. Eight bytes of SHA-256, which is
39    /// what `Park` already records, and no more a secret there than here.
40    ///
41    /// These let recovery answer the question it usually needs Anthropic for. Empty on a
42    /// record written before they existed, which is a record that simply asks.
43    #[serde(default)]
44    pub(super) from_fingerprint: String,
45    #[serde(default)]
46    pub(super) to_fingerprint: String,
47    /// Where the tool's live login was when the switch started, as the tool names it.
48    ///
49    /// Which login is live depends on a home variable, and recovery judges an interrupted
50    /// switch by reading the live login. Read from another place, it would compare the
51    /// switch's two sides with a login that has nothing to do with them, and could decide
52    /// the switch landed when it never did. `None` on a record written before this was kept.
53    #[serde(default)]
54    pub(super) slot: Option<String>,
55}
56
57/// What a later run found an interrupted switch had done, now recorded in the state.
58#[derive(Debug)]
59pub struct Recovered {
60    pub from: String,
61    pub to: String,
62    pub finished: bool,
63}
64
65impl Recovered {
66    pub fn code(&self) -> &'static str {
67        if self.finished {
68            "interrupted_switch_finished"
69        } else {
70            "interrupted_switch_undone"
71        }
72    }
73}
74
75impl std::fmt::Display for Recovered {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        let from = if self.from.is_empty() {
78            String::new()
79        } else {
80            format!(" from `{}`", self.from)
81        };
82        write!(
83            f,
84            "an earlier switch{from} to `{}` was interrupted; {}",
85            self.to,
86            if self.finished {
87                "it had in fact finished, and Pitboard has recorded that"
88            } else {
89                "it had not finished, and nothing was lost"
90            }
91        )
92    }
93}
94
95fn claude() -> ProviderId {
96    ProviderId::Claude
97}
98
99impl Journal {
100    fn from(&self) -> Key {
101        Key::new(self.provider, self.from_label.clone())
102    }
103
104    fn to(&self) -> Key {
105        Key::new(self.provider, self.to_label.clone())
106    }
107
108    /// Put a login in place of one its service refused for good, parking nothing going out.
109    fn replaces(&self) -> bool {
110        self.from_id.is_empty()
111    }
112}
113
114fn journal_path(ctx: &Context) -> PathBuf {
115    home::dir(ctx).join("journal.json")
116}
117
118/// Durable before the park it names is created: a record lost to a crash would leave a
119/// consumed login looking restorable.
120pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
121    let path = journal_path(ctx);
122    let fail = |source| Error::RecoveryFailed {
123        path: path.clone(),
124        source,
125    };
126    home::ensure(ctx, permit).map_err(fail)?;
127    let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
128    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
129}
130
131/// A switch was interrupted, and the next command that changes state will finish it.
132pub(crate) fn pending(ctx: &Context) -> bool {
133    journal_path(ctx).exists()
134}
135
136/// Which tool's switch was interrupted, where one was and its record can be read.
137pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
138    let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
139    serde_json::from_str::<Journal>(&raw)
140        .ok()
141        .map(|journal| journal.provider)
142}
143
144/// The switch reached a state the account index fully describes.
145pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
146    let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
147}
148
149struct Found {
150    /// The park the record reserved: written, never written, or `None` if unreadable.
151    parked: Option<Option<Value>>,
152    /// The id of the account the live login belongs to, or `None` if that cannot be learned.
153    live_owner: Option<String>,
154}
155
156#[derive(Default, Debug, PartialEq)]
157struct Repair {
158    /// Hold the interrupted run's park for the account it came from. Keyed by account id,
159    /// not label: the label may have been reused since.
160    hold: Option<(String, Park)>,
161    /// The interrupted run's park copies a login that is still signed in, so Claude Code
162    /// will rotate past it.
163    drop: bool,
164    /// The destination's login is live: it is in use, and its park was consumed.
165    landed: bool,
166}
167
168/// What recovery read of the tool's own record and of its store, which is what a switch
169/// that landed leaves recorded as in use.
170struct Seen {
171    /// What the tool's own record named, read before its store.
172    named: Option<String>,
173    /// The fingerprint of the login the store held: the one installed, or what Claude Code
174    /// rotated it to since. Empty where the store could not be read.
175    login: String,
176}
177
178/// `None` when the facts do not settle what happened.
179fn repair_for(
180    state: &State,
181    tool: &'static dyn Provider,
182    journal: &Journal,
183    found: &Found,
184) -> Option<Repair> {
185    let parked = found.parked.as_ref()?;
186    let owner = found.live_owner.as_deref()?;
187    let mut repair = Repair {
188        landed: owner == journal.to_id,
189        ..Repair::default()
190    };
191    if owner == journal.from_id {
192        repair.drop = parked.is_some();
193    } else if let Some(oauth) = parked
194        && !state.references(&journal.park_service)
195    {
196        repair.hold = Some((
197            journal.from_id.clone(),
198            park::describe(tool, &journal.park_service, journal.started_at, oauth),
199        ));
200    }
201    Some(repair)
202}
203
204/// A switch that landed leaves the login recovery `seen` in the store recorded as the
205/// destination's, at `at`.
206fn apply(state: &mut State, journal: &Journal, repair: Repair, seen: Seen, at: i64) {
207    if repair.drop {
208        state.discard(&journal.park_service, ParkKind::Vault);
209    }
210    if let Some((id, park)) = repair.hold {
211        match state.by_id(journal.provider, &id).map(Account::key) {
212            Some(key) => state.park(&key, park),
213            // The account it belongs to is gone, so nothing will ever restore this copy.
214            // Listing it is what gets it deleted rather than left in the keychain.
215            None => state.release(&park.service),
216        }
217    }
218    if !repair.landed {
219        return;
220    }
221    match state
222        .by_id(journal.provider, &journal.to_id)
223        .map(Account::owner)
224    {
225        Some(owner) => {
226            let installed = InUse {
227                owner: Some(owner),
228                login: seen.login,
229                known_at: at,
230                named: seen.named,
231            };
232            state.identified(journal.provider, installed, at);
233            state.discard(&journal.incoming_service, ParkKind::Vault);
234        }
235        // The destination was forgotten since, whichever account holds its label now, and an
236        // id alone does not say whose its login is as the service names one. What the record
237        // said before the switch is past.
238        None => {
239            state.in_use.remove(journal.provider.code());
240        }
241    }
242}
243
244fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
245    match store::vault_read(ctx, service) {
246        Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
247        Err(_) => None,
248    }
249}
250
251/// Whose the live login is, as far as the record and the login itself can say.
252enum Owner {
253    /// The account's under this id.
254    Is(String),
255    /// Nobody can say, for this reason.
256    Unknown(String),
257    /// The record cannot say whose this login is, and the service has not been asked.
258    Unasked(Value),
259    /// A write into the store stopped partway, which no read gets past and a whole write
260    /// ends, for this reason.
261    Unfinished(String),
262}
263
264/// Whose the live login is, read off the record and the login without asking anybody, and
265/// that login's fingerprint, empty where it could not be read.
266///
267/// The fingerprints settle it without a round trip whenever they can, which is what makes
268/// an interrupted switch recoverable with no network at all.
269fn live_owner(ctx: &Context, tool: &'static dyn Provider, journal: &Journal) -> (Owner, String) {
270    match tool.read_live(ctx) {
271        Err(e) => match left_unfinished(ctx, tool) {
272            Some(why) => (Owner::Unfinished(why), String::new()),
273            None => (Owner::Unknown(e.to_string()), String::new()),
274        },
275        // The login going in was never there or is gone again, and its park is all it has.
276        Ok(None) if journal.replaces() => (Owner::Is(String::new()), String::new()),
277        Ok(None) => (Owner::Unknown("nothing is signed in".into()), String::new()),
278        Ok(Some(live)) => {
279            let login = tool.fingerprint(&live.raw);
280            let owner = match live_owner_by_fingerprint(journal, &login) {
281                Some(id) => Owner::Is(id),
282                None => Owner::Unasked(live.raw),
283            };
284            (owner, login)
285        }
286    }
287}
288
289// Why the tool's store holds a write that stopped partway, where it does: Claude Code's pieces
290// in Credential Manager (`credman_chunking`).
291fn left_unfinished(ctx: &Context, tool: &'static dyn Provider) -> Option<String> {
292    let live = tool.live(ctx).ok()?;
293    match store::read_raw(&live.chain, &live.service) {
294        Err(store::Error::Unfinished(why)) => Some(why),
295        _ => None,
296    }
297}
298
299fn not_finished(state: &State, journal: &Journal, why: &str, detail: impl ToString) -> Error {
300    Error::RecoveryUnfinished {
301        tool: journal.provider,
302        from: state.typed(&journal.from()),
303        to: state.typed(&journal.to()),
304        detail: format!("{why}; {}", detail.to_string()),
305    }
306}
307
308// What ends a write that stopped partway: the login the switch went to, written whole. The
309// part of the outgoing document that is no account's, such as its MCP tokens, no read gets
310// back now, so it is not in it.
311fn finishing(
312    ctx: &Context,
313    state: &State,
314    tool: &'static dyn Provider,
315    journal: &Journal,
316    why: &str,
317) -> Result<String> {
318    let Some(Some(incoming)) = read_park(ctx, &journal.incoming_service) else {
319        return Err(not_finished(
320            state,
321            journal,
322            why,
323            "its parked login could not be read",
324        ));
325    };
326    tool.splice(&Value::Object(serde_json::Map::new()), &incoming)
327        .map(|next| super::to_body(&next))
328        .map_err(|e| not_finished(state, journal, why, e))
329}
330
331// Under the tool's write lock, as a switch writes.
332fn finish(ctx: &Context, permit: Permit, tool: &'static dyn Provider, body: &str) -> Result<()> {
333    let live = tool.live(ctx).map_err(|e| super::shape(tool.id(), e))?;
334    let _guard = super::write_lock(ctx, permit, tool)?;
335    Ok(store::write_raw(&live.chain, permit, &live.service, body)?)
336}
337
338/// The id of the account whose the live login is, with the service asked where only it
339/// can say.
340fn ask(
341    ctx: &Context,
342    state: &State,
343    tool: &'static dyn Provider,
344    owner: Owner,
345) -> std::result::Result<String, String> {
346    match owner {
347        Owner::Is(id) => Ok(id),
348        Owner::Unknown(why) | Owner::Unfinished(why) => Err(why),
349        Owner::Unasked(live) => identity_of_document(ctx, tool, &live)
350            .map(|identity| state.id_for(tool.id(), &identity))
351            .map_err(|e| e.to_string()),
352    }
353}
354
355/// Who owns the live login, answered from the record rather than from Anthropic, where the
356/// record is enough to answer it.
357///
358/// Recovery needs to know which side of the switch the live credential came from, and
359/// asking Anthropic is the only answer that survives Claude Code rotating a token. But the
360/// two candidates are both Pitboard's own documents and their refresh tokens were
361/// fingerprinted when the record was written, so the common case is a comparison and not a
362/// round trip. That is what lets a switch be recovered on a plane.
363///
364/// It narrows the network dependency rather than removing it. A rotation inside the seconds
365/// of an interrupted switch leaves a fingerprint matching neither side, which is exactly
366/// when this says nothing and Anthropic is asked after all.
367fn live_owner_by_fingerprint(journal: &Journal, found: &str) -> Option<String> {
368    // The login going in has a refresh token, so a slot whose login has none, as a refused one
369    // may, holds what was there before.
370    if journal.replaces() && found.is_empty() {
371        return Some(journal.from_id.clone());
372    }
373    if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
374        return None;
375    }
376    if journal.from_fingerprint == journal.to_fingerprint {
377        return None;
378    }
379    if found.is_empty() {
380        return None;
381    }
382    if found == journal.to_fingerprint {
383        Some(journal.to_id.clone())
384    } else if found == journal.from_fingerprint {
385        Some(journal.from_id.clone())
386    } else {
387        None
388    }
389}
390
391/// What abandoning an unfinishable record decided to keep.
392#[derive(Debug)]
393pub struct Abandoned {
394    pub from: String,
395    pub to: String,
396    /// Copies kept rather than deleted, because which one is live is now unknown.
397    pub kept: usize,
398}
399
400/// Throws away a record that cannot be finished, keeping every copy it names.
401///
402/// Recovery needs the service to say who owns the live login. Offline, or with a session
403/// the service no longer accepts, it cannot, and every command that changes anything stops
404/// at that. This is the way out: nothing is installed and nothing that might be the only
405/// copy is deleted, so the worst case is a copy that outlives its use, which `status` shows
406/// and `doctor` reports.
407///
408/// One exception, for a tool whose park may never be a copy: a park whose refresh token is
409/// the live login's own is a second copy for certain, whoever owns it, and is dropped
410/// rather than kept.
411pub(super) fn abandon(
412    ctx: &Context,
413    permit: Permit,
414    state: &mut State,
415) -> Result<Option<Abandoned>> {
416    let path = journal_path(ctx);
417    let raw = match std::fs::read_to_string(&path) {
418        Ok(r) => r,
419        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
420        Err(source) => return Err(Error::RecoveryFailed { path, source }),
421    };
422    let journal =
423        serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
424            path: path.clone(),
425            source,
426        })?;
427    let tool = match provider::of(journal.provider) {
428        LoginKind::Document(tool) => tool,
429        LoginKind::Folder(_) => {
430            return Err(Error::RecoveryRecordCorrupt {
431                path,
432                source: serde::de::Error::custom(
433                    "it names a tool whose login no switch records here",
434                ),
435            });
436        }
437    };
438
439    // The copy the interrupted run parked is recorded against the account it came from, so
440    // nothing holds a keychain item that no file names.
441    let mut kept = 0;
442    if let Some(Some(document)) = read_park(ctx, &journal.park_service)
443        && park::is_live_twin(ctx, tool, &document)
444    {
445        state.discard(&journal.park_service, ParkKind::Vault);
446    } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
447        && let Some(key) = state
448            .by_id(journal.provider, &journal.from_id)
449            .map(Account::key)
450    {
451        state.park(
452            &key,
453            park::describe(tool, &journal.park_service, ctx.now(), &document),
454        );
455        kept += 1;
456    }
457    let incoming = read_park(ctx, &journal.incoming_service).flatten();
458    if incoming.is_some_and(|document| park::is_live_twin(ctx, tool, &document)) {
459        state.discard(&journal.incoming_service, ParkKind::Vault);
460    } else if state
461        .by_id(journal.provider, &journal.to_id)
462        .and_then(|a| a.parked.as_ref())
463        .is_some()
464    {
465        kept += 1;
466    }
467    state::save(ctx, permit, state)?;
468    clear_journal(ctx, permit);
469    Ok(Some(Abandoned {
470        from: state.typed(&journal.from()),
471        to: state.typed(&journal.to()),
472        kept,
473    }))
474}
475
476/// The record of an interrupted switch, with every fact recovery decides it from that can be
477/// read without asking anybody.
478struct Waiting {
479    tool: &'static dyn Provider,
480    journal: Journal,
481    /// The park the record reserved: written, never written, or `None` if unreadable.
482    parked: Option<Option<Value>>,
483    owner: Owner,
484    seen: Seen,
485}
486
487/// The record of an interrupted switch, where one is waiting, and what recovery decides it
488/// from. Reads, and writes nothing whatever it finds.
489fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
490    let path = journal_path(ctx);
491    let raw = match std::fs::read_to_string(&path) {
492        Ok(r) => r,
493        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
494        Err(source) => return Err(Error::RecoveryFailed { path, source }),
495    };
496    // Written atomically, so a record that does not parse was damaged afterwards and says
497    // nothing about how far its switch got.
498    let journal =
499        serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
500            path: path.clone(),
501            source,
502        })?;
503    let tool = match provider::of(journal.provider) {
504        LoginKind::Document(tool) => tool,
505        LoginKind::Folder(_) => {
506            return Err(Error::RecoveryRecordCorrupt {
507                path,
508                source: serde::de::Error::custom(
509                    "it names a tool whose login no switch records here",
510                ),
511            });
512        }
513    };
514
515    if let Some(slot) = &journal.slot {
516        let here = tool.slot(ctx);
517        if *slot != here {
518            return Err(Error::RecoveryElsewhere {
519                tool: journal.provider,
520                from: state.typed(&journal.from()),
521                to: state.typed(&journal.to()),
522                slot: slot.clone(),
523            });
524        }
525    }
526
527    let named = in_use::named(ctx, journal.provider);
528    let (owner, login) = live_owner(ctx, tool, &journal);
529    // A replacement parks no login going out, so nothing parked is what there is to find.
530    let parked = if journal.park_service.is_empty() {
531        Some(None)
532    } else {
533        read_park(ctx, &journal.park_service)
534    };
535    Ok(Some(Waiting {
536        tool,
537        parked,
538        journal,
539        owner,
540        seen: Seen { named, login },
541    }))
542}
543
544/// What finishes or undoes the switch, or, where the facts do not settle what it did, the
545/// refusal of the change that found them so.
546fn decide(
547    state: &State,
548    tool: &'static dyn Provider,
549    journal: &Journal,
550    parked: Option<Option<Value>>,
551    owner: std::result::Result<String, String>,
552) -> Result<Repair> {
553    let found = Found {
554        parked,
555        live_owner: owner.as_ref().ok().cloned(),
556    };
557    repair_for(state, tool, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
558        tool: journal.provider,
559        from: state.typed(&journal.from()),
560        to: state.typed(&journal.to()),
561        detail: owner
562            .err()
563            .unwrap_or_else(|| "its parked login could not be read".into()),
564    })
565}
566
567pub(super) fn reconcile(
568    ctx: &Context,
569    permit: Permit,
570    state: &mut State,
571) -> Result<Option<Recovered>> {
572    let Some(mut waiting) = read(ctx, state)? else {
573        return Ok(None);
574    };
575    if let Owner::Unfinished(why) = &waiting.owner {
576        let (tool, journal) = (waiting.tool, &waiting.journal);
577        let body = finishing(ctx, state, tool, journal, why)?;
578        finish(ctx, permit, tool, &body).map_err(|e| not_finished(state, journal, why, e))?;
579        (waiting.owner, waiting.seen.login) = live_owner(ctx, tool, journal);
580    }
581    let Waiting {
582        tool,
583        journal,
584        parked,
585        owner,
586        seen,
587    } = waiting;
588    let owner = ask(ctx, state, tool, owner);
589    let repair = decide(state, tool, &journal, parked, owner)?;
590    let finished = repair.landed;
591    apply(state, &journal, repair, seen, ctx.now());
592    state::save(ctx, permit, state)?;
593    clear_journal(ctx, permit);
594
595    Ok(Some(Recovered {
596        from: state.typed(&journal.from()),
597        to: state.typed(&journal.to()),
598        finished,
599    }))
600}
601
602/// Whether working out what an interrupted switch did may ask the service whose the live
603/// login is, where the record cannot say. A change asks, and so does `status`, which asks
604/// the service about every account anyway; `doctor` and `status_offline` send no request.
605/// A tool whose login names its own account is identified from the login either way, which
606/// asks nobody.
607#[derive(Clone, Copy, Debug, PartialEq, Eq)]
608pub(crate) enum Asking {
609    Service,
610    Nobody,
611}
612
613/// The refusal the next change would make over the interrupted switch waiting here, worked
614/// out by reading what [`reconcile`] reads and deciding as it decides, and changing nothing.
615///
616/// `None` where no switch is waiting, where the next change finishes or undoes it, and
617/// where telling which would need the service and `asking` says not to ask it. A record
618/// that cannot be read, or that was written for another slot, is refused by the next
619/// change for that reason and not for this one, and is not this function's to say.
620pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
621    let Waiting {
622        tool,
623        journal,
624        parked,
625        owner,
626        ..
627    } = read(ctx, state).ok()??;
628    if let Owner::Unfinished(why) = &owner {
629        return finishing(ctx, state, tool, &journal, why).err();
630    }
631    if asking == Asking::Nobody
632        && matches!(owner, Owner::Unasked(_))
633        && !tool.identifies_by_itself()
634    {
635        return None;
636    }
637    let owner = ask(ctx, state, tool, owner);
638    decide(state, tool, &journal, parked, owner).err()
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    const NOW: i64 = 1_700_000_100;
646    const PARK: &str = "pitboard-park-from-uuid-1700000000000";
647    const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
648
649    /// A record as 0.8.0 writes it. An account enrolled then kept its account uuid as its
650    /// id, so what the record names is still each account's id.
651    #[test]
652    fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
653        let written: Journal = serde_json::from_value(serde_json::json!({
654            "provider": "claude",
655            "started_at": 1_700_000_000,
656            "from_label": "from",
657            "from_uuid": "from-uuid",
658            "to_label": "to",
659            "to_uuid": "to-uuid",
660            "park_service": PARK,
661            "incoming_service": INCOMING,
662            "from_fingerprint": "f",
663            "to_fingerprint": "t",
664            "slot": "Claude Code-credentials"
665        }))
666        .expect("still read");
667        assert_eq!(
668            (written.from_id.as_str(), written.to_id.as_str()),
669            ("from-uuid", "to-uuid")
670        );
671    }
672
673    fn journal() -> Journal {
674        Journal {
675            provider: ProviderId::Claude,
676            started_at: 1_700_000_000,
677            from_label: "from".into(),
678            from_id: "from-uuid".into(),
679            to_label: "to".into(),
680            to_id: "to-uuid".into(),
681            park_service: PARK.into(),
682            incoming_service: INCOMING.into(),
683            from_fingerprint: "ffffffffffffffff".into(),
684            to_fingerprint: "0000000000000000".into(),
685            slot: None,
686        }
687    }
688
689    /// A record written before fingerprints existed simply asks, which is what it always
690    /// did. Reading an old record must never be a reason to refuse.
691    #[test]
692    fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
693        let mut j = journal();
694        j.from_fingerprint = String::new();
695        j.to_fingerprint = String::new();
696        assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
697    }
698
699    /// Two sides that fingerprint the same are not two sides. Nothing can be read off that,
700    /// not even from a login that matches both.
701    #[test]
702    fn identical_fingerprints_settle_nothing() {
703        let mut j = journal();
704        j.from_fingerprint = fingerprint("refresh");
705        j.to_fingerprint = j.from_fingerprint.clone();
706        assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
707    }
708
709    /// The fingerprint of a Claude Code login on `refresh`.
710    fn fingerprint(refresh: &str) -> String {
711        let live =
712            serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}});
713        provider::document(ProviderId::Claude).fingerprint(&live)
714    }
715
716    /// The store holding the login fingerprinted `login`, with the tool's own record naming
717    /// nobody.
718    fn seen(login: &str) -> Seen {
719        Seen {
720            named: None,
721            login: login.into(),
722        }
723    }
724
725    /// What a switch's head records before anything moves: the store holds `from`'s login,
726    /// as Anthropic said.
727    fn identified_from(s: &mut State) {
728        let from = InUse {
729            owner: Some(account("from", None).owner()),
730            login: journal().from_fingerprint,
731            known_at: NOW - 60,
732            named: None,
733        };
734        s.identified(ProviderId::Claude, from, NOW - 60);
735    }
736
737    fn account(label: &str, parked: Option<&str>) -> Account {
738        Account {
739            last_used_at: None,
740            replaced_at: None,
741            plan: None,
742            label: label.into(),
743            id: format!("{label}-uuid"),
744            account_uuid: format!("{label}-uuid"),
745            email: format!("{label}@example.com"),
746            detail: state::Detail::Claude {
747                organization_uuid: format!("{label}-org"),
748                oauth_account: serde_json::json!({}),
749            },
750            parked: parked.map(|s| Park {
751                service: s.into(),
752                parked_at: 1_699_000_000,
753                kind: ParkKind::Vault,
754                login: "f".into(),
755                access_expires_at: None,
756                refresh_expires_at: None,
757                session_expires_at: None,
758            }),
759        }
760    }
761
762    /// `from` signed in with nothing parked, `to` parked: the state before a switch.
763    fn before() -> State {
764        State {
765            accounts: vec![account("from", None), account("to", Some(INCOMING))],
766            ..State::default()
767        }
768    }
769
770    fn written() -> Option<Option<Value>> {
771        Some(Some(
772            serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
773        ))
774    }
775
776    fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
777        Found {
778            parked,
779            live_owner: owner.map(str::to_owned),
780        }
781    }
782
783    /// Killed after reserving the park name but before writing it.
784    #[test]
785    fn nothing_parked_and_nothing_installed_changes_nothing() {
786        let repair = repair_for(
787            &before(),
788            provider::document(ProviderId::Claude),
789            &journal(),
790            &found(Some(None), Some("from-uuid")),
791        );
792        assert_eq!(repair, Some(Repair::default()));
793    }
794
795    /// Killed after the park was written, before the install. `from` is still signed in, so
796    /// the park is a second copy of a live login and Claude Code will rotate past it.
797    #[test]
798    fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
799        for s in [before(), {
800            let mut recorded = before();
801            recorded.park(
802                &Key::new(ProviderId::Claude, "from"),
803                account("x", Some(PARK)).parked.unwrap(),
804            );
805            recorded
806        }] {
807            let repair = repair_for(
808                &s,
809                provider::document(ProviderId::Claude),
810                &journal(),
811                &found(written(), Some("from-uuid")),
812            )
813            .unwrap();
814            assert!(repair.drop && repair.hold.is_none() && !repair.landed);
815
816            let mut applied = s;
817            apply(
818                &mut applied,
819                &journal(),
820                repair,
821                seen(&journal().from_fingerprint),
822                NOW,
823            );
824            assert!(!applied.references(PARK));
825            assert!(applied.discarded.iter().any(|d| d.service == PARK));
826        }
827    }
828
829    /// Killed after the install, before state recorded it. Claude Code may already have
830    /// rotated the token.
831    #[test]
832    fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
833        let mut s = before();
834        identified_from(&mut s);
835        let repair = repair_for(
836            &s,
837            provider::document(ProviderId::Claude),
838            &journal(),
839            &found(written(), Some("to-uuid")),
840        )
841        .unwrap();
842        let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
843        assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
844        assert_eq!(park.service, PARK);
845        assert!(repair.landed);
846
847        let rotated = fingerprint("rotated-since");
848        apply(&mut s, &journal(), repair, seen(&rotated), NOW);
849        assert_eq!(
850            s.account_in_use(ProviderId::Claude)
851                .map(|a| a.label.as_str()),
852            Some("to")
853        );
854        assert_eq!(
855            s.in_use(ProviderId::Claude)
856                .map(|r| (r.login.as_str(), r.known_at)),
857            Some((rotated.as_str(), NOW)),
858            "the login the store holds, which Claude Code may have rotated since the install"
859        );
860        let from = s.get(&Key::new(ProviderId::Claude, "from")).unwrap();
861        assert_eq!(from.parked.as_ref().unwrap().service, PARK);
862        assert_eq!(
863            from.replaced_at, None,
864            "its login is parked, so nothing of it was lost"
865        );
866        assert!(
867            s.get(&Key::new(ProviderId::Claude, "to"))
868                .unwrap()
869                .parked
870                .is_none(),
871            "the copy now live must never be offered again"
872        );
873        assert!(s.discarded.iter().any(|d| d.service == INCOMING));
874    }
875
876    /// Someone signed in as a third account since: the orphan may be the only copy of
877    /// `from`'s login, and the destination's park may still be good.
878    #[test]
879    fn a_third_account_signed_in_since_keeps_both_parks() {
880        let mut s = before();
881        let repair = repair_for(
882            &s,
883            provider::document(ProviderId::Claude),
884            &journal(),
885            &found(written(), Some("other-uuid")),
886        )
887        .unwrap();
888        apply(&mut s, &journal(), repair, seen(&fingerprint("other")), NOW);
889        assert!(s.references(PARK) && s.references(INCOMING));
890        assert!(s.discarded.is_empty());
891    }
892
893    #[test]
894    fn an_already_recorded_park_is_not_held_twice() {
895        let mut s = before();
896        s.park(
897            &Key::new(ProviderId::Claude, "from"),
898            account("x", Some(PARK)).parked.unwrap(),
899        );
900        let repair = repair_for(
901            &s,
902            provider::document(ProviderId::Claude),
903            &journal(),
904            &found(written(), Some("to-uuid")),
905        )
906        .unwrap();
907        assert_eq!(repair.hold, None);
908    }
909
910    #[test]
911    fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
912        for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
913            assert_eq!(
914                repair_for(
915                    &before(),
916                    provider::document(ProviderId::Claude),
917                    &journal(),
918                    &unknown
919                ),
920                None,
921                "could-not-tell must never be read as nothing-there"
922            );
923        }
924    }
925
926    #[test]
927    fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
928        let mut s = State {
929            accounts: vec![account("other", None)],
930            ..State::default()
931        };
932        identified_from(&mut s);
933        let repair = repair_for(
934            &s,
935            provider::document(ProviderId::Claude),
936            &journal(),
937            &found(written(), Some("to-uuid")),
938        )
939        .unwrap();
940        apply(
941            &mut s,
942            &journal(),
943            repair,
944            seen(&journal().to_fingerprint),
945            NOW,
946        );
947        assert!(
948            !s.references(PARK),
949            "a park must never be filed under whatever account happens to hold a label"
950        );
951        assert_eq!(
952            s.in_use(ProviderId::Claude),
953            None,
954            "the store no longer holds `from`'s login, and nobody here says whose it holds"
955        );
956    }
957
958    /// The destination forgotten and its label given to another account since. That account
959    /// is not the one whose login the switch installed, and the destination's park is not
960    /// its to consume.
961    #[test]
962    fn a_label_given_to_another_account_since_is_not_the_destination() {
963        const THEIRS: &str = "pitboard-park-someone-uuid-1695000000000";
964        let someone = Account {
965            id: "someone-uuid".into(),
966            account_uuid: "someone-uuid".into(),
967            ..account("to", Some(THEIRS))
968        };
969        let mut s = State {
970            accounts: vec![account("from", None), someone],
971            ..State::default()
972        };
973        identified_from(&mut s);
974        let repair = repair_for(
975            &s,
976            provider::document(ProviderId::Claude),
977            &journal(),
978            &found(written(), Some("to-uuid")),
979        )
980        .unwrap();
981        apply(
982            &mut s,
983            &journal(),
984            repair,
985            seen(&journal().to_fingerprint),
986            NOW,
987        );
988
989        assert_eq!(
990            s.in_use(ProviderId::Claude),
991            None,
992            "the login installed is not the account that holds the label now"
993        );
994        let to = s.get(&Key::new(ProviderId::Claude, "to")).unwrap();
995        assert_eq!(to.last_used_at, None, "never in use");
996        assert_eq!(to.parked.as_ref().map(|p| p.service.as_str()), Some(THEIRS));
997        assert!(
998            !s.discarded.iter().any(|d| d.service == INCOMING),
999            "the destination's park is no account's here to consume"
1000        );
1001    }
1002}