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