basis 0.11.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
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
//! Where basis's conversations are persisted, and how they are scoped.
//!
//! mentra persists every agent to its runtime store — since basis 0.7 the
//! **file-backed** one, plain files under one root, no database (ADR-0023;
//! upstream `mentra#28`) — and tags each record with a **runtime
//! identifier**. basis uses that tag to answer one question: *which
//! conversations belong to this workspace?* — which is what ACP's
//! `session/list` asks and the only reading of "my sessions" that is both
//! honest and useful, since ACP scopes a session to a `cwd` from the moment
//! `session/new` opens it.
//!
//! # Why the workspace path, verbatim
//!
//! mentra's default identifier is the literal string `"default"`, and its
//! default store is one shared root. Listing under `"default"` would
//! therefore enumerate the agents of *every* mentra program on the machine —
//! worse than returning nothing, because a client would offer a user
//! conversations that are not theirs.
//!
//! The identifier is the canonicalized workspace path with a `basis:` prefix. No
//! hash: the identifier never becomes a filename — mentra keeps it as a field
//! of each agent's `agent.json` and filters listings by comparing it — so
//! every character survives, and a readable value is one a person debugging
//! the store can understand by opening the file. The prefix keeps basis's
//! records from ever colliding with another program's `"default"`.
//!
//! Changing the identifier does not move the store — mentra's default path is
//! independent of it — so nothing already written is lost. Records created
//! before this scheme carry `"default"` and do not appear in any workspace's
//! list, which is the correct answer for a conversation whose workspace was
//! never recorded. They are not stranded either: mentra loads an agent by id
//! alone, so resuming one still works, and it re-tags itself the next time it
//! persists. [`WorkspaceBuilder::open`](crate::WorkspaceBuilder::open) is where
//! the tag is set, and where that ruling is written down.
//!
//! One caveat since ADR-0018: mentra fixes the tag per *runtime* at build
//! time, so only a workspace on its own private runtime — every
//! `Workspace::open(path)`, the CLI, the free functions — tags rows with its
//! path. A workspace on a **shared** [`Runtime`](crate::Runtime) mints rows
//! tagged `"basis:runtime"` until mentra grows a per-session override; those
//! rows stay out of every per-workspace list (the `"default"` ruling above,
//! applied again) and re-file themselves the first time they persist under a
//! runtime that knows their workspace. [`Runtime`](crate::Runtime)'s `mint` is
//! the one line that changes when the override lands.
//!
//! # Where the files go
//!
//! mentra's default directory is keyed by the *process's* current directory,
//! not by the workspace basis opened, so every program started from one place
//! shares one store root whatever workspace it went on to open — including
//! every test binary in one `cargo test`.
//! [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
//! is how a caller says otherwise, and [`list_in`] is how the same caller reads
//! back what it wrote. The directory the caller names **is** the store's root
//! — mentra lays `agents/`, `rules.json` and `runs.jsonl` inside it — and the
//! root is bound to the store in exactly one place, `store_in`, because two
//! places would eventually disagree and a conversation written under one root
//! and looked for under another is simply missing.
//!
//! # When the directory holds a database instead
//!
//! basis 0.6 and earlier kept conversations in `runtime.sqlite` under the
//! same directory. This build links no SQLite and does not migrate
//! (ADR-0023's E2 precedent): opening, listing or deleting against a
//! directory that holds one is refused with [`RunError::LegacyStore`], which
//! names the two ways forward, rather than starting an empty file store
//! beside data it cannot see — the refusal is `refuse_legacy_store`, and
//! every path that opens a store the caller pointed somewhere runs it.
//!
//! # When there is no file
//!
//! [`RuntimeBuilder::with_ephemeral_history`](crate::RuntimeBuilder::with_ephemeral_history)
//! answers *where* with *nowhere*, and opens mentra's in-memory store instead.
//! Nothing in this module can see one of those conversations: there is no file
//! for [`list_in`] to read and no row for [`list`] to filter, whichever
//! directory either is pointed at. One file still gets written even then — a
//! compaction snapshot, which mentra persists without consulting the store —
//! and it goes to a per-runtime directory under the OS temp directory, which
//! is as close to *nowhere* as that file gets. Everything else below is about
//! the durable case.

use std::{
    path::{Path, PathBuf},
    sync::atomic::{AtomicU64, Ordering},
};

use mentra::{
    BuiltinProvider, Runtime,
    runtime::{FileRuntimeStore, VolatileRuntimeStore},
};

use crate::error::RunError;

/// Distinguishes basis's records from every other mentra program sharing the
/// store.
const IDENTIFIER_PREFIX: &str = "basis:";

/// What basis 0.6 and earlier kept conversations in — mentra's SQLite
/// database, which this build can no longer read (ADR-0023).
///
/// Named so `refuse_legacy_store` can look for exactly the file the old
/// layout put where the file store's root now goes, and refuse by name
/// instead of shadowing it with an empty store.
const LEGACY_SQLITE_FILENAME: &str = "runtime.sqlite";

/// What basis calls the directory of compaction snapshots inside whichever
/// directory holds the store.
///
/// mentra's own name for it: `transcripts/` beside the store's own entries is
/// the layout mentra lays down under its default root, so a workspace pointed
/// at [`default_directory`] lands on exactly the paths it would have used had
/// nobody said anything.
const TRANSCRIPTS_DIRNAME: &str = "transcripts";

/// The runtime identifier for conversations in `workspace`.
///
/// Every caller that creates or enumerates a conversation must derive it from
/// here — a session filed under one spelling of a path and looked for under
/// another is simply missing.
pub fn runtime_identifier(workspace: &Path) -> String {
    // Canonicalizing is what makes a symlinked path and its target one
    // workspace rather than two. A path that does not exist yet cannot be
    // canonicalized, and is used as written rather than rejected: naming the
    // store is not the place to validate a workspace.
    let resolved = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());

    format!("{IDENTIFIER_PREFIX}{}", resolved.display())
}

/// A conversation mentra has on disk, as basis reports it.
///
/// basis's own shape rather than a re-export of mentra's `PersistedAgentSummary`,
/// for the same reason [`Event`](crate::Event) is basis's own type: what basis
/// publishes should not move because a runtime internal did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedSession {
    /// The persisted agent id, which is also the ACP session id (ADR-0007).
    pub agent_id: String,
    /// The name the session was opened under.
    pub name: String,
    /// How many messages the conversation holds. Zero means it was opened and
    /// never used.
    pub messages: usize,
    /// When the conversation was first written, in seconds since the epoch.
    ///
    /// Optional because mentra's summary is: a store that keeps nothing
    /// across process lifetimes has no record and therefore no answer, and
    /// basis carries that rather than inventing a number to fill the gap.
    /// Nothing reached through [`list_in`] is one of those — listing opens a
    /// file store of its own, whatever the workspace was running on — so in
    /// practice both of these arrive set. [`list_in`]'s ordering still handles `None`, because a
    /// rule that only works for the values it happens to see is not a rule.
    pub created_at: Option<u64>,
    /// When it was last written, on the same clock and absent in the same case.
    ///
    /// This is *last persisted*, not last spoken: mentra rewrites an agent's
    /// row on every turn and on every `set_model`, `set_effort` or `set_name`,
    /// so it moves for a rename as readily as for an exchange. Close enough to
    /// "last activity" to sort a list by, and not close enough to bill by.
    pub updated_at: Option<u64>,
}

/// Every conversation persisted for `workspace`, most recently used first.
///
/// Reads the default directory. A workspace opened with
/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir) is read by
/// [`list_in`] instead — the two have to name the same place, and nothing here
/// can guess which one a caller chose.
///
/// Teammates are left out. mentra spawns those as an agent's own collaborators;
/// they are internal to a conversation rather than conversations a person
/// started, and offering one to be resumed would be offering something that was
/// never theirs to resume.
pub fn list(workspace: &Path) -> Result<Vec<PersistedSession>, RunError> {
    list_in(&default_directory(), workspace)
}

/// The same, for conversations kept somewhere of the caller's choosing.
///
/// `dir` is what was passed to
/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir). A directory
/// nothing was ever written to lists nothing rather than failing: an empty
/// history and a store that does not exist yet are the same answer.
pub fn list_in(dir: &Path, workspace: &Path) -> Result<Vec<PersistedSession>, RunError> {
    let identifier = runtime_identifier(workspace);

    let mut sessions: Vec<PersistedSession> = enumerating_runtime(&identifier, dir)?
        .list_persisted_agents(&identifier)?
        .into_iter()
        .filter(|agent| !agent.is_teammate)
        .map(|agent| PersistedSession {
            agent_id: agent.id,
            name: agent.name,
            messages: agent.history_len,
            created_at: agent.created_at,
            updated_at: agent.updated_at,
        })
        .collect();
    by_recency(&mut sessions);

    Ok(sessions)
}

/// Orders conversations the way a person looks for one: the last thing they
/// touched at the top.
///
/// Ordered here rather than at each surface, because there are two of them —
/// `basis list` and ACP's `session/list` — and a client that sorted for itself
/// would need a timestamp basis might not have. mentra returns creation order
/// (sorted `(created_at, id)` for the file store, insertion order for the
/// volatile one), which answers "which is oldest" and never "which was I just
/// in".
///
/// Two rules, and the second is what makes the answer usable at all:
///
/// - A conversation with no timestamp sorts **last**, not first. `None` comes
///   from a store that persists nothing, so it is *unknown*, and floating an
///   unknown to the top of a list sorted by recency claims the one thing it
///   cannot know.
/// - The sort is **stable**, so conversations that tie — the same second, or a
///   volatile store where every one of them is `None` — keep the order mentra
///   gave them, which is deterministic in both backends. A tiebreak of basis's
///   own would replace a meaningful order with an arbitrary one.
fn by_recency(sessions: &mut [PersistedSession]) {
    sessions.sort_by(|left, right| match (left.updated_at, right.updated_at) {
        (Some(left), Some(right)) => right.cmp(&left),
        (Some(_), None) => std::cmp::Ordering::Less,
        (None, Some(_)) => std::cmp::Ordering::Greater,
        (None, None) => std::cmp::Ordering::Equal,
    });
}

/// Removes a conversation from the default directory's store for good.
///
/// The writing counterpart to [`list`], and the second half of what a client
/// with a session list can do with it: pick one to resume, or decide it is
/// finished with. mentra removes the record *and* its memory, because a record
/// without its memory is a row `resume` refuses with "missing persisted
/// memory" — a listing entry that cannot be opened.
///
/// Deleting a conversation that is not there is **not** an error. A caller
/// deleting by an id it read from a list is racing anyone else holding the
/// same store, and "it is gone" is the outcome both of them asked for.
///
/// Keyed by the conversation, not by a workspace: mentra's store is indexed by
/// agent id, so this does not check that the id belongs anywhere in
/// particular — the same ruling [`Workspace::resume`](crate::Workspace::resume)
/// makes for the same reason. A caller that means "one of mine" takes the id
/// from [`list`] for its own workspace, which is where a client got it anyway.
///
/// **A live conversation is not stopped by this.** mentra deletes rows; an
/// agent still in memory keeps running and writes its row back on its next
/// persist. A caller holding a [`PreparedRun`](crate::PreparedRun) on this id
/// must drop it first, or the row returns.
pub fn forget(agent_id: &str) -> Result<(), RunError> {
    forget_in(&default_directory(), agent_id)
}

/// The same, for conversations kept somewhere of the caller's choosing.
///
/// `dir` is what was passed to
/// [`with_store_dir`](crate::RuntimeBuilder::with_store_dir), exactly as for
/// [`list_in`]: a conversation is deleted from the file it was listed out of,
/// and nothing here can guess which one a caller chose.
pub fn forget_in(dir: &Path, agent_id: &str) -> Result<(), RunError> {
    // The identifier tags rows on write and filters them on `list_agents_by_
    // runtime`; deletion is keyed by id alone, so what is passed here cannot
    // change which conversation goes. It is still derived rather than invented,
    // so nothing in this module opens a store under a tag no workspace uses.
    Ok(enumerating_runtime(&runtime_identifier(dir), dir)?.delete_agent(agent_id)?)
}

/// A runtime that exists only to reach the store.
///
/// Reading or removing a persisted agent needs a `Runtime`, and
/// `RuntimeBuilder::build` refuses to produce one with an empty provider
/// registry — so a provider is registered to satisfy the builder, with a
/// placeholder key. Nothing here resolves a model or reaches the network:
/// listing reads the store's files and deleting removes some, and the runtime
/// is dropped as soon as it has. Requiring a real credential to touch local
/// records would make `session/list` and `session/delete` fail for a reason
/// that has nothing to do with either.
///
/// The store is built from `dir` rather than left at mentra's default, so that
/// this and [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
/// read and write one file. The identifier is the other thing that has to
/// agree, and it comes from [`runtime_identifier`] on both sides.
fn enumerating_runtime(identifier: &str, dir: &Path) -> Result<Runtime, RunError> {
    Ok(Runtime::empty_builder()
        .with_runtime_identifier(identifier.to_string())
        .with_store(store_in(dir)?)
        .with_provider(BuiltinProvider::OpenAI, "unused-for-listing")
        .build()?)
}

/// The store basis keeps a workspace's conversations in, under `dir` — or the
/// refusal, when `dir` still holds a basis ≤0.6 conversation database.
///
/// The one place the directory becomes a store root: `RuntimeBuilder` writes
/// through this and [`list_in`] reads through it, so the two cannot drift.
/// `dir` itself is the root — mentra lays `agents/`, `rules.json` and
/// `runs.jsonl` inside it — which is what makes 0.7's layout land in exactly
/// the directory 0.6's database sat in.
///
/// **That is why this is fallible, and why the check lives here rather than
/// beside each caller.** A file store started in a directory holding
/// `runtime.sqlite` would look exactly like every conversation being lost, so
/// the check has to happen every time one is opened — and a check a caller
/// has to remember is one a future caller will forget. Constructing the store
/// *is* the check: there is no way to reach a `FileRuntimeStore` for a named
/// directory without passing it.
///
/// mentra's own file store detects the same file, and that is not enough on
/// its own: it errors in mentra's words — naming its `store-sqlite` cargo
/// feature, a fix for a mentra embedder rather than for the person whose
/// history is sitting there — and it raises it from `prepare_recovery`, which
/// `RuntimeHandle::prepare_recovery` treats as best-effort and discards, so
/// nothing legible reaches anyone through `build`. [`RunError::LegacyStore`]
/// says what actually happened and names both ways forward.
///
/// Neither this store type nor [`volatile`]'s reaches basis's surface. A caller
/// picks a *posture* — history in a directory, or history nowhere — and basis
/// picks the backend that is it, rather than re-exporting `RuntimeStore` and
/// the nine traits it composes (see
/// [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)).
pub(crate) fn store_in(dir: &Path) -> Result<FileRuntimeStore, RunError> {
    if dir.join(LEGACY_SQLITE_FILENAME).exists() {
        return Err(RunError::LegacyStore { dir: dir.into() });
    }

    Ok(FileRuntimeStore::new(dir))
}

/// The store that keeps a workspace's conversations nowhere.
///
/// mentra's in-memory `RuntimeStore`: no database file is opened, no directory
/// is created, no tool output is spilled to disk, and dropping the runtime that
/// holds it takes every conversation with it. The one thing it does not stop is
/// a compaction snapshot, which mentra writes without asking the store —
/// [`volatile_transcripts`] is where those go instead. Constructed fresh per
/// workspace, which is
/// what makes two ephemeral workspaces two histories rather than one — the type
/// is `Clone` and clones share state, so a shared instance would be a shared
/// store with none of the durability.
///
/// The backing for
/// [`RuntimeBuilder::with_ephemeral_history`](crate::RuntimeBuilder::with_ephemeral_history),
/// and named here rather than in the builder so that the two mentra store types
/// basis can open are chosen in one file.
pub(crate) fn volatile() -> VolatileRuntimeStore {
    VolatileRuntimeStore::new()
}

/// Where a workspace's compaction snapshots go, under `dir`.
///
/// mentra writes the whole transcript to a file before it replaces a prefix of
/// it with a summary, so *somewhere* is not optional; what is optional is
/// whether basis chooses it. It should: a snapshot is a verbatim copy of the
/// same conversation the store holds, and mentra's own default puts the two
/// in one directory. Keeping that relationship is what makes
/// [`RuntimeBuilder::with_store_dir`](crate::RuntimeBuilder::with_store_dir)
/// move both — and pointing it at [`default_directory`] a no-op, exactly as it
/// is for [`store_in`].
pub(crate) fn transcripts_in(dir: &Path) -> PathBuf {
    dir.join(TRANSCRIPTS_DIRNAME)
}

/// Where they go when nobody said where the history lives.
///
/// Keyed by the process's current directory, like the store beside it — the
/// hazard `with_store_dir` exists to answer, left in place for the caller that
/// has not asked.
pub(crate) fn default_transcripts() -> PathBuf {
    transcripts_in(&default_directory())
}

/// Where they go for a runtime whose history is kept nowhere.
///
/// *Nowhere* is not on offer for these. mentra's `persist_transcript` writes
/// the snapshot before it summarizes and does not consult the store first —
/// `allows_disk_artifacts`, which the volatile store answers `false` to, gates
/// tool-output spill and nothing else — and `max_persisted_transcripts: None`
/// disables the *cleanup* of old snapshots rather than the writing of new ones.
/// So the only lever basis holds is *where*, and the honest answer is the
/// directory the operating system already treats as disposable: never the
/// user's data directory, never the workspace.
///
/// Unique per call, because two runtimes each promised their own disposable
/// history must not read each other's transcripts out of one directory. A
/// counter and not the clock: two runtimes built in one tick would otherwise
/// share a directory, which is the bug mentra's own mock runtime had.
pub(crate) fn volatile_transcripts() -> PathBuf {
    static NEXT: AtomicU64 = AtomicU64::new(0);

    std::env::temp_dir()
        .join("basis-ephemeral-transcripts")
        .join(format!(
            "process-{}-{}",
            std::process::id(),
            NEXT.fetch_add(1, Ordering::Relaxed)
        ))
}

/// The directory mentra keeps basis's conversations in, for a caller that wants
/// to say where the history lives.
///
/// Keyed by the process's current directory, which is why it is worth naming
/// rather than assuming: a host that changes directory changes which store
/// this answers with.
pub fn default_directory() -> PathBuf {
    FileRuntimeStore::default_root()
}

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

    fn session(agent_id: &str, updated_at: Option<u64>) -> PersistedSession {
        PersistedSession {
            agent_id: agent_id.to_string(),
            name: agent_id.to_string(),
            messages: 2,
            created_at: updated_at,
            updated_at,
        }
    }

    fn ordered(sessions: Vec<PersistedSession>) -> Vec<String> {
        let mut sessions = sessions;
        by_recency(&mut sessions);
        sessions
            .into_iter()
            .map(|session| session.agent_id)
            .collect()
    }

    #[test]
    fn the_conversation_touched_last_is_listed_first() {
        assert_eq!(
            ordered(vec![
                session("older", Some(100)),
                session("newest", Some(300)),
                session("middle", Some(200)),
            ]),
            vec!["newest", "middle", "older"]
        );
    }

    #[test]
    fn a_conversation_with_no_timestamp_sorts_last_rather_than_first() {
        // `None` is a store that persists nothing, which means *unknown* — and
        // an unknown floated to the top of a list sorted by recency claims the
        // one thing it cannot know.
        assert_eq!(
            ordered(vec![
                session("unknown", None),
                session("known", Some(1)),
                session("also-unknown", None),
            ]),
            vec!["known", "unknown", "also-unknown"]
        );
    }

    #[test]
    fn conversations_that_tie_keep_the_order_the_store_gave_them() {
        // The volatile store's whole case — every timestamp `None` — plus the
        // ordinary one of two conversations written in the same second. Both
        // have to come back in mentra's own order, which is deterministic in
        // either backend; a tiebreak of basis's own would replace a meaningful
        // order with an arbitrary one.
        assert_eq!(
            ordered(vec![
                session("first", None),
                session("second", None),
                session("third", None),
            ]),
            vec!["first", "second", "third"]
        );
        assert_eq!(
            ordered(vec![
                session("first", Some(7)),
                session("second", Some(7)),
                session("third", Some(9)),
            ]),
            vec!["third", "first", "second"]
        );
    }

    #[test]
    fn an_identifier_names_its_workspace_and_is_lans_own() {
        let identifier = runtime_identifier(Path::new("/definitely/not/a/real/path"));

        assert!(
            identifier.starts_with(IDENTIFIER_PREFIX),
            "basis's rows must not collide with another program's: {identifier}"
        );
        assert!(
            identifier.contains("/definitely/not/a/real/path"),
            "a path that cannot be canonicalized is used as written: {identifier}"
        );
    }

    #[test]
    fn two_workspaces_never_share_an_identifier() {
        assert_ne!(
            runtime_identifier(Path::new("/repo/one")),
            runtime_identifier(Path::new("/repo/two"))
        );
    }

    #[test]
    fn one_workspace_reached_two_ways_is_one_identifier() {
        let workspace = tempfile::tempdir().expect("tempdir");
        let nested = workspace.path().join("inner");
        std::fs::create_dir(&nested).expect("dir");

        // The spelling a client sends is whatever it happened to have; the
        // conversation it opened is the same either way.
        assert_eq!(
            runtime_identifier(&nested),
            runtime_identifier(&workspace.path().join("inner").join(".").to_path_buf())
        );
    }

    #[test]
    fn an_unused_workspace_has_no_conversations() {
        let workspace = tempfile::tempdir().expect("tempdir");
        let store = tempfile::tempdir().expect("tempdir");

        assert_eq!(
            list_in(store.path(), workspace.path())
                .expect("listing an empty workspace is not an error"),
            Vec::new(),
            "nothing has ever been persisted here"
        );
    }

    #[test]
    fn a_chosen_directory_holds_the_layout_the_default_one_would_have() {
        // The identity that makes `with_store_dir` a relocation rather than a
        // second scheme: pointing it at the default directory is a no-op.
        // Asserted as path identity rather than by opening: a machine that ran
        // basis 0.6 has a database in exactly that directory, so opening it is
        // a legitimate refusal (`store_in`'s own guard) and would say nothing
        // about where the two schemes put their roots.
        assert_eq!(
            default_directory(),
            mentra::runtime::FileRuntimeStore::default().root(),
            "basis's root must be mentra's, or moving the store would relocate it"
        );
    }

    #[test]
    fn listing_opens_the_store_it_was_pointed_at() {
        // Listing is the one path that opens a store without a workspace
        // having been opened first, so it is the one most likely to fall back
        // to the machine-wide default without anyone noticing.
        let workspace = tempfile::tempdir().expect("tempdir");
        let store = tempfile::tempdir().expect("tempdir");

        list_in(store.path(), workspace.path()).expect("listing is not an error");

        assert!(
            store.path().join("agents").is_dir(),
            "the store basis was told to read is the store it opened"
        );
    }

    #[test]
    fn a_directory_holding_a_pre_07_database_is_refused_by_name() {
        // basis ≤0.6 kept this workspace's conversations in `runtime.sqlite`
        // under exactly this directory. Listing nothing over it would look
        // like every conversation being lost; the refusal has to say what
        // actually happened and name the ways forward, in basis's words.
        let workspace = tempfile::tempdir().expect("tempdir");
        let store = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            store.path().join(LEGACY_SQLITE_FILENAME),
            b"SQLite format 3\0",
        )
        .expect("plant the old database");

        let error = list_in(store.path(), workspace.path())
            .expect_err("an unreadable existing store must be named, not shadowed");

        let message = error.to_string();
        assert!(message.contains("0.6"), "{message}");
        assert!(message.contains("runtime.sqlite"), "{message}");
        assert!(
            message.contains("not migrated"),
            "the no-migration ruling (ADR-0023) is part of the message: {message}"
        );
        assert!(
            !store.path().join("agents").exists(),
            "a refused directory must not gain an empty store beside the database"
        );
    }

    #[test]
    fn forgetting_refuses_the_same_directory_listing_does() {
        let store = tempfile::tempdir().expect("tempdir");
        std::fs::write(
            store.path().join(LEGACY_SQLITE_FILENAME),
            b"SQLite format 3\0",
        )
        .expect("plant the old database");

        forget_in(store.path(), "some-agent")
            .expect_err("deleting from a database this build cannot read is not a no-op");
    }
}