Skip to main content

plugmem_host/workspace/
registry.rs

1//! The registry: which databases exist, and what each one is for.
2//!
3//! A person with three databases remembers their names. A bot with three
4//! hundred does not, and neither does the model driving it — so there has to be
5//! a way to ask "which database is the one about releases?" and get a name back.
6//! That is a search problem, and this crate already contains a search engine, so
7//! the registry is **an ordinary plugmem database**: one fact per database, the
8//! description as its text. Tags, the entity graph and bitemporality come along
9//! for free, which is why nothing here invents a file format.
10//!
11//! # The registry is an index, not the truth
12//!
13//! Every database also **describes itself**, in a fact of its own anchored on a
14//! reserved entity. The registry is then derivable: [`Workspace::reindex`] walks
15//! the directory, reads each database's own description, and rebuilds it. That
16//! ordering is the whole design:
17//!
18//! - delete the registry and search stops working. Nothing else does, and
19//!   `reindex` brings it back;
20//! - copy, move or delete a database file and its description travels with it,
21//!   because it is inside it;
22//! - the registry can be *wrong* — it is a cache — and [`Workspace::verify`]
23//!   says how, without quietly fixing anything.
24//!
25//! A registry that were the source of truth would instead have four ways to
26//! disagree with the disk, and every one of them would lose data rather than
27//! lose search.
28//!
29//! Reading a whole directory of databases is only affordable because a small
30//! database is small: after the derived shard layout landed, a chat-sized
31//! memory is well under a megabyte and opens in milliseconds.
32//!
33//! # What goes where
34//!
35//! Metadata is stored opaquely by the engine and **is not searchable**, so it
36//! holds only what has to be read back, never what has to be found:
37//!
38//! | field | where | why |
39//! |---|---|---|
40//! | description | the fact's text | this is what search matches |
41//! | tags | tags | this is what filters |
42//! | owner | an `owned-by` edge, and metadata | the edge answers "all of Ann's chats"; the metadata reads back exactly |
43//! | name | metadata, and the record's subject entity | not searched, only returned — and the entity makes lookup by name a graph anchor |
44
45use std::collections::BTreeMap;
46
47use crate::{
48    Database, ExportedFact, FactId, HostError, IfMissing, RecallQuery, RememberInput, Workspace,
49};
50
51use super::{DbName, WorkspaceError};
52
53/// The entity a database's self-description hangs off.
54///
55/// Entity names are normalized (tokenized, joined by spaces), so this is
56/// written the way it is stored — no surprises about what the tokenizer does to
57/// punctuation. It is a *reservation*: a caller that gives one of its own facts
58/// this exact subject will be mistaken for a self-description, and
59/// [`Workspace::verify`] reports the ambiguity rather than guessing.
60pub const SELF_ENTITY: &str = "plugmem workspace self";
61
62/// Tag every registry record carries, so the registry's own facts are
63/// distinguishable from anything else written into that file.
64pub const ENTRY_TAG: &str = "plugmem-db";
65
66/// Tag marking a database as archived: still present, still openable, no longer
67/// somewhere new work should go.
68pub const ARCHIVED_TAG: &str = "archived";
69
70/// Metadata key naming the database a record is about.
71const NAME_KEY: &str = "name";
72
73/// Metadata key naming its owner.
74const OWNER_KEY: &str = "owner";
75
76/// Relation from a database to whoever owns it.
77const OWNED_BY_REL: &str = "owned-by";
78
79/// How many facts an anchored lookup asks for. More than one, so a duplicate on
80/// the reserved anchor is *seen* rather than silently resolved to whichever
81/// scored higher.
82const ANCHOR_K: usize = 4;
83
84/// What a caller says about a database.
85#[derive(Clone, Copy, Debug, Default)]
86pub struct Description<'a> {
87    /// Free text: what this database is for, in the words someone would search
88    /// with. Written by a person or by the model that just created it.
89    pub text: &'a str,
90    /// Tags to filter by (`kind:chat`, `archived`, whatever a caller means).
91    pub tags: &'a [&'a str],
92    /// Who it belongs to, if anyone.
93    pub owner: Option<&'a str>,
94}
95
96/// One database as the registry knows it.
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct DbEntry {
99    /// The database's name — its identity, and what a caller passes back.
100    pub name: DbName,
101    /// What it is for.
102    pub description: String,
103    /// Its tags.
104    pub tags: Vec<String>,
105    /// Its owner, if recorded.
106    pub owner: Option<String>,
107}
108
109impl DbEntry {
110    /// Whether this database is archived.
111    pub fn is_archived(&self) -> bool {
112        self.tags.iter().any(|t| t == ARCHIVED_TAG)
113    }
114}
115
116/// What a [`Workspace::reindex`] pass did.
117#[derive(Clone, Debug, Default, PartialEq, Eq)]
118pub struct ReindexReport {
119    /// Databases whose own description was copied into the registry.
120    pub indexed: Vec<DbName>,
121    /// Databases that have never been described. Not a fault — a database is
122    /// perfectly usable without a description; it just cannot be found by one.
123    pub undescribed: Vec<DbName>,
124    /// Databases held open elsewhere, so this pass could not read them. Named
125    /// rather than skipped silently: the registry is now knowingly incomplete.
126    pub busy: Vec<DbName>,
127}
128
129/// Something [`Workspace::verify`] found. Reported, never repaired — a workspace
130/// is a directory a person can edit, and guessing at their intent is how a
131/// consistency check becomes a data-loss bug.
132#[derive(Clone, Debug, PartialEq, Eq)]
133#[non_exhaustive]
134pub enum WorkspaceIssue {
135    /// The registry describes a database that is not on disk.
136    Missing {
137        /// The database the registry believes in.
138        name: DbName,
139    },
140    /// A database on disk that the registry does not describe. It works; it
141    /// just cannot be found by description until it is described.
142    Undescribed {
143        /// The database with no registry record.
144        name: DbName,
145    },
146    /// The registry's description disagrees with the database's own.
147    Stale {
148        /// The database whose record is out of date.
149        name: DbName,
150    },
151    /// A database could not be read, so nothing about it could be checked.
152    Unreadable {
153        /// The database that would not open.
154        name: DbName,
155        /// Why, in the words of the error.
156        why: String,
157    },
158    /// More than one fact claims the reserved self-description anchor.
159    AmbiguousSelf {
160        /// The database holding them.
161        name: DbName,
162        /// How many facts were anchored there.
163        facts: usize,
164    },
165}
166
167impl Workspace {
168    /// Records what `name` is for, in the database itself and in the registry.
169    ///
170    /// Creates the database if it does not exist — describing a database into
171    /// being is a reasonable thing to want, and the alternative is a two-step
172    /// dance where the first step is forgettable.
173    ///
174    /// Called again for the same database, this **revises** rather than
175    /// duplicating: facts are immutable here, so a change is a new revision and
176    /// the history of what this database used to be for is kept for free. The
177    /// revision has a new fact id, which is exactly why a database's identity is
178    /// its name and never a fact id.
179    ///
180    /// # Errors
181    ///
182    /// Whatever opening or writing either database reports.
183    pub fn describe(
184        &self,
185        name: &DbName,
186        now_ms: u64,
187        desc: Description<'_>,
188    ) -> Result<(), WorkspaceError> {
189        let db = self.get(name, now_ms, IfMissing::Create)?;
190        write_self(&db, now_ms, desc).map_err(|e| self.blame(name, e))?;
191        drop(db);
192
193        let registry = self.registry()?;
194        write_entry(&registry, now_ms, name, desc)?;
195        Ok(())
196    }
197
198    /// Marks `name` archived, keeping its description. Returns whether anything
199    /// changed (`false` when it was already archived).
200    ///
201    /// Archiving does not close, move or delete the database — it is a label,
202    /// and the caller decides what it means. Deleting is deleting a file, and
203    /// this crate does not do that on a caller's behalf.
204    ///
205    /// # Errors
206    ///
207    /// [`WorkspaceError::NoSuchDatabase`] when there is no record to archive,
208    /// plus whatever writing the registry reports.
209    pub fn archive(&self, name: &DbName, now_ms: u64) -> Result<bool, WorkspaceError> {
210        let Some(entry) = self.entry(name)? else {
211            return Err(WorkspaceError::NoSuchDatabase {
212                name: name.clone(),
213                path: self.layout().path_of(name),
214            });
215        };
216        if entry.is_archived() {
217            return Ok(false);
218        }
219        let mut tags: Vec<&str> = entry.tags.iter().map(String::as_str).collect();
220        tags.push(ARCHIVED_TAG);
221        self.describe(
222            name,
223            now_ms,
224            Description {
225                text: &entry.description,
226                tags: &tags,
227                owner: entry.owner.as_deref(),
228            },
229        )?;
230        Ok(true)
231    }
232
233    /// The registry's record for `name`, or `None` if it has none.
234    ///
235    /// # Errors
236    ///
237    /// Whatever opening or reading the registry reports.
238    pub fn entry(&self, name: &DbName) -> Result<Option<DbEntry>, WorkspaceError> {
239        Ok(self.entries()?.into_iter().find(|e| &e.name == name))
240    }
241
242    /// Every record in the registry, sorted by name.
243    ///
244    /// A full dump rather than a query: the registry holds one fact per
245    /// database, so this is cheap at the scale where listing is what a caller
246    /// wants. Past that scale they want [`Workspace::find`].
247    ///
248    /// # Errors
249    ///
250    /// Whatever opening the registry reports.
251    pub fn entries(&self) -> Result<Vec<DbEntry>, WorkspaceError> {
252        let registry = self.registry()?;
253        let mut out: Vec<DbEntry> = registry.export().iter().filter_map(entry_of).collect();
254        out.sort_by(|a, b| a.name.cmp(&b.name));
255        Ok(out)
256    }
257
258    /// The databases whose descriptions best match `query`, best first.
259    ///
260    /// This is the answer to "I do not know the name": ask in words, get names
261    /// back, then work with the name. Results are ranked by the same fused
262    /// recall every other search uses — one database's worth of scoring, so the
263    /// ranking means something (scores from *different* databases would not be
264    /// comparable, which is why nothing here ever merges across them).
265    ///
266    /// The query doubles as a graph anchor, so a person's name finds what they
267    /// own even though an owner is an edge and edges are not text. "Ann" reaches
268    /// the Ann entity, the walk crosses `owned-by` in either direction, and the
269    /// records on the other side come back. Nothing special-cases owners: it is
270    /// the lexical and graph sources doing what they already do, fused.
271    ///
272    /// # Errors
273    ///
274    /// Whatever opening or querying the registry reports.
275    pub fn find(&self, query: &str, k: usize, now_ms: u64) -> Result<Vec<DbEntry>, WorkspaceError> {
276        let registry = self.registry()?;
277        let hits = registry.recall(RecallQuery {
278            entities: &[query],
279            k,
280            ..RecallQuery::text(now_ms, query)
281        })?;
282        let mut by_name: BTreeMap<String, DbEntry> = self
283            .entries()?
284            .into_iter()
285            .map(|e| (e.name.to_string(), e))
286            .collect();
287
288        // Ranked order comes from the recall; the entries themselves come from
289        // the dump, which is the only place a fact's tags are readable in one
290        // pass. `remove` also dedupes, so a database cannot appear twice.
291        let mut out = Vec::with_capacity(hits.facts.len());
292        for hit in &hits.facts {
293            if let Some(snap) = registry.get(hit.id)
294                && let Some(name) = snap.metadata.get(NAME_KEY)
295                && let Some(entry) = by_name.remove(name)
296            {
297                out.push(entry);
298            }
299        }
300        Ok(out)
301    }
302
303    /// Rebuilds the registry from the databases themselves.
304    ///
305    /// The repair path, and the reason the registry is allowed to be a cache.
306    /// It reads each database's own description and writes it back into the
307    /// registry, so a registry that was deleted, corrupted or edited by hand
308    /// comes back from the data.
309    ///
310    /// A database held open by another process **cannot** be read here — one
311    /// file has one writer — so it is named in the report rather than skipped
312    /// silently. That is a real limit of rebuilding a live workspace, and the
313    /// normal path (`describe` keeping the registry current) does not have it.
314    ///
315    /// # Errors
316    ///
317    /// Whatever listing the directory or writing the registry reports. A single
318    /// unreadable database is reported, not raised.
319    pub fn reindex(&self, now_ms: u64) -> Result<ReindexReport, WorkspaceError> {
320        let mut report = ReindexReport::default();
321        for name in self.layout().list()? {
322            let db = match self.get(&name, now_ms, IfMissing::Fail) {
323                Ok(db) => db,
324                Err(WorkspaceError::Busy { .. }) => {
325                    report.busy.push(name);
326                    continue;
327                }
328                Err(e) => return Err(e),
329            };
330            let found = self_description(&db, now_ms).map_err(|e| self.blame(&name, e))?;
331            drop(db);
332
333            match found {
334                Some(desc) => {
335                    let registry = self.registry()?;
336                    let tags: Vec<&str> = desc.tags.iter().map(String::as_str).collect();
337                    write_entry(
338                        &registry,
339                        now_ms,
340                        &name,
341                        Description {
342                            text: &desc.text,
343                            tags: &tags,
344                            owner: desc.owner.as_deref(),
345                        },
346                    )?;
347                    report.indexed.push(name);
348                }
349                None => report.undescribed.push(name),
350            }
351        }
352        Ok(report)
353    }
354
355    /// Checks the registry against the directory, reporting every disagreement.
356    ///
357    /// Fixes nothing: see [`WorkspaceIssue`].
358    ///
359    /// # Errors
360    ///
361    /// Whatever listing the directory or opening the registry reports.
362    pub fn verify(&self, now_ms: u64) -> Result<Vec<WorkspaceIssue>, WorkspaceError> {
363        let on_disk = self.layout().list()?;
364        let recorded = self.entries()?;
365        let mut issues = Vec::new();
366
367        for entry in &recorded {
368            if !self.layout().exists(&entry.name) {
369                issues.push(WorkspaceIssue::Missing {
370                    name: entry.name.clone(),
371                });
372            }
373        }
374
375        for name in on_disk {
376            let db = match self.get(&name, now_ms, IfMissing::Fail) {
377                Ok(db) => db,
378                Err(e) => {
379                    issues.push(WorkspaceIssue::Unreadable {
380                        name,
381                        why: e.to_string(),
382                    });
383                    continue;
384                }
385            };
386            let anchored = anchored_facts(&db, now_ms).map_err(|e| self.blame(&name, e))?;
387            let own = anchored.first().map(|(id, snap)| SelfDescription {
388                text: snap.text.clone(),
389                tags: db.tags_of(*id),
390                owner: snap.metadata.get(OWNER_KEY).cloned(),
391            });
392            let anchored = anchored.len();
393            drop(db);
394
395            if anchored > 1 {
396                issues.push(WorkspaceIssue::AmbiguousSelf {
397                    name: name.clone(),
398                    facts: anchored,
399                });
400            }
401            match (own, recorded.iter().find(|e| e.name == name)) {
402                // Nobody has said what this database is for. Perfectly usable;
403                // just not findable by description.
404                (None, None) => issues.push(WorkspaceIssue::Undescribed { name }),
405                // Anything else where the two disagree is what `reindex` exists
406                // to settle — including a record for a database that no longer
407                // describes itself.
408                (Some(own), Some(record)) if own.agrees_with(record) => {}
409                _ => issues.push(WorkspaceIssue::Stale { name }),
410            }
411        }
412        Ok(issues)
413    }
414
415    /// Attributes a host failure to a named database, so a lock conflict says
416    /// which one.
417    fn blame(&self, name: &DbName, e: HostError) -> WorkspaceError {
418        match e {
419            HostError::Locked { .. } => WorkspaceError::Busy { name: name.clone() },
420            other => WorkspaceError::Host(other),
421        }
422    }
423}
424
425/// Writes (or revises) the self-description inside a database.
426fn write_self(db: &Database, now_ms: u64, desc: Description<'_>) -> Result<(), HostError> {
427    let owner = desc.owner.map(|o| [(OWNER_KEY, o)]);
428    let input = RememberInput {
429        entity: Some(SELF_ENTITY),
430        tags: desc.tags,
431        metadata: owner.as_ref().map(|m| m.as_slice()),
432        ..RememberInput::text(now_ms, desc.text)
433    };
434    match anchored_facts(db, now_ms)?.first() {
435        Some((id, _)) => db.revise(*id, input)?,
436        None => db.remember(input)?,
437    };
438    Ok(())
439}
440
441/// Writes (or revises) a database's record in the registry.
442fn write_entry(
443    registry: &Database,
444    now_ms: u64,
445    name: &DbName,
446    desc: Description<'_>,
447) -> Result<(), WorkspaceError> {
448    let mut tags: Vec<&str> = Vec::with_capacity(desc.tags.len() + 1);
449    tags.push(ENTRY_TAG);
450    tags.extend(desc.tags.iter().copied().filter(|t| *t != ENTRY_TAG));
451
452    let mut metadata: Vec<(&str, &str)> = vec![(NAME_KEY, name.as_str())];
453    if let Some(owner) = desc.owner {
454        metadata.push((OWNER_KEY, owner));
455    }
456    let links: Vec<(&str, &str)> = desc
457        .owner
458        .map(|owner| vec![(OWNED_BY_REL, owner)])
459        .unwrap_or_default();
460
461    let input = RememberInput {
462        // The record's subject is the database itself, so looking one up by
463        // name is a graph anchor rather than a scan, and an owner edge makes
464        // "everything Ann owns" reachable from either end (expansion walks
465        // edges in both directions).
466        entity: Some(name.as_str()),
467        tags: &tags,
468        links: &links,
469        metadata: Some(&metadata),
470        ..RememberInput::text(now_ms, desc.text)
471    };
472
473    match existing_record(registry, now_ms, name)? {
474        Some(id) => registry.revise(id, input)?,
475        None => registry.remember(input)?,
476    };
477    Ok(())
478}
479
480/// The id of `name`'s registry record, if it has one.
481fn existing_record(
482    registry: &Database,
483    now_ms: u64,
484    name: &DbName,
485) -> Result<Option<FactId>, HostError> {
486    let hits = registry.recall(RecallQuery {
487        entities: &[name.as_str()],
488        k: ANCHOR_K,
489        ..blank(now_ms)
490    })?;
491    // The anchor can also surface a *neighbour's* facts, so the record is
492    // confirmed by the name in its metadata rather than by having been returned.
493    for hit in &hits.facts {
494        if let Some(snap) = registry.get(hit.id)
495            && snap.metadata.get(NAME_KEY).map(String::as_str) == Some(name.as_str())
496        {
497            return Ok(Some(hit.id));
498        }
499    }
500    Ok(None)
501}
502
503/// Facts anchored on the reserved self-description entity, newest first.
504fn anchored_facts(
505    db: &Database,
506    now_ms: u64,
507) -> Result<Vec<(FactId, crate::FactSnapshot)>, HostError> {
508    let hits = db.recall(RecallQuery {
509        entities: &[SELF_ENTITY],
510        k: ANCHOR_K,
511        ..blank(now_ms)
512    })?;
513    Ok(hits
514        .facts
515        .iter()
516        .filter_map(|hit| db.get(hit.id).map(|snap| (hit.id, snap)))
517        .collect())
518}
519
520/// What a database says about itself. Deliberately not a [`DbEntry`]: the name
521/// is not in the file, it is the file's place in the directory, so a type that
522/// carried one here would have to invent it.
523struct SelfDescription {
524    text: String,
525    tags: Vec<String>,
526    owner: Option<String>,
527}
528
529impl SelfDescription {
530    /// Whether the registry's record says the same thing this database does —
531    /// that is, whether a `reindex` would leave the record alone.
532    fn agrees_with(&self, record: &DbEntry) -> bool {
533        self.text == record.description && self.owner == record.owner && self.tags == record.tags
534    }
535}
536
537/// A database's own description, if it has one.
538fn self_description(db: &Database, now_ms: u64) -> Result<Option<SelfDescription>, HostError> {
539    let Some((id, snap)) = anchored_facts(db, now_ms)?.into_iter().next() else {
540        return Ok(None);
541    };
542    Ok(Some(SelfDescription {
543        text: snap.text,
544        tags: db.tags_of(id),
545        owner: snap.metadata.get(OWNER_KEY).cloned(),
546    }))
547}
548
549/// A query with no text and no vector — just the anchors set by the caller.
550/// Anchoring alone is enough: the graph source seeds from the named entities and
551/// returns their facts, so a lookup by entity costs no tokenizing and no search.
552fn blank(now_ms: u64) -> RecallQuery<'static> {
553    RecallQuery {
554        text: None,
555        ..RecallQuery::text(now_ms, "")
556    }
557}
558
559/// A registry record read back out of an export, or `None` for a fact that is
560/// not one.
561fn entry_of(fact: &ExportedFact) -> Option<DbEntry> {
562    if !fact.tags.iter().any(|t| t == ENTRY_TAG) {
563        return None;
564    }
565    entry_from(&fact.text, &fact.tags, &fact.metadata)
566}
567
568/// Builds an entry from the three places its parts live.
569fn entry_from(text: &str, tags: &[String], metadata: &BTreeMap<String, String>) -> Option<DbEntry> {
570    let name = DbName::parse(metadata.get(NAME_KEY)?).ok()?;
571    Some(DbEntry {
572        name,
573        description: text.to_string(),
574        tags: tags.iter().filter(|t| *t != ENTRY_TAG).cloned().collect(),
575        owner: metadata.get(OWNER_KEY).cloned(),
576    })
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::workspace::testkit::{TempDir, name, workspace};
583    use crate::{RememberInput, WorkspaceLimits};
584
585    /// A description with only text — the common case.
586    fn about(text: &str) -> Description<'_> {
587        Description {
588            text,
589            ..Description::default()
590        }
591    }
592
593    fn names(entries: &[DbEntry]) -> Vec<&str> {
594        entries.iter().map(|e| e.name.as_str()).collect()
595    }
596
597    #[test]
598    fn the_registry_is_not_opened_until_something_needs_it() {
599        let tmp = TempDir::new("registry-lazy");
600        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
601
602        // Resolving a name a caller already knows never touches the registry:
603        // no file, and no lock another process would trip over.
604        ws.get(&name("chat-42"), 1_000, IfMissing::Create).unwrap();
605        assert!(!ws.layout().registry_path().exists());
606
607        ws.entries().unwrap();
608        assert!(crate::storage::database_exists(
609            &ws.layout().registry_path()
610        ));
611        assert!(ws.close_registry());
612        assert!(!ws.close_registry());
613    }
614
615    #[test]
616    fn describing_twice_revises_one_record_rather_than_adding_a_second() {
617        let tmp = TempDir::new("registry-revise");
618        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
619        let chat = name("chat-42");
620
621        ws.describe(&chat, 1_000, about("work chat about plugmem"))
622            .unwrap();
623        ws.describe(&chat, 2_000, about("work chat about releases"))
624            .unwrap();
625
626        let entries = ws.entries().unwrap();
627        assert_eq!(names(&entries), ["chat-42"]);
628        assert_eq!(entries[0].description, "work chat about releases");
629
630        // The database's own copy moved with it — that is what makes the
631        // registry rebuildable.
632        let db = ws.get(&chat, 3_000, IfMissing::Fail).unwrap();
633        let own = self_description(&db, 3_000).unwrap().unwrap();
634        assert_eq!(own.text, "work chat about releases");
635    }
636
637    #[test]
638    fn describing_a_name_that_has_no_database_creates_it() {
639        let tmp = TempDir::new("registry-create");
640        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
641        let chat = name("chat-42");
642        assert!(!ws.layout().exists(&chat));
643        ws.describe(&chat, 1_000, about("a brand new chat"))
644            .unwrap();
645        assert!(ws.layout().exists(&chat));
646    }
647
648    #[test]
649    fn a_database_is_found_by_what_it_is_for_and_by_who_owns_it() {
650        let tmp = TempDir::new("registry-find");
651        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
652
653        ws.describe(
654            &name("chat-42"),
655            1_000,
656            Description {
657                text: "release planning and performance work on the engine",
658                tags: &["kind:chat"],
659                owner: Some("ann"),
660            },
661        )
662        .unwrap();
663        ws.describe(
664            &name("recipes"),
665            1_000,
666            Description {
667                text: "dinner ideas and shopping lists",
668                tags: &["kind:notes"],
669                owner: Some("bob"),
670            },
671        )
672        .unwrap();
673
674        // By description.
675        let hits = ws.find("release planning", 4, 2_000).unwrap();
676        assert_eq!(hits.first().map(|e| e.name.as_str()), Some("chat-42"));
677        let hits = ws.find("shopping lists", 4, 2_000).unwrap();
678        assert_eq!(hits.first().map(|e| e.name.as_str()), Some("recipes"));
679
680        // By owner — which lives in an edge, not in the text. The graph source
681        // reaches it from the person's name.
682        let ann = ws.find("ann", 4, 2_000).unwrap();
683        assert_eq!(names(&ann), ["chat-42"]);
684
685        // Tags and owner survive the round trip.
686        let entry = ws.entry(&name("chat-42")).unwrap().unwrap();
687        assert_eq!(entry.tags, ["kind:chat"]);
688        assert_eq!(entry.owner.as_deref(), Some("ann"));
689        assert!(!entry.is_archived());
690        assert!(ws.entry(&name("nope")).unwrap().is_none());
691    }
692
693    #[test]
694    fn archiving_is_a_label_and_is_idempotent() {
695        let tmp = TempDir::new("registry-archive");
696        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
697        let chat = name("chat-42");
698
699        // Nothing to archive is an error naming the database, not a silent no-op.
700        assert!(matches!(
701            ws.archive(&chat, 1_000),
702            Err(WorkspaceError::NoSuchDatabase { .. })
703        ));
704
705        ws.describe(
706            &chat,
707            1_000,
708            Description {
709                text: "an old project",
710                tags: &["kind:chat"],
711                owner: Some("ann"),
712            },
713        )
714        .unwrap();
715
716        assert!(ws.archive(&chat, 2_000).unwrap());
717        let entry = ws.entry(&chat).unwrap().unwrap();
718        assert!(entry.is_archived());
719        // The rest of the record survives being labelled.
720        assert_eq!(entry.description, "an old project");
721        assert_eq!(entry.owner.as_deref(), Some("ann"));
722        assert!(entry.tags.contains(&"kind:chat".to_string()));
723
724        // Already archived: nothing to do, and it says so.
725        assert!(!ws.archive(&chat, 3_000).unwrap());
726        // Archiving does not close, move or delete anything.
727        assert!(ws.layout().exists(&chat));
728    }
729
730    #[test]
731    fn a_deleted_registry_is_rebuilt_from_the_databases_themselves() {
732        let tmp = TempDir::new("registry-reindex");
733        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
734
735        for (db, text) in [("chat-42", "release planning"), ("recipes", "dinner ideas")] {
736            ws.describe(
737                &name(db),
738                1_000,
739                Description {
740                    text,
741                    tags: &["kind:chat"],
742                    owner: Some("ann"),
743                },
744            )
745            .unwrap();
746        }
747        // A database nobody described: usable, just not findable.
748        ws.get(&name("scratch"), 1_000, IfMissing::Create).unwrap();
749
750        // Lose the registry entirely, the way a botched backup would.
751        ws.close_registry();
752        for entry in std::fs::read_dir(ws.layout().root()).unwrap() {
753            let path = entry.unwrap().path();
754            if path.is_file() {
755                std::fs::remove_file(path).unwrap();
756            }
757        }
758        assert!(ws.entries().unwrap().is_empty());
759
760        let report = ws.reindex(2_000).unwrap();
761        assert_eq!(
762            report
763                .indexed
764                .iter()
765                .map(DbName::to_string)
766                .collect::<Vec<_>>(),
767            ["chat-42", "recipes"]
768        );
769        assert_eq!(
770            report
771                .undescribed
772                .iter()
773                .map(DbName::to_string)
774                .collect::<Vec<_>>(),
775            ["scratch"]
776        );
777        assert!(report.busy.is_empty());
778
779        // Everything is back, including what was never in the registry's text.
780        let entry = ws.entry(&name("chat-42")).unwrap().unwrap();
781        assert_eq!(entry.description, "release planning");
782        assert_eq!(entry.tags, ["kind:chat"]);
783        assert_eq!(entry.owner.as_deref(), Some("ann"));
784    }
785
786    #[test]
787    fn reindex_names_the_databases_it_could_not_read() {
788        let tmp = TempDir::new("registry-reindex-busy");
789        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
790        let held = name("chat-42");
791        ws.describe(&held, 1_000, about("release planning"))
792            .unwrap();
793        ws.describe(&name("recipes"), 1_000, about("dinner ideas"))
794            .unwrap();
795
796        // Someone else holds the writer, so this pass genuinely cannot read it.
797        // It has to say so: the rebuilt registry is knowingly incomplete.
798        ws.close_all();
799        let outsider = Database::open(ws.layout().path_of(&held), crate::Config::default())
800            .unwrap()
801            .0;
802
803        let report = ws.reindex(2_000).unwrap();
804        assert_eq!(report.busy, std::slice::from_ref(&held));
805        assert_eq!(report.indexed, [name("recipes")]);
806
807        drop(outsider);
808        assert_eq!(ws.reindex(3_000).unwrap().busy, []);
809    }
810
811    #[test]
812    fn verify_reports_every_way_the_registry_can_disagree() {
813        let tmp = TempDir::new("registry-verify");
814        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
815
816        // Agreeing: no issue.
817        let agreed = name("chat-42");
818        ws.describe(&agreed, 1_000, about("release planning"))
819            .unwrap();
820        assert_eq!(ws.verify(2_000).unwrap(), []);
821
822        // On disk, never described.
823        let plain = name("scratch");
824        ws.get(&plain, 1_000, IfMissing::Create).unwrap();
825
826        // Described in the database, absent from the registry — exactly what a
827        // rebuild would fix.
828        let unlisted = name("orphan");
829        let db = ws.get(&unlisted, 1_000, IfMissing::Create).unwrap();
830        write_self(&db, 1_000, about("known only to itself")).unwrap();
831        drop(db);
832
833        // Registry knows a database the disk does not have.
834        let registry = ws.registry().unwrap();
835        write_entry(&registry, 1_000, &name("ghost"), about("gone")).unwrap();
836        drop(registry);
837
838        // Two facts on the reserved anchor: ambiguous, and reported rather than
839        // resolved to whichever ranked higher.
840        let two = name("twins");
841        let db = ws.get(&two, 1_000, IfMissing::Create).unwrap();
842        for text in ["first claim", "second claim"] {
843            db.remember(RememberInput {
844                entity: Some(SELF_ENTITY),
845                ..RememberInput::text(1_000, text)
846            })
847            .unwrap();
848        }
849        drop(db);
850
851        let issues = ws.verify(2_000).unwrap();
852        assert!(issues.contains(&WorkspaceIssue::Missing {
853            name: name("ghost")
854        }));
855        assert!(issues.contains(&WorkspaceIssue::Undescribed { name: plain }));
856        assert!(issues.contains(&WorkspaceIssue::Stale { name: unlisted }));
857        assert!(issues.iter().any(|i| matches!(
858            i,
859            WorkspaceIssue::AmbiguousSelf { name, facts: 2 } if name == &two
860        )));
861        // The one that agrees is not mentioned.
862        assert!(!issues.iter().any(|i| matches!(
863            i,
864            WorkspaceIssue::Stale { name } | WorkspaceIssue::Undescribed { name } if name == &agreed
865        )));
866    }
867
868    #[test]
869    fn verify_reports_a_database_it_could_not_open() {
870        let tmp = TempDir::new("registry-verify-busy");
871        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
872        let held = name("chat-42");
873        ws.describe(&held, 1_000, about("release planning"))
874            .unwrap();
875        ws.close_all();
876        let _outsider = Database::open(ws.layout().path_of(&held), crate::Config::default())
877            .unwrap()
878            .0;
879
880        let issues = ws.verify(2_000).unwrap();
881        assert!(issues.iter().any(|i| matches!(
882            i,
883            WorkspaceIssue::Unreadable { name, why } if name == &held && why.contains("chat-42")
884        )));
885    }
886
887    #[test]
888    fn a_stray_fact_in_the_registry_file_is_not_read_as_a_record() {
889        // The registry is an ordinary database, so anything can be written into
890        // it — by a person with the CLI, or by a restore that went sideways.
891        // Only facts carrying the marker tag count as records.
892        let tmp = TempDir::new("registry-stray");
893        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
894        ws.describe(&name("chat-42"), 1_000, about("planning"))
895            .unwrap();
896
897        let registry = ws.registry().unwrap();
898        registry
899            .remember(RememberInput::text(1_000, "somebody's shopping list"))
900            .unwrap();
901        drop(registry);
902
903        assert_eq!(names(&ws.entries().unwrap()), ["chat-42"]);
904    }
905
906    #[test]
907    fn a_failure_that_is_not_a_lock_stops_a_reindex_instead_of_being_counted() {
908        // `Busy` is expected and reported; anything else means the workspace is
909        // not in a state worth continuing through, so it is raised.
910        let tmp = TempDir::new("registry-reindex-err");
911        let (seed, _) = workspace(&tmp, WorkspaceLimits::default());
912        seed.get(&name("chat-42"), 1_000, IfMissing::Create)
913            .unwrap();
914        drop(seed);
915
916        let broken: crate::Opener = Box::new(|_| Err(HostError::Embed("no provider".into())));
917        let ws = Workspace::new(
918            crate::WorkspaceLayout::new(&tmp.0),
919            broken,
920            WorkspaceLimits::default(),
921        );
922        assert!(matches!(
923            ws.reindex(2_000),
924            Err(WorkspaceError::Host(HostError::Embed(_)))
925        ));
926    }
927
928    #[test]
929    fn a_host_failure_is_attributed_to_the_database_it_came_from() {
930        let tmp = TempDir::new("registry-blame");
931        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
932        let chat = name("chat-42");
933
934        // A lock conflict names the database rather than a path the caller
935        // never typed; everything else keeps its own message.
936        let locked = HostError::Locked {
937            path: ws.layout().path_of(&chat),
938        };
939        assert!(matches!(
940            ws.blame(&chat, locked),
941            WorkspaceError::Busy { name } if name == chat
942        ));
943        assert!(matches!(
944            ws.blame(&chat, HostError::Embed("no".into())),
945            WorkspaceError::Host(HostError::Embed(_))
946        ));
947    }
948
949    #[test]
950    fn a_stale_record_is_one_a_reindex_would_change() {
951        let tmp = TempDir::new("registry-stale");
952        let (ws, _) = workspace(&tmp, WorkspaceLimits::default());
953        let chat = name("chat-42");
954        ws.describe(&chat, 1_000, about("release planning"))
955            .unwrap();
956
957        // Edit only the registry, as a hand-edit or a partial restore would.
958        let registry = ws.registry().unwrap();
959        write_entry(&registry, 2_000, &chat, about("something else")).unwrap();
960        drop(registry);
961        assert_eq!(
962            ws.verify(3_000).unwrap(),
963            [WorkspaceIssue::Stale { name: chat.clone() }]
964        );
965
966        // And a rebuild settles it, from the database's own copy.
967        ws.reindex(4_000).unwrap();
968        assert_eq!(ws.verify(5_000).unwrap(), []);
969        assert_eq!(
970            ws.entry(&chat).unwrap().unwrap().description,
971            "release planning"
972        );
973    }
974}