Skip to main content

aion_store/
memory.rs

1//! `InMemoryStore` reference implementation and behavioural test suite.
2
3use std::collections::{BTreeMap, HashMap};
4use std::sync::{Mutex, MutexGuard, PoisonError};
5
6use aion_core::{
7    Event, TimerId, WorkflowFilter, WorkflowId, WorkflowListRequest, WorkflowStatus,
8    WorkflowSummary, status_from_events,
9};
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12
13use crate::namespace::{
14    MintOutcome, NamespaceOrigin, NamespacePlacement, NamespaceRecord, NamespaceState,
15    NamespaceStore,
16};
17use crate::package::{PackageRecord, PackageRouteRecord, PackageStore};
18use crate::visibility::{
19    PageCandidate, VisibilityPage, VisibilityRecord, VisibilityStore, paginate,
20};
21use crate::worker_deployment::{
22    DesiredState, PutOutcome, UndecodableWorkerDeployment, WorkerDeployment,
23    WorkerDeploymentDeleteOutcome, WorkerDeploymentListing, WorkerDeploymentPutResult,
24    WorkerDeploymentStore,
25};
26use crate::workloop::{
27    InvariantRecordSlot, InvariantStateRecord, UndecodableWorkloop, WorkloopDueProbe,
28    WorkloopListing, WorkloopRecord, WorkloopStore,
29};
30use crate::{
31    ReadableEventStore, RunSummary, StoreError, TimerEntry, TimerRetirement, WritableEventStore,
32    WriteToken,
33};
34
35/// Correct non-durable [`crate::EventStore`] implementation for tests and backend equivalence.
36#[derive(Debug, Default)]
37pub struct InMemoryStore {
38    state: Mutex<InMemoryState>,
39    namespaces: Mutex<BTreeMap<String, NamespaceRecord>>,
40    worker_deployments: Mutex<BTreeMap<String, Vec<u8>>>,
41    workloops: Mutex<BTreeMap<String, StoredWorkloopRow>>,
42    invariant_records: Mutex<BTreeMap<(String, String), Vec<u8>>>,
43    /// Assistant-session records, keyed by the session id's text form. Bytes
44    /// rather than decoded records so a poisoned row is expressible here
45    /// exactly as it is on the durable backend.
46    assistant_sessions: Mutex<BTreeMap<String, Vec<u8>>>,
47    /// Assistant transcripts, keyed by the session id's text form, each in
48    /// index order.
49    assistant_transcripts: Mutex<BTreeMap<String, Vec<crate::assistant::AssistantTranscriptEvent>>>,
50    /// The harness each caller last opened a session on, keyed by subject.
51    assistant_default_harnesses: Mutex<BTreeMap<String, String>>,
52}
53
54/// The reference [`crate::AssistantSessionStore`] implementation.
55mod assistant;
56
57#[async_trait]
58impl VisibilityStore for InMemoryStore {
59    async fn record_visibility(&self, record: VisibilityRecord) -> Result<(), StoreError> {
60        let mut state = self.lock_state()?;
61        // One row per workflow identity: a successor generation's upsert
62        // replaces the predecessor's row by construction of the key.
63        state.visibility.insert(record.workflow_id.clone(), record);
64        Ok(())
65    }
66
67    async fn get_visibility(
68        &self,
69        workflow_id: &WorkflowId,
70    ) -> Result<Option<VisibilityRecord>, StoreError> {
71        let state = self.lock_state()?;
72        Ok(state.visibility.get(workflow_id).cloned())
73    }
74
75    async fn remove_visibility(
76        &self,
77        workflow_id: &WorkflowId,
78        run_id: &aion_core::RunId,
79    ) -> Result<bool, StoreError> {
80        let mut state = self.lock_state()?;
81        // The run guard: only the named generation's row is removable, so a
82        // reconcile prune racing a newer generation's upsert deletes nothing.
83        let matches = state
84            .visibility
85            .get(workflow_id)
86            .is_some_and(|row| &row.run_id == run_id);
87        if matches {
88            state.visibility.remove(workflow_id);
89        }
90        Ok(matches)
91    }
92
93    /// The reference fold: every row is a candidate and [`paginate`] does the
94    /// rest. Linear in the namespace by design — this store exists to define
95    /// the answer, not to serve it at scale.
96    async fn list_workflows(
97        &self,
98        request: &WorkflowListRequest,
99    ) -> Result<VisibilityPage, StoreError> {
100        let candidates = {
101            let state = self.lock_state()?;
102            state
103                .visibility
104                .values()
105                .filter(|record| record.namespace == request.namespace)
106                .cloned()
107                .map(|record| PageCandidate::new(record, request))
108                .collect::<Vec<_>>()
109        };
110        paginate(request, candidates)
111    }
112}
113
114#[derive(Debug, Default)]
115struct InMemoryState {
116    histories: HashMap<WorkflowId, Vec<Event>>,
117    timers: HashMap<(WorkflowId, TimerId), TimerEntry>,
118    visibility: HashMap<WorkflowId, VisibilityRecord>,
119    packages: HashMap<(String, String), PackageRecord>,
120    package_routes: HashMap<String, String>,
121}
122
123impl InMemoryStore {
124    fn lock_state(&self) -> Result<MutexGuard<'_, InMemoryState>, StoreError> {
125        self.state
126            .lock()
127            .map_err(|error| StoreError::Backend(format!("in-memory store lock poisoned: {error}")))
128    }
129
130    /// Poison-tolerant lock over the namespace registry map.
131    ///
132    /// The namespace operations have no fallible body beyond the lock, so a
133    /// poisoned guard is recovered in place (matching the recording test-double
134    /// pattern in `testing.rs`) rather than surfaced as a `StoreError`.
135    fn lock_namespaces(&self) -> MutexGuard<'_, BTreeMap<String, NamespaceRecord>> {
136        self.namespaces
137            .lock()
138            .unwrap_or_else(PoisonError::into_inner)
139    }
140
141    fn lock_worker_deployments(&self) -> MutexGuard<'_, BTreeMap<String, Vec<u8>>> {
142        self.worker_deployments
143            .lock()
144            .unwrap_or_else(PoisonError::into_inner)
145    }
146
147    /// Write backend bytes directly for persistence conformance testing.
148    ///
149    /// This deliberately bypasses the entity codec so the shared suite can pin
150    /// poisoned-row listing, replacement, and deletion behavior on every backend.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`StoreError::Serialization`] when `name` is empty or whitespace.
155    pub fn write_raw_worker_deployment(
156        &self,
157        name: &str,
158        bytes: Vec<u8>,
159    ) -> Result<(), StoreError> {
160        if name.trim().is_empty() {
161            return Err(StoreError::Serialization(
162                "worker deployment name must not be empty".to_owned(),
163            ));
164        }
165        self.lock_worker_deployments()
166            .insert(name.to_owned(), bytes);
167        Ok(())
168    }
169
170    fn lock_workloops(&self) -> MutexGuard<'_, BTreeMap<String, StoredWorkloopRow>> {
171        self.workloops
172            .lock()
173            .unwrap_or_else(PoisonError::into_inner)
174    }
175
176    fn lock_invariant_records(&self) -> MutexGuard<'_, BTreeMap<(String, String), Vec<u8>>> {
177        self.invariant_records
178            .lock()
179            .unwrap_or_else(PoisonError::into_inner)
180    }
181
182    /// Write workloop backend bytes directly for persistence conformance
183    /// testing (poisoned-row listing behavior), bypassing the entity codec.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`StoreError::Serialization`] when `loop_id` is empty.
188    pub fn write_raw_workloop(&self, loop_id: &str, bytes: Vec<u8>) -> Result<(), StoreError> {
189        if loop_id.trim().is_empty() {
190            return Err(StoreError::Serialization(
191                "workloop id must not be empty".to_owned(),
192            ));
193        }
194        // A raw row's due instant comes from the probe when the bytes carry
195        // one; poisoned bytes index as never-due (skipped by the sweep,
196        // surfaced by the listing).
197        let next_check_at =
198            WorkloopDueProbe::decode(&bytes).map_or(None, |probe| probe.next_check_at);
199        self.lock_workloops().insert(
200            loop_id.to_owned(),
201            StoredWorkloopRow {
202                next_check_at,
203                bytes,
204            },
205        );
206        Ok(())
207    }
208}
209
210/// One stored workloop row: the encoded record plus its due instant, cached
211/// at write time so the due sweep filters on a comparison rather than a full
212/// decode — the store-level half of the R13.3 zero-cost-sleep measurement.
213#[derive(Debug)]
214struct StoredWorkloopRow {
215    next_check_at: Option<DateTime<Utc>>,
216    bytes: Vec<u8>,
217}
218
219#[async_trait]
220impl WorkloopStore for InMemoryStore {
221    async fn put_workloop(&self, record: WorkloopRecord) -> Result<(), StoreError> {
222        let bytes = record.encode()?;
223        self.lock_workloops().insert(
224            record.loop_id.to_string(),
225            StoredWorkloopRow {
226                next_check_at: record.next_check_at,
227                bytes,
228            },
229        );
230        Ok(())
231    }
232
233    async fn get_workloop(
234        &self,
235        loop_id: &WorkflowId,
236    ) -> Result<Option<WorkloopRecord>, StoreError> {
237        self.lock_workloops()
238            .get(&loop_id.to_string())
239            .map(|row| WorkloopRecord::decode(&row.bytes))
240            .transpose()
241    }
242
243    async fn list_workloops(&self) -> Result<WorkloopListing, StoreError> {
244        let mut listing = WorkloopListing::default();
245        for (loop_id, row) in self.lock_workloops().iter() {
246            match WorkloopRecord::decode(&row.bytes) {
247                Ok(record) => listing.workloops.push(record),
248                Err(error) => listing.undecodable.push(UndecodableWorkloop {
249                    loop_id: loop_id.clone(),
250                    error: error.to_string(),
251                }),
252            }
253        }
254        Ok(listing)
255    }
256
257    async fn due_workloops(&self, as_of: DateTime<Utc>) -> Result<Vec<WorkloopRecord>, StoreError> {
258        let mut due = Vec::new();
259        // The due filter is a cached-instant comparison per row; only DUE rows
260        // are decoded, so a thousand sleeping loops cost a thousand
261        // comparisons, not a thousand spec re-validations (R13.3, measured).
262        for row in self.lock_workloops().values() {
263            if row.next_check_at.is_none_or(|at| at > as_of) {
264                continue;
265            }
266            match WorkloopRecord::decode(&row.bytes) {
267                Ok(record) => due.push(record),
268                Err(error) => {
269                    // Undecodable rows are excluded from the hot sweep path but
270                    // never silently: they stay visible via list_workloops.
271                    tracing::warn!(%error, "skipping undecodable workloop row in due sweep");
272                }
273            }
274        }
275        Ok(due)
276    }
277
278    async fn remove_workloop(&self, loop_id: &WorkflowId) -> Result<bool, StoreError> {
279        Ok(self.lock_workloops().remove(&loop_id.to_string()).is_some())
280    }
281
282    async fn put_invariant_record(
283        &self,
284        record: InvariantStateRecord,
285        prune_before: DateTime<Utc>,
286    ) -> Result<u64, StoreError> {
287        let key = (record.loop_id.to_string(), record.invariant.clone());
288        let mut slots = self.lock_invariant_records();
289        let mut slot = match slots.get(&key) {
290            Some(bytes) => InvariantRecordSlot::decode(bytes)?.rotated(record),
291            None => InvariantRecordSlot {
292                current: record,
293                previous: Vec::new(),
294            },
295        };
296        // The prune runs on the SAME slot value the rotation produced, inside
297        // the one lock acquisition: retention is applied to the generation
298        // this write just rotated in, never to a slot re-read afterwards.
299        let removed = slot.prune(prune_before);
300        slots.insert(key, slot.encode()?);
301        Ok(removed)
302    }
303
304    async fn current_invariant_record(
305        &self,
306        loop_id: &WorkflowId,
307        invariant: &str,
308    ) -> Result<Option<InvariantStateRecord>, StoreError> {
309        self.lock_invariant_records()
310            .get(&(loop_id.to_string(), invariant.to_owned()))
311            .map(|bytes| InvariantRecordSlot::decode(bytes).map(|slot| slot.current))
312            .transpose()
313    }
314
315    async fn invariant_record_generations(
316        &self,
317        loop_id: &WorkflowId,
318        invariant: &str,
319    ) -> Result<Vec<InvariantStateRecord>, StoreError> {
320        self.lock_invariant_records()
321            .get(&(loop_id.to_string(), invariant.to_owned()))
322            .map(|bytes| InvariantRecordSlot::decode(bytes).map(|slot| slot.previous))
323            .transpose()
324            .map(Option::unwrap_or_default)
325    }
326}
327
328fn history_head(history: &[Event]) -> u64 {
329    history.iter().map(Event::seq).max().unwrap_or_default()
330}
331
332fn history_in_sequence_order(history: &[Event]) -> Vec<Event> {
333    let mut ordered = history.to_vec();
334    ordered.sort_by_key(Event::seq);
335    ordered
336}
337
338#[async_trait]
339impl PackageStore for InMemoryStore {
340    async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
341        let primary = record.workflow_type.clone();
342        self.put_package_with_routes(record, &[primary]).await
343    }
344
345    async fn put_package_with_routes(
346        &self,
347        record: PackageRecord,
348        route_workflow_types: &[String],
349    ) -> Result<(), StoreError> {
350        let mut state = self.lock_state()?;
351        for workflow_type in route_workflow_types {
352            state
353                .package_routes
354                .insert(workflow_type.clone(), record.content_hash.clone());
355        }
356        state.packages.insert(
357            (record.workflow_type.clone(), record.content_hash.clone()),
358            record,
359        );
360        Ok(())
361    }
362
363    async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
364        let state = self.lock_state()?;
365        let mut records: Vec<PackageRecord> = state.packages.values().cloned().collect();
366        records.sort_by(|left, right| {
367            left.deployed_at
368                .cmp(&right.deployed_at)
369                .then_with(|| left.workflow_type.cmp(&right.workflow_type))
370                .then_with(|| left.content_hash.cmp(&right.content_hash))
371        });
372        Ok(records)
373    }
374
375    async fn delete_package(
376        &self,
377        workflow_type: &str,
378        content_hash: &str,
379    ) -> Result<(), StoreError> {
380        let mut state = self.lock_state()?;
381        state
382            .packages
383            .remove(&(workflow_type.to_owned(), content_hash.to_owned()));
384        Ok(())
385    }
386
387    async fn put_package_route(
388        &self,
389        workflow_type: &str,
390        content_hash: &str,
391    ) -> Result<(), StoreError> {
392        let mut state = self.lock_state()?;
393        state
394            .package_routes
395            .insert(workflow_type.to_owned(), content_hash.to_owned());
396        Ok(())
397    }
398
399    async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
400        let state = self.lock_state()?;
401        let mut routes: Vec<PackageRouteRecord> = state
402            .package_routes
403            .iter()
404            .map(|(workflow_type, content_hash)| PackageRouteRecord {
405                workflow_type: workflow_type.clone(),
406                content_hash: content_hash.clone(),
407            })
408            .collect();
409        routes.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
410        Ok(routes)
411    }
412}
413
414#[async_trait]
415impl NamespaceStore for InMemoryStore {
416    async fn register_namespace(
417        &self,
418        name: &str,
419        origin: NamespaceOrigin,
420    ) -> Result<MintOutcome, StoreError> {
421        let now = Utc::now();
422        let mut namespaces = self.lock_namespaces();
423        if let Some(existing) = namespaces.get_mut(name) {
424            existing.bump_last_seen(now);
425            Ok(MintOutcome::AlreadyExisted)
426        } else {
427            namespaces.insert(
428                name.to_owned(),
429                NamespaceRecord::new_minted(name, origin, now),
430            );
431            Ok(MintOutcome::Created)
432        }
433    }
434
435    async fn put_namespace(&self, record: NamespaceRecord) -> Result<MintOutcome, StoreError> {
436        let now = Utc::now();
437        let mut namespaces = self.lock_namespaces();
438        if let Some(existing) = namespaces.get_mut(&record.name) {
439            // Idempotent on an existing name: reconcile as already-existing
440            // rather than overwriting the durable record wholesale. Only the
441            // staleness signal is refreshed.
442            existing.bump_last_seen(now);
443            Ok(MintOutcome::AlreadyExisted)
444        } else {
445            namespaces.insert(record.name.clone(), record);
446            Ok(MintOutcome::Created)
447        }
448    }
449
450    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>, StoreError> {
451        let namespaces = self.lock_namespaces();
452        let mut records: Vec<NamespaceRecord> = namespaces.values().cloned().collect();
453        records.sort_by(|left, right| {
454            left.created_at
455                .cmp(&right.created_at)
456                .then_with(|| left.name.cmp(&right.name))
457        });
458        Ok(records)
459    }
460
461    async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceRecord>, StoreError> {
462        let namespaces = self.lock_namespaces();
463        Ok(namespaces.get(name).cloned())
464    }
465
466    async fn set_namespace_placement(
467        &self,
468        name: &str,
469        placement: NamespacePlacement,
470    ) -> Result<Option<()>, StoreError> {
471        let now = Utc::now();
472        let mut namespaces = self.lock_namespaces();
473        let Some(existing) = namespaces.get_mut(name) else {
474            // Placement targets an already-minted namespace: an absent row is a
475            // not-found the caller surfaces, never a silent mint here.
476            return Ok(None);
477        };
478        existing.placement = placement;
479        existing.bump_last_seen(now);
480        Ok(Some(()))
481    }
482
483    async fn deprecate_namespace(&self, name: &str) -> Result<(), StoreError> {
484        let mut namespaces = self.lock_namespaces();
485        if let Some(existing) = namespaces.get_mut(name) {
486            existing.state = NamespaceState::Deprecated;
487        }
488        // A missing row is an idempotent no-op: deprecation never strands
489        // durable history and an absent registry entry is not an error.
490        Ok(())
491    }
492}
493
494#[async_trait]
495impl WorkerDeploymentStore for InMemoryStore {
496    async fn put_worker_deployment(
497        &self,
498        mut record: WorkerDeployment,
499    ) -> Result<WorkerDeploymentPutResult, StoreError> {
500        let mut deployments = self.lock_worker_deployments();
501        let outcome = if let Some(previous) = deployments.get(&record.name) {
502            match WorkerDeployment::decode(previous) {
503                Ok(previous) => {
504                    record = record.preserving_previous(&previous, Utc::now());
505                }
506                Err(error) => {
507                    tracing::warn!(
508                        deployment_key = %record.name,
509                        %error,
510                        "replacing undecodable prior worker-deployment bytes"
511                    );
512                }
513            }
514            PutOutcome::Replaced
515        } else {
516            PutOutcome::Created
517        };
518        deployments.insert(record.name.clone(), record.encode()?);
519        Ok(WorkerDeploymentPutResult {
520            outcome,
521            deployment: record,
522        })
523    }
524
525    async fn get_worker_deployment(
526        &self,
527        name: &str,
528    ) -> Result<Option<WorkerDeployment>, StoreError> {
529        self.lock_worker_deployments()
530            .get(name)
531            .map(|bytes| WorkerDeployment::decode(bytes))
532            .transpose()
533    }
534
535    async fn list_worker_deployments(&self) -> Result<WorkerDeploymentListing, StoreError> {
536        let mut listing = WorkerDeploymentListing::default();
537        for (name, bytes) in self.lock_worker_deployments().iter() {
538            match WorkerDeployment::decode(bytes) {
539                Ok(record) => listing.deployments.push(record),
540                Err(error) => listing.undecodable.push(UndecodableWorkerDeployment {
541                    name: name.clone(),
542                    error: error.to_string(),
543                }),
544            }
545        }
546        Ok(listing)
547    }
548
549    async fn set_desired_state(
550        &self,
551        name: &str,
552        desired: DesiredState,
553    ) -> Result<Option<WorkerDeployment>, StoreError> {
554        let mut deployments = self.lock_worker_deployments();
555        let Some(bytes) = deployments.get(name) else {
556            return Ok(None);
557        };
558        let mut record = WorkerDeployment::decode(bytes)?;
559        record.change_desired_state(desired, Utc::now());
560        deployments.insert(name.to_owned(), record.encode()?);
561        Ok(Some(record))
562    }
563
564    async fn delete_worker_deployment(
565        &self,
566        name: &str,
567    ) -> Result<WorkerDeploymentDeleteOutcome, StoreError> {
568        let Some(bytes) = self.lock_worker_deployments().remove(name) else {
569            return Ok(WorkerDeploymentDeleteOutcome {
570                existed: false,
571                deployment: None,
572            });
573        };
574        Ok(WorkerDeploymentDeleteOutcome {
575            existed: true,
576            deployment: WorkerDeployment::decode(&bytes).ok(),
577        })
578    }
579}
580
581#[async_trait]
582impl WritableEventStore for InMemoryStore {
583    async fn append(
584        &self,
585        _token: WriteToken,
586        workflow_id: &WorkflowId,
587        events: &[Event],
588        expected_seq: u64,
589    ) -> Result<(), StoreError> {
590        let mut state = self.lock_state()?;
591        let current_head = state
592            .histories
593            .get(workflow_id)
594            .map_or(0, |history| history_head(history));
595
596        if current_head != expected_seq {
597            return Err(StoreError::SequenceConflict {
598                expected: expected_seq,
599                found: current_head,
600            });
601        }
602
603        if events.is_empty() {
604            return Ok(());
605        }
606
607        for (next_seq, event) in (expected_seq + 1..).zip(events.iter()) {
608            if event.seq() != next_seq {
609                return Err(StoreError::Backend(format!(
610                    "event sequence must be contiguous: expected {next_seq}, got {}",
611                    event.seq()
612                )));
613            }
614        }
615
616        state
617            .histories
618            .entry(workflow_id.clone())
619            .or_default()
620            .extend(events.iter().cloned());
621        Ok(())
622    }
623}
624
625#[async_trait]
626impl ReadableEventStore for InMemoryStore {
627    async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
628        let state = self.lock_state()?;
629        Ok(state
630            .histories
631            .get(workflow_id)
632            .map_or_else(Vec::new, |history| history_in_sequence_order(history)))
633    }
634
635    async fn read_history_from(
636        &self,
637        workflow_id: &WorkflowId,
638        from_seq: u64,
639    ) -> Result<Vec<Event>, StoreError> {
640        let state = self.lock_state()?;
641        Ok(state
642            .histories
643            .get(workflow_id)
644            .map_or_else(Vec::new, |history| {
645                let mut events = history
646                    .iter()
647                    .filter(|event| event.seq() >= from_seq)
648                    .cloned()
649                    .collect::<Vec<_>>();
650                events.sort_by_key(Event::seq);
651                events
652            }))
653    }
654
655    async fn read_run_chain(
656        &self,
657        workflow_id: &WorkflowId,
658    ) -> Result<Vec<RunSummary>, StoreError> {
659        let state = self.lock_state()?;
660        let Some(history) = state.histories.get(workflow_id) else {
661            return Ok(Vec::new());
662        };
663
664        crate::run_chain::run_chain_from_history(history)
665    }
666
667    async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
668        let state = self.lock_state()?;
669        let mut workflow_ids = state.histories.keys().cloned().collect::<Vec<_>>();
670        workflow_ids.sort_by_key(ToString::to_string);
671        Ok(workflow_ids)
672    }
673
674    async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
675        let state = self.lock_state()?;
676        let mut active = state
677            .histories
678            .iter()
679            .filter(|(_, history)| {
680                matches!(
681                    status_from_events(&history_in_sequence_order(history)),
682                    WorkflowStatus::Running
683                )
684            })
685            .map(|(workflow_id, _)| workflow_id.clone())
686            .collect::<Vec<_>>();
687        active.sort_by_key(ToString::to_string);
688        Ok(active)
689    }
690
691    async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
692        let state = self.lock_state()?;
693        let mut paused = state
694            .histories
695            .iter()
696            .filter(|(_, history)| {
697                matches!(
698                    status_from_events(&history_in_sequence_order(history)),
699                    WorkflowStatus::Paused
700                )
701            })
702            .map(|(workflow_id, _)| workflow_id.clone())
703            .collect::<Vec<_>>();
704        paused.sort_by_key(ToString::to_string);
705        Ok(paused)
706    }
707
708    async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
709        let state = self.lock_state()?;
710        let mut summaries = state
711            .histories
712            .values()
713            .filter_map(|history| {
714                WorkflowSummary::from_history(&history_in_sequence_order(history))
715            })
716            .filter(|summary| filter.matches(summary))
717            .collect::<Vec<_>>();
718        summaries.sort_by(|left, right| {
719            left.started_at.cmp(&right.started_at).then_with(|| {
720                left.workflow_id
721                    .to_string()
722                    .cmp(&right.workflow_id.to_string())
723            })
724        });
725        Ok(summaries)
726    }
727
728    async fn schedule_timer(
729        &self,
730        workflow_id: &WorkflowId,
731        timer_id: &TimerId,
732        fire_at: DateTime<Utc>,
733        armed_seq: u64,
734    ) -> Result<(), StoreError> {
735        let mut state = self.lock_state()?;
736        state.timers.insert(
737            (workflow_id.clone(), timer_id.clone()),
738            TimerEntry {
739                workflow_id: workflow_id.clone(),
740                timer_id: timer_id.clone(),
741                fire_at,
742                armed_seq,
743            },
744        );
745        Ok(())
746    }
747
748    async fn retire_timer(
749        &self,
750        workflow_id: &WorkflowId,
751        timer_id: &TimerId,
752        fire_at: DateTime<Utc>,
753        armed_seq: u64,
754    ) -> Result<TimerRetirement, StoreError> {
755        let mut state = self.lock_state()?;
756        let key = (workflow_id.clone(), timer_id.clone());
757        // Remove only the arming the caller decided against: a row whose
758        // `(fire_at, armed_seq)` identity differs — a new instant, or the
759        // SAME instant re-armed under a newer `TimerStarted` — is the
760        // replacement timer's durable claim to a recovery fire
761        // (`Superseded`); the absent-row case is the idempotent `Retired`
762        // success ("nothing left to retire"), never an error. The single
763        // `lock_state()` held across get-and-remove is what makes the
764        // compare atomic against a concurrent `schedule_timer`.
765        match state.timers.get(&key) {
766            Some(entry) if entry.fire_at == fire_at && entry.armed_seq == armed_seq => {
767                state.timers.remove(&key);
768                Ok(TimerRetirement::Retired)
769            }
770            Some(_) => Ok(TimerRetirement::Superseded),
771            None => Ok(TimerRetirement::Retired),
772        }
773    }
774
775    async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
776        let state = self.lock_state()?;
777        let mut timers = state
778            .timers
779            .values()
780            .filter(|entry| entry.fire_at <= as_of)
781            .cloned()
782            .collect::<Vec<_>>();
783        timers.sort_by(|left, right| {
784            left.fire_at
785                .cmp(&right.fire_at)
786                .then_with(|| {
787                    left.workflow_id
788                        .to_string()
789                        .cmp(&right.workflow_id.to_string())
790                })
791                .then_with(|| left.timer_id.to_string().cmp(&right.timer_id.to_string()))
792        });
793        Ok(timers)
794    }
795}
796
797#[cfg(test)]
798mod tests {
799    use std::sync::Arc;
800
801    use aion_core::{
802        Event, EventEnvelope, Payload, TimerId, WorkflowError, WorkflowFilter, WorkflowId,
803        WorkflowStatus,
804    };
805    use chrono::{DateTime, Utc};
806    use serde_json::json;
807    use tokio::task;
808    use uuid::Uuid;
809
810    use super::InMemoryStore;
811    use crate::{ReadableEventStore, StoreError, TimerEntry, WritableEventStore, WriteToken};
812
813    fn write_token() -> WriteToken {
814        WriteToken::recorder()
815    }
816
817    fn recorded_at(offset_seconds: i64) -> DateTime<Utc> {
818        DateTime::from_timestamp(1_700_000_000 + offset_seconds, 0).unwrap_or_default()
819    }
820
821    fn workflow_id(value: u128) -> WorkflowId {
822        WorkflowId::new(Uuid::from_u128(value))
823    }
824
825    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
826        EventEnvelope {
827            seq,
828            recorded_at: recorded_at(i64::try_from(seq).unwrap_or_default()),
829            workflow_id: workflow_id.clone(),
830        }
831    }
832
833    fn run_id(value: u128) -> aion_core::RunId {
834        aion_core::RunId::new(Uuid::from_u128(value))
835    }
836
837    fn payload(label: &str) -> Payload {
838        Payload::from_json(&json!({ "label": label })).unwrap_or_else(|error| {
839            Payload::new(
840                aion_core::ContentType::Json,
841                format!("{{\"payload_error\":\"{error}\"}}").into_bytes(),
842            )
843        })
844    }
845
846    fn workflow_started(seq: u64, workflow_id: &WorkflowId, workflow_type: &str) -> Event {
847        Event::WorkflowStarted {
848            envelope: envelope(seq, workflow_id),
849            workflow_type: workflow_type.to_owned(),
850            input: payload("input"),
851            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
852            parent_run_id: None,
853            parent_workflow_id: None,
854            package_version: aion_core::PackageVersion::new("a".repeat(64)),
855        }
856    }
857
858    fn workflow_completed(seq: u64, workflow_id: &WorkflowId) -> Event {
859        Event::WorkflowCompleted {
860            envelope: envelope(seq, workflow_id),
861            result: payload("result"),
862        }
863    }
864
865    fn workflow_failed(seq: u64, workflow_id: &WorkflowId) -> Event {
866        Event::WorkflowFailed {
867            envelope: envelope(seq, workflow_id),
868            error: WorkflowError {
869                message: String::from("failed"),
870                details: None,
871            },
872        }
873    }
874
875    #[tokio::test]
876    async fn read_history_returns_empty_for_unknown_workflow() -> Result<(), StoreError> {
877        let store = InMemoryStore::default();
878
879        assert_eq!(store.read_history(&workflow_id(1)).await?, Vec::new());
880        Ok(())
881    }
882
883    #[tokio::test]
884    async fn append_preserves_sequence_order() -> Result<(), StoreError> {
885        let store = InMemoryStore::default();
886        let workflow_id = workflow_id(1);
887        let first = workflow_started(1, &workflow_id, "checkout");
888        let second = workflow_completed(2, &workflow_id);
889
890        store
891            .append(write_token(), &workflow_id, std::slice::from_ref(&first), 0)
892            .await?;
893        store
894            .append(
895                write_token(),
896                &workflow_id,
897                std::slice::from_ref(&second),
898                1,
899            )
900            .await?;
901
902        assert_eq!(store.read_history(&workflow_id).await?, vec![first, second]);
903        Ok(())
904    }
905
906    #[tokio::test]
907    async fn list_active_returns_only_running_workflows() -> Result<(), StoreError> {
908        let store = InMemoryStore::default();
909        let running = workflow_id(1);
910        let completed = workflow_id(2);
911
912        store
913            .append(
914                write_token(),
915                &running,
916                &[workflow_started(1, &running, "checkout")],
917                0,
918            )
919            .await?;
920        store
921            .append(
922                write_token(),
923                &completed,
924                &[
925                    workflow_started(1, &completed, "checkout"),
926                    workflow_completed(2, &completed),
927                ],
928                0,
929            )
930            .await?;
931
932        assert_eq!(store.list_active().await?, vec![running]);
933        Ok(())
934    }
935
936    fn workflow_paused(seq: u64, workflow_id: &WorkflowId) -> Event {
937        Event::WorkflowPaused {
938            envelope: envelope(seq, workflow_id),
939            run_id: run_id(1),
940            reason: None,
941            operator: None,
942        }
943    }
944
945    /// #204 GATE-2 recovery mechanism: a paused run is EXCLUDED from `list_active`
946    /// (== Running) — so it is not respawned on restart — and is the sole content
947    /// of `list_paused` (== Paused), the durable source the dispatch-hold is
948    /// rebuilt from.
949    #[tokio::test]
950    async fn list_paused_and_list_active_partition_by_projected_status() -> Result<(), StoreError> {
951        let store = InMemoryStore::default();
952        let running = workflow_id(1);
953        let paused = workflow_id(2);
954
955        store
956            .append(
957                write_token(),
958                &running,
959                &[workflow_started(1, &running, "checkout")],
960                0,
961            )
962            .await?;
963        store
964            .append(
965                write_token(),
966                &paused,
967                &[
968                    workflow_started(1, &paused, "checkout"),
969                    workflow_paused(2, &paused),
970                ],
971                0,
972            )
973            .await?;
974
975        assert_eq!(
976            store.list_active().await?,
977            vec![running],
978            "a paused run is excluded from list_active (not respawned)"
979        );
980        assert_eq!(
981            store.list_paused().await?,
982            vec![paused],
983            "list_paused returns exactly the paused run (the hold rebuild source)"
984        );
985        Ok(())
986    }
987
988    #[tokio::test]
989    async fn list_workflow_ids_returns_running_and_terminal_histories() -> Result<(), StoreError> {
990        let store = InMemoryStore::default();
991        let running = workflow_id(2);
992        let completed = workflow_id(1);
993
994        store
995            .append(
996                write_token(),
997                &running,
998                &[workflow_started(1, &running, "checkout")],
999                0,
1000            )
1001            .await?;
1002        store
1003            .append(
1004                write_token(),
1005                &completed,
1006                &[
1007                    workflow_started(1, &completed, "checkout"),
1008                    workflow_completed(2, &completed),
1009                ],
1010                0,
1011            )
1012            .await?;
1013
1014        assert_eq!(store.list_workflow_ids().await?, vec![completed, running]);
1015        Ok(())
1016    }
1017
1018    #[tokio::test]
1019    async fn read_run_chain_projects_run_id_from_started_event() -> Result<(), StoreError> {
1020        let store = InMemoryStore::default();
1021        let workflow_id = workflow_id(1);
1022
1023        store
1024            .append(
1025                write_token(),
1026                &workflow_id,
1027                &[
1028                    workflow_started(1, &workflow_id, "checkout"),
1029                    workflow_completed(2, &workflow_id),
1030                ],
1031                0,
1032            )
1033            .await?;
1034
1035        let chain = store.read_run_chain(&workflow_id).await?;
1036
1037        assert_eq!(chain.len(), 1);
1038        // run_id comes from the WorkflowStarted event (hardcoded to from_u128(1) in the helper)
1039        assert_eq!(chain[0].run_id, run_id(1));
1040        assert_eq!(chain[0].status, WorkflowStatus::Completed);
1041        assert_eq!(chain[0].closed_at, Some(recorded_at(2)));
1042        Ok(())
1043    }
1044
1045    #[tokio::test]
1046    async fn query_uses_core_filter_semantics() -> Result<(), StoreError> {
1047        let store = InMemoryStore::default();
1048        let running_checkout = workflow_id(1);
1049        let completed_checkout = workflow_id(2);
1050        let failed_billing = workflow_id(3);
1051
1052        store
1053            .append(
1054                write_token(),
1055                &running_checkout,
1056                &[workflow_started(1, &running_checkout, "checkout")],
1057                0,
1058            )
1059            .await?;
1060        store
1061            .append(
1062                write_token(),
1063                &completed_checkout,
1064                &[
1065                    workflow_started(1, &completed_checkout, "checkout"),
1066                    workflow_completed(2, &completed_checkout),
1067                ],
1068                0,
1069            )
1070            .await?;
1071        store
1072            .append(
1073                write_token(),
1074                &failed_billing,
1075                &[
1076                    workflow_started(1, &failed_billing, "billing"),
1077                    workflow_failed(2, &failed_billing),
1078                ],
1079                0,
1080            )
1081            .await?;
1082
1083        let filter = WorkflowFilter {
1084            workflow_type: Some(String::from("checkout")),
1085            status: Some(WorkflowStatus::Completed),
1086            started_after: Some(recorded_at(1)),
1087            started_before: Some(recorded_at(1)),
1088            parent: None,
1089        };
1090        let summaries = store.query(&filter).await?;
1091
1092        assert_eq!(summaries.len(), 1);
1093        assert_eq!(summaries[0].workflow_id, completed_checkout);
1094        assert_eq!(summaries[0].status, WorkflowStatus::Completed);
1095        Ok(())
1096    }
1097
1098    #[tokio::test]
1099    async fn stale_expected_sequence_writes_nothing() -> Result<(), StoreError> {
1100        let store = InMemoryStore::default();
1101        let workflow_id = workflow_id(1);
1102        let first = workflow_started(1, &workflow_id, "checkout");
1103
1104        store
1105            .append(write_token(), &workflow_id, std::slice::from_ref(&first), 0)
1106            .await?;
1107        let conflict = store
1108            .append(
1109                write_token(),
1110                &workflow_id,
1111                &[workflow_completed(2, &workflow_id)],
1112                0,
1113            )
1114            .await;
1115
1116        assert_eq!(
1117            conflict,
1118            Err(StoreError::SequenceConflict {
1119                expected: 0,
1120                found: 1,
1121            })
1122        );
1123        assert_eq!(store.read_history(&workflow_id).await?, vec![first]);
1124        Ok(())
1125    }
1126
1127    #[tokio::test]
1128    async fn append_rejects_non_contiguous_event_sequences() -> Result<(), StoreError> {
1129        let store = InMemoryStore::default();
1130        let wf = workflow_id(1);
1131
1132        let result = store
1133            .append(
1134                write_token(),
1135                &wf,
1136                &[
1137                    workflow_started(1, &wf, "checkout"),
1138                    workflow_completed(5, &wf),
1139                ],
1140                0,
1141            )
1142            .await;
1143
1144        assert!(result.is_err());
1145        assert!(matches!(result, Err(StoreError::Backend(_))));
1146        assert_eq!(store.read_history(&wf).await?, Vec::new());
1147        Ok(())
1148    }
1149
1150    #[tokio::test]
1151    async fn concurrent_appends_on_same_expected_sequence_conflict_once() -> Result<(), StoreError>
1152    {
1153        let store = Arc::new(InMemoryStore::default());
1154        let workflow_id = workflow_id(1);
1155        let first_store = Arc::clone(&store);
1156        let first_workflow = workflow_id.clone();
1157        let second_store = Arc::clone(&store);
1158        let second_workflow = workflow_id.clone();
1159
1160        let first = task::spawn(async move {
1161            first_store
1162                .append(
1163                    write_token(),
1164                    &first_workflow,
1165                    &[workflow_started(1, &first_workflow, "checkout")],
1166                    0,
1167                )
1168                .await
1169        });
1170        let second = task::spawn(async move {
1171            second_store
1172                .append(
1173                    write_token(),
1174                    &second_workflow,
1175                    &[workflow_completed(1, &second_workflow)],
1176                    0,
1177                )
1178                .await
1179        });
1180
1181        let results = [
1182            first
1183                .await
1184                .map_err(|error| StoreError::Backend(format!("append task failed: {error}")))?,
1185            second
1186                .await
1187                .map_err(|error| StoreError::Backend(format!("append task failed: {error}")))?,
1188        ];
1189
1190        assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
1191        assert_eq!(
1192            results
1193                .iter()
1194                .filter(|result| matches!(
1195                    result,
1196                    Err(StoreError::SequenceConflict {
1197                        expected: 0,
1198                        found: 1
1199                    })
1200                ))
1201                .count(),
1202            1
1203        );
1204        assert_eq!(store.read_history(&workflow_id).await?.len(), 1);
1205        Ok(())
1206    }
1207
1208    #[tokio::test]
1209    async fn rescheduling_same_timer_replaces_prior_fire_at() -> Result<(), StoreError> {
1210        let store = InMemoryStore::default();
1211        let workflow_id = workflow_id(1);
1212        let timer_id = TimerId::anonymous(1);
1213        let first_fire_at = recorded_at(10);
1214        let replacement_fire_at = recorded_at(30);
1215
1216        store
1217            .schedule_timer(&workflow_id, &timer_id, first_fire_at, 1)
1218            .await?;
1219        store
1220            .schedule_timer(&workflow_id, &timer_id, replacement_fire_at, 2)
1221            .await?;
1222
1223        assert_eq!(store.expired_timers(first_fire_at).await?, Vec::new());
1224        assert_eq!(
1225            store.expired_timers(replacement_fire_at).await?,
1226            vec![TimerEntry {
1227                workflow_id,
1228                timer_id,
1229                fire_at: replacement_fire_at,
1230                armed_seq: 2,
1231            }]
1232        );
1233        Ok(())
1234    }
1235
1236    #[tokio::test]
1237    async fn expired_timers_include_boundary_and_exclude_future() -> Result<(), StoreError> {
1238        let store = InMemoryStore::default();
1239        let workflow_id = workflow_id(1);
1240        let past_timer = TimerId::anonymous(1);
1241        let boundary_timer = TimerId::anonymous(2);
1242        let future_timer = TimerId::anonymous(3);
1243        let as_of = recorded_at(20);
1244
1245        store
1246            .schedule_timer(&workflow_id, &future_timer, recorded_at(30), 1)
1247            .await?;
1248        store
1249            .schedule_timer(&workflow_id, &boundary_timer, as_of, 2)
1250            .await?;
1251        store
1252            .schedule_timer(&workflow_id, &past_timer, recorded_at(10), 3)
1253            .await?;
1254
1255        assert_eq!(
1256            store.expired_timers(as_of).await?,
1257            vec![
1258                TimerEntry {
1259                    workflow_id: workflow_id.clone(),
1260                    timer_id: past_timer,
1261                    fire_at: recorded_at(10),
1262                    armed_seq: 3,
1263                },
1264                TimerEntry {
1265                    workflow_id,
1266                    timer_id: boundary_timer,
1267                    fire_at: as_of,
1268                    armed_seq: 2,
1269                },
1270            ]
1271        );
1272        Ok(())
1273    }
1274}
1275
1276#[cfg(test)]
1277mod namespace_tests {
1278    #![allow(clippy::expect_used)]
1279
1280    use super::InMemoryStore;
1281    use crate::namespace::{
1282        MintOutcome, NamespaceOrigin, NamespacePlacement, NamespaceRecord, NamespaceState,
1283    };
1284    use crate::{NamespaceStore, StoreError};
1285    use chrono::{TimeZone, Utc};
1286    use std::collections::BTreeSet;
1287
1288    fn labels(values: &[&str]) -> BTreeSet<String> {
1289        values.iter().map(|v| (*v).to_owned()).collect()
1290    }
1291
1292    /// `set_namespace_placement` updates ONLY the placement (+ `last_seen`) of an
1293    /// existing record, leaves the other fields untouched, is idempotent, and is a
1294    /// not-found (`Ok(None)`) for an absent namespace — never a silent mint.
1295    #[tokio::test]
1296    async fn set_placement_updates_only_placement_and_reports_not_found() -> Result<(), StoreError>
1297    {
1298        let store = InMemoryStore::default();
1299        store
1300            .register_namespace("orders", NamespaceOrigin::Explicit)
1301            .await?;
1302        let original = store
1303            .get_namespace("orders")
1304            .await?
1305            .expect("namespace must persist");
1306        assert_eq!(original.placement, NamespacePlacement::Unplaced);
1307
1308        let placement = NamespacePlacement::Prefer {
1309            nodes: labels(&["n1", "n2"]),
1310        };
1311        assert_eq!(
1312            store
1313                .set_namespace_placement("orders", placement.clone())
1314                .await?,
1315            Some(())
1316        );
1317        let updated = store
1318            .get_namespace("orders")
1319            .await?
1320            .expect("namespace must persist");
1321        assert_eq!(updated.placement, placement);
1322        // Only placement + last_seen changed; identity/lifecycle preserved.
1323        assert_eq!(updated.origin, original.origin);
1324        assert_eq!(updated.created_at, original.created_at);
1325        assert_eq!(updated.state, original.state);
1326
1327        // Idempotent: re-applying the same placement is a successful no-op.
1328        assert_eq!(
1329            store.set_namespace_placement("orders", placement).await?,
1330            Some(())
1331        );
1332
1333        // Absent namespace: not-found, and nothing minted.
1334        assert_eq!(
1335            store
1336                .set_namespace_placement("ghost", NamespacePlacement::Unplaced)
1337                .await?,
1338            None
1339        );
1340        assert!(store.get_namespace("ghost").await?.is_none());
1341        Ok(())
1342    }
1343
1344    #[tokio::test]
1345    async fn register_creates_if_absent_and_persists() -> Result<(), StoreError> {
1346        let store = InMemoryStore::default();
1347
1348        let outcome = store
1349            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1350            .await?;
1351
1352        assert_eq!(outcome, MintOutcome::Created);
1353        let record = store
1354            .get_namespace("orders")
1355            .await?
1356            .expect("namespace must persist");
1357        assert_eq!(record.name, "orders");
1358        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
1359        assert_eq!(record.state, NamespaceState::Active);
1360        assert_eq!(record.created_at, record.last_seen);
1361        Ok(())
1362    }
1363
1364    #[tokio::test]
1365    async fn second_register_already_existed_bumps_last_seen_only() -> Result<(), StoreError> {
1366        let store = InMemoryStore::default();
1367
1368        let first = store
1369            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1370            .await?;
1371        assert_eq!(first, MintOutcome::Created);
1372        let original = store
1373            .get_namespace("orders")
1374            .await?
1375            .expect("namespace must persist");
1376
1377        // A different origin on re-register must NOT overwrite the recorded origin.
1378        let second = store
1379            .register_namespace("orders", NamespaceOrigin::Explicit)
1380            .await?;
1381        assert_eq!(second, MintOutcome::AlreadyExisted);
1382
1383        let touched = store
1384            .get_namespace("orders")
1385            .await?
1386            .expect("namespace must persist");
1387        assert_eq!(touched.created_at, original.created_at);
1388        assert_eq!(touched.origin, NamespaceOrigin::WorkerMint);
1389        assert!(touched.last_seen >= original.last_seen);
1390        Ok(())
1391    }
1392
1393    #[tokio::test]
1394    async fn put_namespace_is_idempotent_on_existing_name() -> Result<(), StoreError> {
1395        let store = InMemoryStore::default();
1396        let now = Utc
1397            .with_ymd_and_hms(2026, 6, 30, 12, 0, 0)
1398            .single()
1399            .expect("valid instant");
1400
1401        let mut record = NamespaceRecord::new_minted("billing", NamespaceOrigin::Explicit, now);
1402        record.config.kind = Some("tenant".to_owned());
1403
1404        let created = store.put_namespace(record.clone()).await?;
1405        assert_eq!(created, MintOutcome::Created);
1406
1407        // A second put with a DIFFERENT record body must reconcile as
1408        // AlreadyExisted and must not overwrite the stored record wholesale.
1409        let mut replacement =
1410            NamespaceRecord::new_minted("billing", NamespaceOrigin::WorkerMint, now);
1411        replacement.config.kind = None;
1412        let again = store.put_namespace(replacement).await?;
1413        assert_eq!(again, MintOutcome::AlreadyExisted);
1414
1415        let stored = store
1416            .get_namespace("billing")
1417            .await?
1418            .expect("namespace must persist");
1419        assert_eq!(stored.origin, NamespaceOrigin::Explicit);
1420        assert_eq!(stored.config.kind.as_deref(), Some("tenant"));
1421        Ok(())
1422    }
1423
1424    #[tokio::test]
1425    async fn list_orders_by_created_at_then_name() -> Result<(), StoreError> {
1426        let store = InMemoryStore::default();
1427        let earlier = Utc
1428            .with_ymd_and_hms(2026, 6, 30, 12, 0, 0)
1429            .single()
1430            .expect("valid instant");
1431        let later = Utc
1432            .with_ymd_and_hms(2026, 6, 30, 13, 0, 0)
1433            .single()
1434            .expect("valid instant");
1435
1436        // Two share `earlier` (tiebreak by name), one is `later`.
1437        store
1438            .put_namespace(NamespaceRecord::new_minted(
1439                "zeta",
1440                NamespaceOrigin::Explicit,
1441                earlier,
1442            ))
1443            .await?;
1444        store
1445            .put_namespace(NamespaceRecord::new_minted(
1446                "alpha",
1447                NamespaceOrigin::Explicit,
1448                earlier,
1449            ))
1450            .await?;
1451        store
1452            .put_namespace(NamespaceRecord::new_minted(
1453                "beta",
1454                NamespaceOrigin::Explicit,
1455                later,
1456            ))
1457            .await?;
1458
1459        let listed: Vec<String> = store
1460            .list_namespaces()
1461            .await?
1462            .into_iter()
1463            .map(|record| record.name)
1464            .collect();
1465
1466        assert_eq!(listed, vec!["alpha", "zeta", "beta"]);
1467        Ok(())
1468    }
1469
1470    #[tokio::test]
1471    async fn get_returns_none_for_absent_name() -> Result<(), StoreError> {
1472        let store = InMemoryStore::default();
1473        assert!(store.get_namespace("missing").await?.is_none());
1474        Ok(())
1475    }
1476
1477    #[tokio::test]
1478    async fn deprecate_sets_state_and_is_idempotent() -> Result<(), StoreError> {
1479        let store = InMemoryStore::default();
1480        store
1481            .register_namespace("orders", NamespaceOrigin::WorkerMint)
1482            .await?;
1483
1484        store.deprecate_namespace("orders").await?;
1485        let deprecated = store
1486            .get_namespace("orders")
1487            .await?
1488            .expect("namespace must persist");
1489        assert_eq!(deprecated.state, NamespaceState::Deprecated);
1490
1491        // Deprecating again is a no-op, not an error.
1492        store.deprecate_namespace("orders").await?;
1493        let still = store
1494            .get_namespace("orders")
1495            .await?
1496            .expect("namespace must persist");
1497        assert_eq!(still.state, NamespaceState::Deprecated);
1498
1499        // Deprecating an absent namespace is also an idempotent no-op.
1500        store.deprecate_namespace("never-seen").await?;
1501        assert!(store.get_namespace("never-seen").await?.is_none());
1502        Ok(())
1503    }
1504}