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, identify_document};
9use crate::context::Context;
10use crate::provider::ProviderId;
11use crate::service::Permit;
12use crate::state::{Key, Park, State};
13use crate::{atomic, home, park, state, store};
14use serde_json::Value;
15use std::path::PathBuf;
16
17#[derive(serde::Serialize, serde::Deserialize)]
18pub(super) struct Journal {
19    /// Which tool's login moved. Both sides of a switch are always the same tool's: a
20    /// switch replaces one tool's live login with another account of that same tool.
21    ///
22    /// Absent from a record written before there was a second tool, and every one of those
23    /// was Claude Code's.
24    #[serde(default = "claude")]
25    pub(super) provider: ProviderId,
26    pub(super) started_at: i64,
27    pub(super) from_label: String,
28    /// The accounts' ids, under the names a record has always given them.
29    #[serde(rename = "from_uuid")]
30    pub(super) from_id: String,
31    pub(super) to_label: String,
32    #[serde(rename = "to_uuid")]
33    pub(super) to_id: String,
34    pub(super) park_service: String,
35    /// The park being installed, so recovery consumes exactly that copy.
36    pub(super) incoming_service: String,
37    /// The refresh tokens on each side, as fingerprints. Eight bytes of SHA-256, which is
38    /// what `Park` already records, and no more a secret there than here.
39    ///
40    /// These let recovery answer the question it usually needs Anthropic for. Empty on a
41    /// record written before they existed, which is a record that simply asks.
42    #[serde(default)]
43    pub(super) from_fingerprint: String,
44    #[serde(default)]
45    pub(super) to_fingerprint: String,
46    /// Where the tool's live login was when the switch started, as the tool names it.
47    ///
48    /// Which login is live depends on a home variable, and recovery judges an interrupted
49    /// switch by reading the live login. Read from another place, it would compare the
50    /// switch's two sides with a login that has nothing to do with them, and could decide
51    /// the switch landed when it never did. `None` on a record written before this was kept.
52    #[serde(default)]
53    pub(super) slot: Option<String>,
54}
55
56/// What a later run found an interrupted switch had done, now recorded in the state.
57#[derive(Debug)]
58pub struct Recovered {
59    pub from: String,
60    pub to: String,
61    pub finished: bool,
62}
63
64impl Recovered {
65    pub fn code(&self) -> &'static str {
66        if self.finished {
67            "interrupted_switch_finished"
68        } else {
69            "interrupted_switch_undone"
70        }
71    }
72}
73
74impl std::fmt::Display for Recovered {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(
77            f,
78            "an earlier switch from `{}` to `{}` was interrupted; {}",
79            self.from,
80            self.to,
81            if self.finished {
82                "it had in fact finished, and Pitboard has recorded that"
83            } else {
84                "it had not finished, and nothing was lost"
85            }
86        )
87    }
88}
89
90fn claude() -> ProviderId {
91    ProviderId::Claude
92}
93
94impl Journal {
95    fn from(&self) -> Key {
96        Key::new(self.provider, self.from_label.clone())
97    }
98
99    fn to(&self) -> Key {
100        Key::new(self.provider, self.to_label.clone())
101    }
102}
103
104fn journal_path(ctx: &Context) -> PathBuf {
105    home::dir(ctx).join("journal.json")
106}
107
108/// Durable before the park it names is created: a record lost to a crash would leave a
109/// consumed login looking restorable.
110pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
111    let path = journal_path(ctx);
112    let fail = |source| Error::RecoveryFailed {
113        path: path.clone(),
114        source,
115    };
116    home::ensure(ctx, permit).map_err(fail)?;
117    let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
118    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
119}
120
121/// A switch was interrupted, and the next command that changes state will finish it.
122pub fn pending(ctx: &Context) -> bool {
123    journal_path(ctx).exists()
124}
125
126/// Which tool's switch was interrupted, where one was and its record can be read.
127pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
128    let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
129    serde_json::from_str::<Journal>(&raw)
130        .ok()
131        .map(|journal| journal.provider)
132}
133
134/// The switch reached a state the account index fully describes.
135pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
136    let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
137}
138
139struct Found {
140    /// The park the record reserved: written, never written, or `None` if unreadable.
141    parked: Option<Option<Value>>,
142    /// The id of the account the live login belongs to, or `None` if that cannot be learned.
143    live_owner: Option<String>,
144}
145
146#[derive(Default, Debug, PartialEq)]
147struct Repair {
148    /// Hold the interrupted run's park for the account it came from. Keyed by account id,
149    /// not label: the label may have been reused since.
150    hold: Option<(String, Park)>,
151    /// The interrupted run's park copies a login that is still signed in, so Claude Code
152    /// will rotate past it.
153    drop: bool,
154    /// The destination's login is live: it is active, and its park was consumed.
155    landed: bool,
156}
157
158/// `None` when the facts do not settle what happened.
159fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
160    let parked = found.parked.as_ref()?;
161    let owner = found.live_owner.as_deref()?;
162    let mut repair = Repair {
163        landed: owner == journal.to_id,
164        ..Repair::default()
165    };
166    if owner == journal.from_id {
167        repair.drop = parked.is_some();
168    } else if let Some(oauth) = parked
169        && !state.references(&journal.park_service)
170    {
171        repair.hold = Some((
172            journal.from_id.clone(),
173            park::describe(
174                journal.provider,
175                &journal.park_service,
176                journal.started_at,
177                oauth,
178            ),
179        ));
180    }
181    Some(repair)
182}
183
184fn apply(state: &mut State, journal: &Journal, repair: Repair) {
185    if repair.drop {
186        state.discard(&journal.park_service);
187    }
188    if let Some((id, park)) = repair.hold {
189        match state
190            .by_id(journal.provider, &id)
191            .map(crate::state::Account::key)
192        {
193            Some(key) => state.park(&key, park),
194            // The account it belongs to is gone, so nothing will ever restore this copy.
195            // Listing it is what gets it deleted rather than left in the keychain.
196            None => state.release(&park.service),
197        }
198    }
199    if repair.landed && state.get(&journal.to()).is_some() {
200        state.set_active(journal.provider, Some(journal.to_label.clone()));
201        state.discard(&journal.incoming_service);
202    }
203}
204
205fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
206    match store::vault_read(ctx, service) {
207        Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
208        Err(_) => None,
209    }
210}
211
212/// Whose the live login is, as far as the record and the login itself can say.
213enum Owner {
214    /// The account's under this id.
215    Is(String),
216    /// Nobody can say, for this reason.
217    Unknown(String),
218    /// The record cannot say whose this login is, and the service has not been asked.
219    Unasked(Value),
220}
221
222/// Whose the live login is, read off the record and the login without asking anybody.
223///
224/// The fingerprints settle it without a round trip whenever they can, which is what makes
225/// an interrupted switch recoverable with no network at all.
226fn live_owner(ctx: &Context, journal: &Journal) -> Owner {
227    match crate::provider::of(journal.provider).read_live(ctx) {
228        Err(e) => Owner::Unknown(e.to_string()),
229        Ok(None) => Owner::Unknown("nothing is signed in".into()),
230        Ok(Some(live)) => match live_owner_by_fingerprint(journal, &live.raw) {
231            Some(id) => Owner::Is(id),
232            None => Owner::Unasked(live.raw),
233        },
234    }
235}
236
237/// The id of the account whose the live login is, with the service asked where only it
238/// can say.
239fn ask(
240    ctx: &Context,
241    state: &State,
242    which: ProviderId,
243    owner: Owner,
244) -> std::result::Result<String, String> {
245    match owner {
246        Owner::Is(id) => Ok(id),
247        Owner::Unknown(why) => Err(why),
248        Owner::Unasked(live) => identify_document(ctx, which, &live)
249            .map(|owner| state.id_of(which, &owner))
250            .map_err(|e| e.to_string()),
251    }
252}
253
254/// Who owns the live login, answered from the record rather than from Anthropic, where the
255/// record is enough to answer it.
256///
257/// Recovery needs to know which side of the switch the live credential came from, and
258/// asking Anthropic is the only answer that survives Claude Code rotating a token. But the
259/// two candidates are both Pitboard's own documents and their refresh tokens were
260/// fingerprinted when the record was written, so the common case is a comparison and not a
261/// round trip. That is what lets a switch be recovered on a plane.
262///
263/// It narrows the network dependency rather than removing it. A rotation inside the seconds
264/// of an interrupted switch leaves a fingerprint matching neither side, which is exactly
265/// when this says nothing and Anthropic is asked after all.
266fn live_owner_by_fingerprint(journal: &Journal, live: &Value) -> Option<String> {
267    if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
268        return None;
269    }
270    if journal.from_fingerprint == journal.to_fingerprint {
271        return None;
272    }
273    let found = crate::provider::of(journal.provider).fingerprint(live);
274    if found.is_empty() {
275        return None;
276    }
277    if found == journal.to_fingerprint {
278        Some(journal.to_id.clone())
279    } else if found == journal.from_fingerprint {
280        Some(journal.from_id.clone())
281    } else {
282        None
283    }
284}
285
286/// What abandoning an unfinishable record decided to keep.
287#[derive(Debug)]
288pub struct Abandoned {
289    pub from: String,
290    pub to: String,
291    /// Copies kept rather than deleted, because which one is live is now unknown.
292    pub kept: usize,
293}
294
295/// Throws away a record that cannot be finished, keeping every copy it names.
296///
297/// Recovery needs the service to say who owns the live login. Offline, or with a session
298/// the service no longer accepts, it cannot, and every command that changes anything stops
299/// at that. This is the way out: nothing is installed and nothing that might be the only
300/// copy is deleted, so the worst case is a copy that outlives its use, which `status` shows
301/// and `doctor` reports.
302///
303/// One exception, for a tool whose park may never be a copy: a park whose refresh token is
304/// the live login's own is a second copy for certain, whoever owns it, and is dropped
305/// rather than kept.
306pub(super) fn abandon(
307    ctx: &Context,
308    permit: Permit,
309    state: &mut State,
310) -> Result<Option<Abandoned>> {
311    let path = journal_path(ctx);
312    let raw = match std::fs::read_to_string(&path) {
313        Ok(r) => r,
314        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
315        Err(source) => return Err(Error::RecoveryFailed { path, source }),
316    };
317    let journal = serde_json::from_str::<Journal>(&raw)
318        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
319
320    // The copy the interrupted run parked is recorded against the account it came from, so
321    // nothing holds a keychain item that no file names.
322    let mut kept = 0;
323    if let Some(Some(document)) = read_park(ctx, &journal.park_service)
324        && park::is_live_twin(ctx, journal.provider, &document)
325    {
326        state.discard(&journal.park_service);
327    } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
328        && let Some(key) = state
329            .by_id(journal.provider, &journal.from_id)
330            .map(crate::state::Account::key)
331    {
332        state.park(
333            &key,
334            park::describe(
335                journal.provider,
336                &journal.park_service,
337                ctx.now(),
338                &document,
339            ),
340        );
341        kept += 1;
342    }
343    let incoming = read_park(ctx, &journal.incoming_service).flatten();
344    if incoming.is_some_and(|document| park::is_live_twin(ctx, journal.provider, &document)) {
345        state.discard(&journal.incoming_service);
346    } else if state
347        .by_id(journal.provider, &journal.to_id)
348        .and_then(|a| a.parked.as_ref())
349        .is_some()
350    {
351        kept += 1;
352    }
353    state::save(ctx, permit, state)?;
354    clear_journal(ctx, permit);
355    Ok(Some(Abandoned {
356        from: state.typed(&journal.from()),
357        to: state.typed(&journal.to()),
358        kept,
359    }))
360}
361
362/// The record of an interrupted switch, with every fact recovery decides it from that can be
363/// read without asking anybody.
364struct Waiting {
365    journal: Journal,
366    /// The park the record reserved: written, never written, or `None` if unreadable.
367    parked: Option<Option<Value>>,
368    owner: Owner,
369}
370
371/// The record of an interrupted switch, where one is waiting, and what recovery decides it
372/// from. Reads, and writes nothing whatever it finds.
373fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
374    let path = journal_path(ctx);
375    let raw = match std::fs::read_to_string(&path) {
376        Ok(r) => r,
377        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
378        Err(source) => return Err(Error::RecoveryFailed { path, source }),
379    };
380    // Written atomically, so a record that does not parse was damaged afterwards and says
381    // nothing about how far its switch got.
382    let journal = serde_json::from_str::<Journal>(&raw)
383        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
384
385    if let Some(slot) = &journal.slot {
386        let here = crate::provider::of(journal.provider).slot(ctx);
387        if *slot != here {
388            return Err(Error::RecoveryElsewhere {
389                tool: journal.provider,
390                from: state.typed(&journal.from()),
391                to: state.typed(&journal.to()),
392                slot: slot.clone(),
393            });
394        }
395    }
396
397    let owner = live_owner(ctx, &journal);
398    Ok(Some(Waiting {
399        parked: read_park(ctx, &journal.park_service),
400        journal,
401        owner,
402    }))
403}
404
405/// What finishes or undoes the switch, or, where the facts do not settle what it did, the
406/// refusal of the change that found them so.
407fn decide(
408    state: &State,
409    journal: &Journal,
410    parked: Option<Option<Value>>,
411    owner: std::result::Result<String, String>,
412) -> Result<Repair> {
413    let found = Found {
414        parked,
415        live_owner: owner.as_ref().ok().cloned(),
416    };
417    repair_for(state, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
418        tool: journal.provider,
419        from: state.typed(&journal.from()),
420        to: state.typed(&journal.to()),
421        detail: owner
422            .err()
423            .unwrap_or_else(|| "its parked login could not be read".into()),
424    })
425}
426
427pub(super) fn reconcile(
428    ctx: &Context,
429    permit: Permit,
430    state: &mut State,
431) -> Result<Option<Recovered>> {
432    let Some(Waiting {
433        journal,
434        parked,
435        owner,
436    }) = read(ctx, state)?
437    else {
438        return Ok(None);
439    };
440    let owner = ask(ctx, state, journal.provider, owner);
441    let repair = decide(state, &journal, parked, owner)?;
442    let finished = repair.landed;
443    apply(state, &journal, repair);
444    state::save(ctx, permit, state)?;
445    clear_journal(ctx, permit);
446
447    Ok(Some(Recovered {
448        from: state.typed(&journal.from()),
449        to: state.typed(&journal.to()),
450        finished,
451    }))
452}
453
454/// Whether working out what an interrupted switch did may ask the service whose the live
455/// login is, where the record cannot say. A change asks, and so does `status`, which asks
456/// the service about every account anyway; `doctor` and `status_offline` send no request.
457/// A tool whose login names its own account is identified from the login either way, which
458/// asks nobody.
459#[derive(Clone, Copy, Debug, PartialEq, Eq)]
460pub(crate) enum Asking {
461    Service,
462    Nobody,
463}
464
465/// The refusal the next change would make over the interrupted switch waiting here, worked
466/// out by reading what [`reconcile`] reads and deciding as it decides, and changing nothing.
467///
468/// `None` where no switch is waiting, where the next change finishes or undoes it, and
469/// where telling which would need the service and `asking` says not to ask it. A record
470/// that cannot be read, or that was written for another slot, is refused by the next
471/// change for that reason and not for this one, and is not this function's to say.
472pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
473    let Waiting {
474        journal,
475        parked,
476        owner,
477    } = read(ctx, state).ok()??;
478    if asking == Asking::Nobody
479        && matches!(owner, Owner::Unasked(_))
480        && !crate::provider::of(journal.provider).identifies_by_itself()
481    {
482        return None;
483    }
484    let owner = ask(ctx, state, journal.provider, owner);
485    decide(state, &journal, parked, owner).err()
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::state::Account;
492
493    const PARK: &str = "pitboard-park-from-uuid-1700000000000";
494    const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
495
496    /// A record as 0.8.0 writes it. An account enrolled then kept its account uuid as its
497    /// id, so what the record names is still each account's id.
498    #[test]
499    fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
500        let written: Journal = serde_json::from_value(serde_json::json!({
501            "provider": "claude",
502            "started_at": 1_700_000_000,
503            "from_label": "from",
504            "from_uuid": "from-uuid",
505            "to_label": "to",
506            "to_uuid": "to-uuid",
507            "park_service": PARK,
508            "incoming_service": INCOMING,
509            "from_fingerprint": "f",
510            "to_fingerprint": "t",
511            "slot": "Claude Code-credentials"
512        }))
513        .expect("still read");
514        assert_eq!(
515            (written.from_id.as_str(), written.to_id.as_str()),
516            ("from-uuid", "to-uuid")
517        );
518    }
519
520    fn journal() -> Journal {
521        Journal {
522            provider: ProviderId::Claude,
523            started_at: 1_700_000_000,
524            from_label: "from".into(),
525            from_id: "from-uuid".into(),
526            to_label: "to".into(),
527            to_id: "to-uuid".into(),
528            park_service: PARK.into(),
529            incoming_service: INCOMING.into(),
530            from_fingerprint: "ffffffffffffffff".into(),
531            to_fingerprint: "0000000000000000".into(),
532            slot: None,
533        }
534    }
535
536    /// A record written before fingerprints existed simply asks, which is what it always
537    /// did. Reading an old record must never be a reason to refuse.
538    #[test]
539    fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
540        let mut j = journal();
541        j.from_fingerprint = String::new();
542        j.to_fingerprint = String::new();
543        assert_eq!(live_owner_by_fingerprint(&j, &live("refresh")), None);
544    }
545
546    /// Two sides that fingerprint the same are not two sides. Nothing can be read off that,
547    /// not even from a login that matches both.
548    #[test]
549    fn identical_fingerprints_settle_nothing() {
550        let mut j = journal();
551        let live = live("refresh");
552        j.from_fingerprint = crate::provider::of(ProviderId::Claude).fingerprint(&live);
553        j.to_fingerprint = j.from_fingerprint.clone();
554        assert_eq!(live_owner_by_fingerprint(&j, &live), None);
555    }
556
557    /// A Claude Code login on `refresh`.
558    fn live(refresh: &str) -> Value {
559        serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}})
560    }
561
562    fn account(label: &str, parked: Option<&str>) -> Account {
563        Account {
564            last_used_at: None,
565            label: label.into(),
566            id: format!("{label}-uuid"),
567            account_uuid: format!("{label}-uuid"),
568            email: format!("{label}@example.com"),
569            detail: state::Detail::Claude {
570                organization_uuid: format!("{label}-org"),
571                oauth_account: serde_json::json!({}),
572            },
573            parked: parked.map(|s| Park {
574                service: s.into(),
575                parked_at: 1_699_000_000,
576                refresh_fingerprint: "f".into(),
577                access_expires_at: None,
578                refresh_expires_at: None,
579            }),
580        }
581    }
582
583    /// `from` signed in with nothing parked, `to` parked: the state before a switch.
584    fn before() -> State {
585        State {
586            accounts: vec![account("from", None), account("to", Some(INCOMING))],
587            ..State::default()
588        }
589    }
590
591    fn written() -> Option<Option<Value>> {
592        Some(Some(
593            serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
594        ))
595    }
596
597    fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
598        Found {
599            parked,
600            live_owner: owner.map(str::to_owned),
601        }
602    }
603
604    /// Killed after reserving the park name but before writing it.
605    #[test]
606    fn nothing_parked_and_nothing_installed_changes_nothing() {
607        let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
608        assert_eq!(repair, Some(Repair::default()));
609    }
610
611    /// Killed after the park was written, before the install. `from` is still signed in, so
612    /// the park is a second copy of a live login and Claude Code will rotate past it.
613    #[test]
614    fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
615        for s in [before(), {
616            let mut recorded = before();
617            recorded.park(
618                &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
619                account("x", Some(PARK)).parked.unwrap(),
620            );
621            recorded
622        }] {
623            let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
624            assert!(repair.drop && repair.hold.is_none() && !repair.landed);
625
626            let mut applied = s;
627            apply(&mut applied, &journal(), repair);
628            assert!(!applied.references(PARK));
629            assert!(applied.discarded.contains(&PARK.to_string()));
630        }
631    }
632
633    /// Killed after the install, before state recorded it. Claude Code may already have
634    /// rotated the token.
635    #[test]
636    fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
637        let mut s = before();
638        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
639        let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
640        assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
641        assert_eq!(park.service, PARK);
642        assert!(repair.landed);
643
644        apply(&mut s, &journal(), repair);
645        assert_eq!(s.active_for(ProviderId::Claude), Some("to"));
646        assert_eq!(
647            s.get(&crate::state::Key::new(
648                crate::provider::ProviderId::Claude,
649                "from"
650            ))
651            .unwrap()
652            .parked
653            .as_ref()
654            .unwrap()
655            .service,
656            PARK
657        );
658        assert!(
659            s.get(&crate::state::Key::new(
660                crate::provider::ProviderId::Claude,
661                "to"
662            ))
663            .unwrap()
664            .parked
665            .is_none(),
666            "the copy now live must never be offered again"
667        );
668        assert!(s.discarded.contains(&INCOMING.to_string()));
669    }
670
671    /// Someone signed in as a third account since: the orphan may be the only copy of
672    /// `from`'s login, and the destination's park may still be good.
673    #[test]
674    fn a_third_account_signed_in_since_keeps_both_parks() {
675        let mut s = before();
676        let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
677        apply(&mut s, &journal(), repair);
678        assert!(s.references(PARK) && s.references(INCOMING));
679        assert!(s.discarded.is_empty());
680    }
681
682    #[test]
683    fn an_already_recorded_park_is_not_held_twice() {
684        let mut s = before();
685        s.park(
686            &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
687            account("x", Some(PARK)).parked.unwrap(),
688        );
689        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
690        assert_eq!(repair.hold, None);
691    }
692
693    #[test]
694    fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
695        for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
696            assert_eq!(
697                repair_for(&before(), &journal(), &unknown),
698                None,
699                "could-not-tell must never be read as nothing-there"
700            );
701        }
702    }
703
704    #[test]
705    fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
706        let mut s = State {
707            accounts: vec![account("other", None)],
708            ..State::default()
709        };
710        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
711        apply(&mut s, &journal(), repair);
712        assert!(
713            !s.references(PARK),
714            "a park must never be filed under whatever account happens to hold a label"
715        );
716        assert_eq!(
717            s.active_for(ProviderId::Claude),
718            None,
719            "a destination that is gone is not made active"
720        );
721    }
722}