trusty-common 0.59.2

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
//! Palace-level alias map: redirect one palace name to another (issue #1939).
//!
//! Why: trusty-mpm pins a managed session's `TRUSTY_MEMORY_PALACE` to the
//! `owner-repo` slug that [`crate::derive_palace_id`] produces (e.g.
//! `bobmatnyc-trusty-tools`), but the pre-existing claude-mpm-era palace for a
//! repo is the BARE repo name (`trusty-tools`). When the `owner-repo` palace has
//! never been created, every memory tool call fails with "palace metadata
//! missing" while the real history lives under the bare name — memory is
//! split-brained. This module persists a small alias map so a lookup for a
//! non-existent palace can be transparently redirected to an existing target,
//! letting BOTH names resolve to the one on-disk store.
//!
//! What: a JSON file `<registry_dir>/palace_aliases.json` mapping
//! `alias_name -> target_palace`. Every write runs under the file's
//! cross-process lock through [`crate::json_rmw`] and refuses a corrupt file
//! (#9544); reads are unlocked and forgiving (a missing/empty/corrupt file is
//! treated as "no aliases").
//! Resolution is consulted by [`crate::memory_core::registry::PalaceRegistry`]
//! ONLY when the requested palace has no metadata on disk, so aliases never
//! shadow a real palace of the same name. This is DISTINCT from the term/KG
//! entity aliases exposed by the `add_alias`/`discover_aliases` MCP tools — those
//! live INSIDE a palace's knowledge graph; this is a PALACE-level redirect that
//! lives beside the per-palace subdirectories.
//!
//! Test: `crate::palace_alias::tests` covers round-trip register/resolve, the
//! missing-file default, idempotent re-registration, self-alias rejection, the
//! rename retarget and alias removal (#9544), and the `palace_registry_dir_from`
//! subdir resolution.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// Filename for the persisted palace-alias map (beside per-palace subdirs).
///
/// Why: the map must live in the SAME directory the daemon uses as its palace
/// registry root (`AppState::data_root`) so a single lookup finds it. Naming it
/// with a `.json` suffix (not a directory) means the palace-listing walker —
/// which only descends into subdirectories containing `palace.json` — skips it
/// automatically.
/// What: `"palace_aliases.json"`.
/// Test: `register_then_resolve_round_trips` writes/reads this path.
const PALACE_ALIASES_JSON: &str = "palace_aliases.json";

/// On-disk shape of the alias map (versioned for forward compatibility).
///
/// Why: wrapping the flat map in a struct with an explicit `version` lets the
/// schema evolve (e.g. add per-alias metadata) without breaking older readers.
/// What: `version` (defaulted to 1 for files written before it existed) plus the
/// `aliases` map. Serialised with `serde_json` pretty output.
/// Test: `register_then_resolve_round_trips`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PalaceAliasesFile {
    /// Schema version; defaults to 1 when absent so pre-version files still load.
    #[serde(default = "default_alias_schema_version")]
    version: u32,
    /// `alias_name -> target_palace` redirects. `BTreeMap` keeps the on-disk
    /// order stable so diffs are deterministic.
    #[serde(default)]
    aliases: BTreeMap<String, String>,
}

fn default_alias_schema_version() -> u32 {
    1
}

// #9544: a derived `Default` stamped `version: 0` on the first locked write.
impl Default for PalaceAliasesFile {
    fn default() -> Self {
        Self {
            version: default_alias_schema_version(),
            aliases: BTreeMap::new(),
        }
    }
}

/// One alias key a write changed: its value before and after (#9544).
///
/// `None` means the key was absent on that side of the write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AliasChange {
    /// The alias name.
    pub key: String,
    /// The target before the write.
    pub before: Option<String>,
    /// The target the write left.
    pub after: Option<String>,
}

/// What [`PalaceAliasStore::rename_target_with_undo`] changed, so a failed
/// palace move can put those keys back (#9544).
///
/// Why: a rollback that rewrites the whole map from a snapshot would also undo
/// every alias another writer registered in between. Recording only the
/// touched keys lets [`PalaceAliasStore::undo`] restore exactly those.
/// What: one [`AliasChange`] per key whose value the write changed, in key
/// order. Empty when the write found the map already in the target state.
/// Test: `rename_target_returns_the_touched_keys_and_undo_restores_them`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[must_use = "a palace rename rolls back through this on a failed move"]
pub struct AliasUndo {
    /// The keys the write changed.
    pub changes: Vec<AliasChange>,
}

/// Outcome of one locked alias-map mutation, as the `json_rmw` closure error.
///
/// `Unchanged` carries the closure's result out WITHOUT publishing, so a
/// mutation that changed nothing never creates or rewrites the file.
enum AliasRmw<R> {
    Rmw(crate::json_rmw::JsonRmwError),
    Unchanged(R),
}

impl<R> From<crate::json_rmw::JsonRmwError> for AliasRmw<R> {
    fn from(e: crate::json_rmw::JsonRmwError) -> Self {
        Self::Rmw(e)
    }
}

/// Run `f` over the alias map under the alias file's exclusive lock.
///
/// Why (#9544): every alias writer must serialise on one cross-process lock,
/// re-read the map under it, and refuse a corrupt file rather than overwrite
/// it. Before this, each writer did an unlocked load and a fixed-name tmp
/// write, so two concurrent writers could lose an update or publish a torn file.
/// What: [`crate::json_rmw::update_blank_as_absent`] on
/// `<registry_dir>/palace_aliases.json`: a missing or whitespace-only file
/// starts empty at `version` 1, a corrupt one is an error with its bytes
/// unchanged. The map is published only when `f` changed it. Blocks while
/// another writer holds the lock (bounded by
/// [`crate::file_lock::DEFAULT_LOCK_TIMEOUT`]).
/// Test: `alias_writers_block_on_the_alias_lock`,
/// `register_alias_refuses_a_corrupt_file_and_keeps_its_bytes`,
/// `whitespace_only_alias_file_still_accepts_a_write`,
/// `first_write_stamps_version_1`.
fn mutate_aliases<R>(
    registry_dir: &Path,
    f: impl FnOnce(&mut BTreeMap<String, String>) -> R,
) -> Result<R> {
    std::fs::create_dir_all(registry_dir)
        .with_context(|| format!("create registry dir {}", registry_dir.display()))?;
    let path = registry_dir.join(PALACE_ALIASES_JSON);
    let outcome = crate::json_rmw::update_blank_as_absent(
        &path,
        |file: &mut PalaceAliasesFile| -> std::result::Result<R, AliasRmw<R>> {
            let before = file.aliases.clone();
            let result = f(&mut file.aliases);
            if file.aliases == before {
                return Err(AliasRmw::Unchanged(result));
            }
            file.version = file.version.max(default_alias_schema_version());
            Ok(result)
        },
    );
    match outcome {
        Ok(result) | Err(AliasRmw::Unchanged(result)) => Ok(result),
        Err(AliasRmw::Rmw(e)) => {
            Err(e).with_context(|| format!("update palace aliases at {}", path.display()))
        }
    }
}

/// Set `key` to `value` (`None` removes it), recording the change if any.
fn set_alias(
    aliases: &mut BTreeMap<String, String>,
    changes: &mut Vec<AliasChange>,
    key: &str,
    value: Option<String>,
) {
    let before = match &value {
        Some(v) => aliases.insert(key.to_string(), v.clone()),
        None => aliases.remove(key),
    };
    if before != value {
        changes.push(AliasChange {
            key: key.to_string(),
            before,
            after: value,
        });
    }
}

/// Stateless namespace for palace-alias persistence.
///
/// Why: like [`crate::memory_core::store::palace_store::PalaceStore`], alias
/// persistence has no state of its own — every operation is a pure function over
/// a registry directory. Grouping under a unit struct gives a stable import path.
/// What: `load_aliases` / `register_alias` / `resolve_alias`, plus (#9544)
/// `rename_target` / `remove_alias`.
/// Test: this module's `tests`.
pub struct PalaceAliasStore;

impl PalaceAliasStore {
    /// Load the full alias map for a registry directory.
    ///
    /// Why: callers that want the whole map (diagnostics, bulk resolution) get it
    /// in one read. A missing file is the common case (no aliases registered yet)
    /// and must NOT be an error, so a fresh install behaves like an empty map.
    /// What: reads `<registry_dir>/palace_aliases.json`. Returns an empty map when
    /// the file is absent, empty, or fails to parse (corruption is logged and
    /// treated as "no aliases" so a bad file can never wedge palace resolution).
    /// Unlocked: a reader never waits on a writer, and the atomic publish
    /// means it sees a whole map. Any read error other than absence is returned.
    /// Test: `load_missing_is_empty`, `register_then_resolve_round_trips`,
    /// `corrupt_file_degrades_to_empty`.
    pub fn load_aliases(registry_dir: &Path) -> Result<BTreeMap<String, String>> {
        let path = registry_dir.join(PALACE_ALIASES_JSON);
        let bytes = match std::fs::read(&path) {
            Ok(b) => b,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
            Err(e) => {
                return Err(e)
                    .with_context(|| format!("read palace aliases at {}", path.display()));
            }
        };
        if bytes.iter().all(u8::is_ascii_whitespace) {
            return Ok(BTreeMap::new());
        }
        match serde_json::from_slice::<PalaceAliasesFile>(&bytes) {
            Ok(file) => Ok(file.aliases),
            Err(e) => {
                // A corrupt alias file must not break palace resolution — the
                // authoritative data is the palaces themselves. Log and degrade
                // to "no aliases".
                tracing::warn!(
                    path = %path.display(),
                    error = %e,
                    "palace alias file is unparseable; ignoring (treating as no aliases)"
                );
                Ok(BTreeMap::new())
            }
        }
    }

    /// Register (or overwrite) an alias `alias_name -> target`, persisting it.
    ///
    /// Why: trusty-mpm calls this at session launch to bind a derived
    /// `owner-repo` name to an existing bare-repo palace so the split-brain
    /// resolves. It must be idempotent — relaunching the same session repeatedly
    /// must converge on one entry, not error or duplicate.
    /// What: rejects empty operands and a self-alias (`alias == target`, which
    /// would be a useless no-op / cycle). Otherwise inserts/updates the entry
    /// under the alias file's lock (see [`mutate_aliases`]); a corrupt alias
    /// file is an error and keeps its bytes (#9544). Re-registering the same
    /// pair writes nothing. Returns `Ok(())`.
    /// Test: `register_then_resolve_round_trips`, `register_is_idempotent`,
    /// `register_self_alias_is_rejected`, `register_rejects_empty`,
    /// `register_alias_refuses_a_corrupt_file_and_keeps_its_bytes`.
    pub fn register_alias(registry_dir: &Path, alias: &str, target: &str) -> Result<()> {
        let alias = alias.trim();
        let target = target.trim();
        if alias.is_empty() || target.is_empty() {
            anyhow::bail!(
                "palace alias and target must both be non-empty (alias={alias:?}, target={target:?})"
            );
        }
        if alias == target {
            anyhow::bail!(
                "refusing to register a self-referential palace alias {alias:?} -> {target:?}"
            );
        }

        // #9544: locked, fail-closed write; a corrupt file is not overwritten.
        mutate_aliases(registry_dir, |aliases| {
            aliases.insert(alias.to_string(), target.to_string());
        })
    }

    /// Point a renamed palace's old id, and every alias of it, at the new id.
    ///
    /// Why (#9544): a palace id rename must keep the old id answering. Writing
    /// `old -> new` alone would strand an older alias `x -> old` on a name that
    /// no longer owns a palace, and a separate retarget write would leave a
    /// window where the map names a dead target. One write covers both.
    /// What: rejects empty operands and `old == new`. Then, in one locked write
    /// (see [`mutate_aliases`]): drops any entry keyed by `new` (that id becomes
    /// a real palace, so an alias under it is inert and would form an
    /// `old <-> new` cycle when a rename is reversed), repoints every
    /// `x -> old` to `x -> new`, and inserts `old -> new`. A corrupt alias file
    /// is an error and is left unchanged, since rewriting it would drop every
    /// alias it holds. [`Self::rename_target_with_undo`] is the same write,
    /// returning what it changed.
    ///
    /// Caller contract (#9544): `old -> new` is inert while `old` still has a
    /// `palace.json`, so the write may precede the directory move. The
    /// retargeted `x -> new` entries are NOT inert: until `new` has a
    /// `palace.json`, `x` resolves to nothing (its old redirect to `old` is
    /// gone). So the caller must hold the palace open-locks for `old` and `new`
    /// across the write and the move, and on a failed move must undo it: call
    /// [`Self::rename_target_with_undo`] and pass its result to [`Self::undo`],
    /// which restores exactly the keys the write changed.
    /// Test: `rename_retargets_aliases_pointing_at_the_old_id`,
    /// `rename_drops_the_alias_keyed_by_the_new_id`,
    /// `rename_target_rejects_empty_or_equal_ids`,
    /// `rename_target_refuses_a_corrupt_alias_file_and_keeps_its_bytes`,
    /// `rename_target_returns_the_touched_keys_and_undo_restores_them`.
    pub fn rename_target(registry_dir: &Path, old: &str, new: &str) -> Result<()> {
        // #9544: the signature 0.59.1 shipped; the undo data is additive.
        Self::rename_target_with_undo(registry_dir, old, new).map(drop)
    }

    /// [`Self::rename_target`], returning an [`AliasUndo`] naming every key it
    /// changed (#9544), so a failed palace move can restore exactly those.
    /// Test: `rename_target_returns_the_touched_keys_and_undo_restores_them`.
    pub fn rename_target_with_undo(registry_dir: &Path, old: &str, new: &str) -> Result<AliasUndo> {
        let old = old.trim();
        let new = new.trim();
        if old.is_empty() || new.is_empty() {
            anyhow::bail!("palace rename ids must both be non-empty (old={old:?}, new={new:?})");
        }
        if old == new {
            anyhow::bail!("refusing to alias palace {old:?} to itself");
        }
        // #9544: locked; a corrupt file fails the rename instead of being
        // overwritten with `{old: new}`.
        let changes = mutate_aliases(registry_dir, |aliases| {
            let mut changes = Vec::new();
            // #9544: `new` becomes a real palace; an alias under it is inert
            // and would close an `old <-> new` cycle when a rename is reversed.
            set_alias(aliases, &mut changes, new, None);
            let retarget: Vec<String> = aliases
                .iter()
                .filter(|(_, target)| target.as_str() == old)
                .map(|(key, _)| key.clone())
                .collect();
            for key in retarget {
                set_alias(aliases, &mut changes, &key, Some(new.to_string()));
            }
            set_alias(aliases, &mut changes, old, Some(new.to_string()));
            changes.sort_by(|a, b| a.key.cmp(&b.key));
            changes
        })?;
        Ok(AliasUndo { changes })
    }

    /// Put back the keys a [`Self::rename_target_with_undo`] changed (#9544).
    ///
    /// Why: a palace move that fails after the alias write must leave the map
    /// as it was for those keys, without clobbering aliases another writer set
    /// in between.
    /// What: under the alias file's lock, restores each change's `before` value
    /// (removing the key when it was absent), but only where the key still
    /// holds the change's `after` value. A key someone else changed since is
    /// left alone and returned, so the caller can report it. A corrupt alias
    /// file is an error and keeps its bytes.
    /// Test: `rename_target_returns_the_touched_keys_and_undo_restores_them`.
    pub fn undo(registry_dir: &Path, undo: &AliasUndo) -> Result<Vec<String>> {
        if undo.changes.is_empty() {
            return Ok(Vec::new());
        }
        mutate_aliases(registry_dir, |aliases| {
            let mut skipped = Vec::new();
            for change in &undo.changes {
                if aliases.get(&change.key) != change.after.as_ref() {
                    skipped.push(change.key.clone());
                    continue;
                }
                match &change.before {
                    Some(v) => aliases.insert(change.key.clone(), v.clone()),
                    None => aliases.remove(&change.key),
                };
            }
            skipped
        })
    }

    /// Remove one alias so its name stops resolving.
    ///
    /// Why (#9544): a renamed palace's old id answers "until removed"; this is
    /// the removal.
    /// What: under the alias file's lock (see [`mutate_aliases`]), removes the
    /// entry keyed by `alias` (trimmed); the file is rewritten only when an
    /// entry was removed. Returns whether one was. A corrupt alias file is an
    /// error and keeps its bytes (#9544). The target palace is never touched.
    /// Test: `alias_remove_stops_old_id_resolving`,
    /// `remove_alias_errors_on_a_corrupt_file`.
    pub fn remove_alias(registry_dir: &Path, alias: &str) -> Result<bool> {
        // #9544: locked, fail-closed; a corrupt file is an error, not `false`.
        mutate_aliases(registry_dir, |aliases| {
            aliases.remove(alias.trim()).is_some()
        })
    }

    /// Resolve a single alias to its target palace name, if one is registered.
    ///
    /// Why: the palace registry consults this when a requested palace has no
    /// on-disk metadata — a hit redirects the open to the target's store.
    /// What: loads the map and returns `Some(target)` when `alias` is present,
    /// `None` otherwise. A missing map yields `None`.
    /// Test: `register_then_resolve_round_trips`, `resolve_unknown_is_none`.
    pub fn resolve_alias(registry_dir: &Path, alias: &str) -> Result<Option<String>> {
        Ok(Self::load_aliases(registry_dir)?.get(alias.trim()).cloned())
    }
}

/// Name the palace an alias redirects to, but only when the redirect actually fires.
///
/// Why (#5810): the registry applied this rule privately, so every caller that
/// wanted to *name* the palace it was about to reach had no way to ask. The
/// `UserPromptSubmit` hook printed the derived slug — `bobmatnyc-trusty-tools` —
/// while the drawers under it came from `trusty-tools`, and `palace_list` returns
/// only the latter. A second copy of the rule in trusty-memory would be the drift
/// this workspace's common-entry-point rule forbids, so the rule lives here and
/// [`crate::memory_core::registry`] now reads it from the same place.
/// What: returns `Some(target)` only when `<registry_dir>/<palace_id>/palace.json`
/// is absent AND an alias maps `palace_id` to a `target` whose own `palace.json`
/// is present. Returns `None` in every other case — the palace exists, no alias is
/// registered, the target is missing, or the alias file cannot be read — so a
/// caller's `unwrap_or(derived)` keeps today's behaviour on every degraded path.
///
/// Presence is `try_exists`, and only `Ok(false)` counts as absent (#5592,
/// ADR-0045): a path we are denied to stat presumes PRESENT, so an unverifiable
/// alias target never wins a redirect and an unstattable palace is never shadowed.
/// Test: `alias_target_is_none_when_the_palace_exists`,
/// `alias_target_names_the_redirect`,
/// `alias_target_is_none_when_the_target_is_missing`,
/// `alias_target_is_none_without_an_alias`.
pub fn alias_target_if_absent(registry_dir: &Path, palace_id: &str) -> Option<String> {
    try_alias_target_if_absent(registry_dir, palace_id)
        .ok()
        .flatten()
}

/// [`alias_target_if_absent`], but an unreadable alias file is an error.
///
/// Why (#9544): the `create_palace` guard must fail closed. Reading an
/// unreadable alias file as "no alias" lets a create through that shadows the
/// alias for good once the read error clears.
/// What: the same presence rule and redirect as [`alias_target_if_absent`].
/// A missing alias file is `Ok(None)`, and a corrupt one reads as no aliases,
/// as at open time. Any other read error on the alias file is returned.
/// Test: `palace_create_fails_when_the_alias_file_is_unreadable`.
pub(crate) fn try_alias_target_if_absent(
    registry_dir: &Path,
    palace_id: &str,
) -> Result<Option<String>> {
    let exists = |id: &str| {
        !matches!(
            registry_dir.join(id).join("palace.json").try_exists(),
            Ok(false)
        )
    };
    if exists(palace_id) {
        return Ok(None);
    }
    // #9544: propagate a read error; the infallible wrapper drops it.
    Ok(PalaceAliasStore::resolve_alias(registry_dir, palace_id)?.filter(|target| exists(target)))
}

/// The id a palace request actually reaches: the live alias target, else itself.
///
/// Why (#9544): every caller that keys state by palace id (write locks, session
/// stores, lexical-lane dirs) must agree on one id per palace, or an
/// alias-addressed write and a canonical write take different locks for one
/// store. This names that id with the same rule the registry's open follows.
/// What: [`alias_target_if_absent`] when it names a redirect, `palace_id`
/// unchanged otherwise. Never fails.
/// Test: `canonical_palace_id_follows_only_a_live_alias`.
pub fn canonical_palace_id(registry_dir: &Path, palace_id: &str) -> String {
    alias_target_if_absent(registry_dir, palace_id).unwrap_or_else(|| palace_id.to_string())
}

/// A palace create refused because the id is a live alias.
///
/// Why (#9544): creating a palace under a name that currently redirects would
/// shadow the alias (a real palace always wins), silently splitting reads and
/// writes for that name off the palace it pointed at.
/// What: carries the alias and the palace it resolves to. Returned, inside an
/// `anyhow::Error`, by `PalaceRegistry::create_palace`; callers downcast it to
/// map the refusal to a conflict. Hand-rolled because `thiserror` is optional
/// in this crate and this module is always compiled.
/// Test: `palace_create_refuses_a_live_alias_name`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveAliasError {
    /// The requested palace id, which is a live alias.
    pub alias: String,
    /// The palace the alias resolves to.
    pub target: String,
}

impl std::fmt::Display for LiveAliasError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "palace id {:?} is an alias of palace {:?}; remove the alias before creating a palace with that id",
            self.alias, self.target
        )
    }
}

impl std::error::Error for LiveAliasError {}

/// Resolve the directory that holds the per-palace subdirectories for a data dir.
///
/// Why: two on-disk layouts exist in the wild. The current code treats the data
/// dir itself as the parent of per-palace dirs (`<data_dir>/<id>/palace.json`);
/// legacy standalone installs nest everything under a `palaces/` subdirectory
/// (`<data_dir>/palaces/<id>/palace.json`), which is where real installs' data
/// lives. trusty-mpm (which registers aliases) and trusty-memory (which resolves
/// them) MUST agree on this directory or the alias file lands somewhere the
/// daemon never reads. Centralising the choice here keeps the two in lockstep —
/// trusty-memory's `resolve_palace_registry_dir` delegates to this.
/// What: returns `<data_dir>/palaces` when that subdirectory exists, else
/// `<data_dir>` itself.
/// Test: `palace_registry_dir_prefers_palaces_subdir`,
/// `palace_registry_dir_falls_back_to_data_dir`.
pub fn palace_registry_dir_from(data_dir: PathBuf) -> PathBuf {
    let nested = data_dir.join("palaces");
    if nested.is_dir() { nested } else { data_dir }
}

/// Resolve the default trusty-memory palace registry directory for this machine.
///
/// Why: trusty-mpm's session-launch alias registration must write the alias file
/// to the exact directory the trusty-memory daemon uses as its palace registry
/// root, WITHOUT depending on the `trusty-memory` crate. Reusing
/// [`crate::resolve_data_dir`] (which honours `TRUSTY_DATA_DIR_OVERRIDE`) keeps
/// path derivation identical to the daemon's, so tests and production agree.
/// What: resolves `resolve_data_dir("trusty-memory")` (the app data dir, created
/// if absent) and applies [`palace_registry_dir_from`] to pick the `palaces/`
/// subdir when present. Returns the registry directory.
/// Test: side-effect-bearing (creates the app data dir); the pure subdir choice
/// it composes is covered by `palace_registry_dir_*`.
pub fn default_palace_registry_dir() -> Result<PathBuf> {
    let data_dir = crate::resolve_data_dir("trusty-memory")?;
    Ok(palace_registry_dir_from(data_dir))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    /// Why: a fresh install has no alias file; load must return an empty map,
    /// never an error, so palace resolution proceeds normally.
    /// Test: itself.
    #[test]
    fn load_missing_is_empty() {
        let tmp = tempdir().unwrap();
        let aliases = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert!(aliases.is_empty());
    }

    /// Why: the core contract — a registered alias must be readable back both via
    /// the full map and the single-key resolver, and it must survive as an
    /// on-disk file (a fresh read from the same dir).
    /// Test: itself.
    #[test]
    fn register_then_resolve_round_trips() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .expect("register");

        // Single-key resolve.
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "bobmatnyc-trusty-tools")
                .expect("resolve")
                .as_deref(),
            Some("trusty-tools")
        );
        // Full-map load reflects it too.
        let all = PalaceAliasStore::load_aliases(tmp.path()).expect("load");
        assert_eq!(
            all.get("bobmatnyc-trusty-tools").map(String::as_str),
            Some("trusty-tools")
        );
        // File actually exists on disk.
        assert!(tmp.path().join(PALACE_ALIASES_JSON).exists());
    }

    /// Why: relaunching the same managed session repeatedly must not duplicate or
    /// error — re-registering the same pair converges on one entry, and pointing
    /// an alias at a new target overwrites in place.
    /// Test: itself.
    #[test]
    fn register_is_idempotent() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("b"));

        // Overwrite to a new target.
        PalaceAliasStore::register_alias(tmp.path(), "a", "c").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("a").map(String::as_str), Some("c"));
    }

    /// Why: a self-alias is a useless no-op / potential cycle; it must be
    /// rejected so a mis-derived slug can never point a palace at itself.
    /// Test: itself.
    #[test]
    fn register_self_alias_is_rejected() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", "same").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "same", " same ").is_err());
    }

    /// Why: empty operands would create a meaningless map entry; guard against
    /// them so a caller passing a blank slug fails loudly rather than silently.
    /// Test: itself.
    #[test]
    fn register_rejects_empty() {
        let tmp = tempdir().unwrap();
        assert!(PalaceAliasStore::register_alias(tmp.path(), "", "b").is_err());
        assert!(PalaceAliasStore::register_alias(tmp.path(), "a", "  ").is_err());
    }

    /// Why: an alias that was never registered must resolve to `None` so the
    /// registry surfaces the normal "metadata missing" error.
    /// Test: itself.
    #[test]
    fn resolve_unknown_is_none() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        assert_eq!(
            PalaceAliasStore::resolve_alias(tmp.path(), "zzz").unwrap(),
            None
        );
    }

    /// Why: a corrupt alias file must degrade to "no aliases", never panic or
    /// error, so it cannot wedge palace resolution.
    /// Test: itself.
    #[test]
    fn corrupt_file_degrades_to_empty() {
        let tmp = tempdir().unwrap();
        std::fs::write(tmp.path().join(PALACE_ALIASES_JSON), b"{ not json ]").unwrap();
        let all =
            PalaceAliasStore::load_aliases(tmp.path()).expect("load never errors on corruption");
        assert!(all.is_empty());
    }

    /// Why: when a `palaces/` subdir exists (the real-install layout) the
    /// registry dir must point INTO it so aliases sit beside the palaces.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_prefers_palaces_subdir() {
        let tmp = tempdir().unwrap();
        let nested = tmp.path().join("palaces");
        std::fs::create_dir_all(&nested).unwrap();
        assert_eq!(palace_registry_dir_from(tmp.path().to_path_buf()), nested);
    }

    /// Write a minimal `palace.json` so the presence probe sees a real palace.
    fn seed_palace(registry_dir: &Path, id: &str) {
        let dir = registry_dir.join(id);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("palace.json"), b"{}").unwrap();
    }

    /// Why: an alias must never shadow a real palace, so a palace that exists on
    /// disk reports no redirect even when an alias names it.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_palace_exists() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "owner-repo");
        seed_palace(tmp.path(), "repo");
        PalaceAliasStore::register_alias(tmp.path(), "owner-repo", "repo").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "owner-repo"), None);
    }

    /// Why: the split-brain case this exists for — the derived name owns no
    /// directory, the alias target does, so the target is the usable name.
    /// Test: itself.
    #[test]
    fn alias_target_names_the_redirect() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "trusty-tools");
        PalaceAliasStore::register_alias(tmp.path(), "bobmatnyc-trusty-tools", "trusty-tools")
            .unwrap();
        assert_eq!(
            alias_target_if_absent(tmp.path(), "bobmatnyc-trusty-tools").as_deref(),
            Some("trusty-tools")
        );
    }

    /// Why: a stale alias pointing at a deleted palace must not rename anything —
    /// the caller keeps the original id so its error names the palace asked for.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_when_the_target_is_missing() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "ghost", "also-gone").unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "ghost"), None);
    }

    /// Why: the common case — no alias file at all — must be a cheap `None`.
    /// Test: itself.
    #[test]
    fn alias_target_is_none_without_an_alias() {
        let tmp = tempdir().unwrap();
        assert_eq!(alias_target_if_absent(tmp.path(), "anything"), None);
    }

    /// Why (#9544): after a rename, an older alias of the old id must reach the
    /// new id in the same write that makes the old id an alias, so no alias is
    /// left naming a palace id that no longer exists.
    /// Test: itself.
    #[test]
    fn rename_retargets_aliases_pointing_at_the_old_id() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "older", "old-id").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "unrelated", "elsewhere").unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "old-id", "new-id").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.get("old-id").map(String::as_str), Some("new-id"));
        assert_eq!(all.get("older").map(String::as_str), Some("new-id"));
        assert_eq!(all.get("unrelated").map(String::as_str), Some("elsewhere"));
        assert_eq!(all.len(), 3);

        // Once the directory has moved, both names resolve to the new palace.
        seed_palace(tmp.path(), "new-id");
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "new-id");
        assert_eq!(canonical_palace_id(tmp.path(), "older"), "new-id");
    }

    /// Why (#9544): reversing a rename (`a -> b`, then `b` back to `a`) must not
    /// leave `a -> b` and `b -> a` in the map; the new id owns a palace, so any
    /// alias keyed by it goes.
    /// Test: itself.
    #[test]
    fn rename_drops_the_alias_keyed_by_the_new_id() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "first", "second").unwrap();
        PalaceAliasStore::rename_target(tmp.path(), "second", "first").unwrap();
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.get("second").map(String::as_str), Some("first"));
        assert_eq!(
            all.get("first"),
            None,
            "no alias may be keyed by the new id"
        );
        assert!(all.iter().all(|(k, v)| k != v), "no self-alias: {all:?}");
    }

    /// Why (#9544): a blank id or a rename onto itself is a caller bug; it must
    /// fail and leave the map as it was.
    /// Test: itself.
    #[test]
    fn rename_target_rejects_empty_or_equal_ids() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "keep", "kept").unwrap();
        assert!(PalaceAliasStore::rename_target(tmp.path(), "", "b").is_err());
        assert!(PalaceAliasStore::rename_target(tmp.path(), "a", "  ").is_err());
        assert!(PalaceAliasStore::rename_target(tmp.path(), "same", " same ").is_err());
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.len(), 1);
        assert_eq!(all.get("keep").map(String::as_str), Some("kept"));
    }

    /// Why (#9544): a corrupt alias file may still hold aliases a person can
    /// recover. A rename that reads it as empty and writes `{old: new}` over it
    /// destroys them, so the rename must fail and leave the bytes as they were.
    /// Test: itself.
    #[test]
    fn rename_target_refuses_a_corrupt_alias_file_and_keeps_its_bytes() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join(PALACE_ALIASES_JSON);
        let garbage: &[u8] = br#"{"aliases": {"keep": "kept""#;
        std::fs::write(&path, garbage).unwrap();
        assert!(
            PalaceAliasStore::rename_target(tmp.path(), "old-id", "new-id").is_err(),
            "a corrupt alias file must refuse the rename"
        );
        assert_eq!(std::fs::read(&path).unwrap(), garbage);
    }

    /// Why (#9544): the old id answers "until removed"; after removal it must
    /// stop redirecting while every other alias stays.
    /// Test: itself.
    #[test]
    fn alias_remove_stops_old_id_resolving() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "new-id");
        PalaceAliasStore::register_alias(tmp.path(), "old-id", "new-id").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "other", "new-id").unwrap();
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "new-id");

        assert!(PalaceAliasStore::remove_alias(tmp.path(), " old-id ").unwrap());
        assert_eq!(canonical_palace_id(tmp.path(), "old-id"), "old-id");
        assert_eq!(canonical_palace_id(tmp.path(), "other"), "new-id");
        assert!(
            !PalaceAliasStore::remove_alias(tmp.path(), "old-id").unwrap(),
            "a second removal finds nothing"
        );
    }

    /// Why (#9544): the canonical id is the alias target only while the redirect
    /// is live; a real palace or a dead target keeps the requested id.
    /// Test: itself.
    #[test]
    fn canonical_palace_id_follows_only_a_live_alias() {
        let tmp = tempdir().unwrap();
        seed_palace(tmp.path(), "target");
        PalaceAliasStore::register_alias(tmp.path(), "live", "target").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "dead", "gone").unwrap();
        assert_eq!(canonical_palace_id(tmp.path(), "live"), "target");
        assert_eq!(canonical_palace_id(tmp.path(), "dead"), "dead");
        assert_eq!(canonical_palace_id(tmp.path(), "target"), "target");

        seed_palace(tmp.path(), "live");
        assert_eq!(canonical_palace_id(tmp.path(), "live"), "live");
    }

    /// Why (#9544): a register over a corrupt file used to read it as empty
    /// and overwrite it with one entry, destroying every alias it held.
    /// Test: itself.
    #[test]
    fn register_alias_refuses_a_corrupt_file_and_keeps_its_bytes() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join(PALACE_ALIASES_JSON);
        let garbage: &[u8] = br#"{"aliases": {"keep": "kept""#;
        std::fs::write(&path, garbage).unwrap();
        assert!(
            PalaceAliasStore::register_alias(tmp.path(), "a", "b").is_err(),
            "a corrupt alias file must refuse the register"
        );
        assert_eq!(std::fs::read(&path).unwrap(), garbage);
    }

    /// Why (#9544): a removal over a corrupt file answered `Ok(false)`, which
    /// reads as "no such alias" when nothing could be determined.
    /// Test: itself.
    #[test]
    fn remove_alias_errors_on_a_corrupt_file() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join(PALACE_ALIASES_JSON);
        let garbage: &[u8] = br#"{"aliases": {"keep": "kept""#;
        std::fs::write(&path, garbage).unwrap();
        assert!(
            PalaceAliasStore::remove_alias(tmp.path(), "keep").is_err(),
            "a corrupt alias file must make the removal fail"
        );
        assert_eq!(std::fs::read(&path).unwrap(), garbage);
    }

    /// Why (#9544): two unlocked writers can lose an update. Every writer must
    /// wait on the alias file's lock while another holder has it.
    /// What: holds the lock, starts `register_alias` and `rename_target` on
    /// threads, asserts neither finishes within 200 ms, releases, and asserts
    /// both finish and both writes landed.
    /// Test: itself.
    #[test]
    fn alias_writers_block_on_the_alias_lock() {
        use std::sync::mpsc;
        use std::time::Duration;
        let tmp = tempdir().unwrap();
        let dir = tmp.path().to_path_buf();
        let path = dir.join(PALACE_ALIASES_JSON);
        let (tx, rx) = mpsc::channel::<&'static str>();
        let mut workers = Vec::new();
        let held = crate::file_lock::with_exclusive_lock(&path, || {
            let (d, t) = (dir.clone(), tx.clone());
            workers.push(std::thread::spawn(move || {
                PalaceAliasStore::register_alias(&d, "x", "y").unwrap();
                t.send("register").unwrap();
            }));
            let (d, t) = (dir.clone(), tx.clone());
            workers.push(std::thread::spawn(move || {
                PalaceAliasStore::rename_target(&d, "old-id", "new-id").unwrap();
                t.send("rename").unwrap();
            }));
            rx.recv_timeout(Duration::from_millis(200))
        })
        .expect("take the alias lock");
        assert!(
            held.is_err(),
            "a writer finished while the alias lock was held: {held:?}"
        );
        let mut done = vec![
            rx.recv_timeout(Duration::from_secs(5))
                .expect("first writer"),
            rx.recv_timeout(Duration::from_secs(5))
                .expect("second writer"),
        ];
        done.sort_unstable();
        assert_eq!(done, ["register", "rename"]);
        for w in workers {
            w.join().unwrap();
        }
        let all = PalaceAliasStore::load_aliases(&dir).unwrap();
        assert_eq!(all.get("x").map(String::as_str), Some("y"));
        assert_eq!(all.get("old-id").map(String::as_str), Some("new-id"));
    }

    /// Why (#9544): a failed palace move must put back exactly the keys the
    /// rename changed, and leave a key another writer changed since alone.
    /// Test: itself.
    #[test]
    fn rename_target_returns_the_touched_keys_and_undo_restores_them() {
        let tmp = tempdir().unwrap();
        let dir = tmp.path();
        PalaceAliasStore::register_alias(dir, "older", "old-id").unwrap();
        PalaceAliasStore::register_alias(dir, "new-id", "elsewhere").unwrap();
        PalaceAliasStore::register_alias(dir, "unrelated", "kept").unwrap();
        let undo = PalaceAliasStore::rename_target_with_undo(dir, "old-id", "new-id").unwrap();
        let change = |key: &str, before: Option<&str>, after: Option<&str>| AliasChange {
            key: key.to_string(),
            before: before.map(str::to_string),
            after: after.map(str::to_string),
        };
        assert_eq!(
            undo.changes,
            vec![
                change("new-id", Some("elsewhere"), None),
                change("old-id", None, Some("new-id")),
                change("older", Some("old-id"), Some("new-id")),
            ]
        );

        // Another writer adds a key and changes one the rename touched.
        PalaceAliasStore::register_alias(dir, "late", "kept").unwrap();
        PalaceAliasStore::register_alias(dir, "older", "moved-on").unwrap();
        let skipped = PalaceAliasStore::undo(dir, &undo).unwrap();
        assert_eq!(skipped, ["older"]);

        let all = PalaceAliasStore::load_aliases(dir).unwrap();
        assert_eq!(all.get("new-id").map(String::as_str), Some("elsewhere"));
        assert_eq!(all.get("old-id"), None);
        assert_eq!(all.get("older").map(String::as_str), Some("moved-on"));
        assert_eq!(all.get("late").map(String::as_str), Some("kept"));
        assert_eq!(all.get("unrelated").map(String::as_str), Some("kept"));
    }

    /// Why (#9544, guard): readers accept a whitespace-only file as empty, so
    /// the locked writers must too, or a `tm` launch fails on it.
    /// Test: itself.
    #[test]
    fn whitespace_only_alias_file_still_accepts_a_write() {
        let tmp = tempdir().unwrap();
        std::fs::write(tmp.path().join(PALACE_ALIASES_JSON), b" \n\t\n").unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").expect("register over blank");
        let all = PalaceAliasStore::load_aliases(tmp.path()).unwrap();
        assert_eq!(all.get("a").map(String::as_str), Some("b"));
    }

    /// Why (#9544, guard): the first write must stamp schema version 1, not
    /// the 0 a derived `Default` would give.
    /// Test: itself.
    #[test]
    fn first_write_stamps_version_1() {
        let tmp = tempdir().unwrap();
        PalaceAliasStore::register_alias(tmp.path(), "a", "b").unwrap();
        let raw = std::fs::read(tmp.path().join(PALACE_ALIASES_JSON)).unwrap();
        let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
        assert_eq!(json["version"], 1, "{json}");
    }

    /// Why: absent a `palaces/` subdir the data dir itself is the registry root
    /// (the monorepo layout), matching trusty-memory's fallback.
    /// Test: itself.
    #[test]
    fn palace_registry_dir_falls_back_to_data_dir() {
        let tmp = tempdir().unwrap();
        assert_eq!(
            palace_registry_dir_from(tmp.path().to_path_buf()),
            tmp.path().to_path_buf()
        );
    }
}