Skip to main content

aion_store/
namespace.rs

1//! Durable, minted-on-use namespace registry contract (Control-Plane Phase 1).
2//!
3//! Turns the namespace from a free-form per-workflow label into a first-class
4//! durable record so that the live set of namespaces is listable and survives
5//! owner-node death / failover. A namespace comes into being with zero
6//! ceremony: a worker registering for an unseen namespace mints one via an
7//! idempotent upsert — no pre-provision step.
8//!
9//! Existence is anchored on durable **state**, never on the registry row
10//! alone: a namespace exists if it has durable state OR a live worker OR an
11//! explicit registry entry. Worker-minting is one path to existence, never the
12//! definition, so a reaped row can never orphan durable history.
13//!
14//! This module defines the foundation only: the [`NamespaceRecord`] shape, its
15//! opaque-byte codec, and the [`NamespaceStore`] trait. The store backends
16//! (in-memory local-only, haematite quorum-replicated) implement the
17//! trait in later slices; the store treats the record as opaque truth and only
18//! decodes it to satisfy `list`.
19
20use std::collections::BTreeSet;
21
22use async_trait::async_trait;
23use chrono::{DateTime, SecondsFormat, Utc};
24use serde::{Deserialize, Serialize};
25
26use crate::StoreError;
27
28/// One durable namespace registry entry.
29///
30/// The control-plane source of truth for "this namespace exists": listable,
31/// failover-survivable, and the anchor for future per-namespace policy
32/// (quotas, placement, retention).
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct NamespaceRecord {
35    /// The namespace name. Free-form, exactly as carried on the wire
36    /// (`StartWorkflowRequest.namespace` / `RegisterWorker.namespaces`).
37    /// Primary key.
38    pub name: String,
39    /// When the registry first minted this namespace (first reference).
40    pub created_at: DateTime<Utc>,
41    /// Most recent time a worker/start referenced it — refreshed on
42    /// mint-touch. Drives staleness/observability; never drives reaping while
43    /// durable state exists.
44    pub last_seen: DateTime<Utc>,
45    /// How it came to exist: worker-mint, explicit POST, or
46    /// inferred-from-state.
47    pub origin: NamespaceOrigin,
48    /// Reserved per-namespace policy blob (retention, quotas, auth scope).
49    /// Phase 1 writes [`NamespaceConfig::default`]; Phase 2 fills it. Present
50    /// day-one to avoid a later data migration.
51    pub config: NamespaceConfig,
52    /// Reserved placement directive (node/shard-range affinity). Phase 1 is
53    /// [`NamespacePlacement::Unplaced`]. Present day-one so physical isolation
54    /// is a later policy, not a migration.
55    pub placement: NamespacePlacement,
56    /// Lifecycle state, so a namespace can be retired
57    /// (deprecate-before-delete) without losing its durable history.
58    pub state: NamespaceState,
59}
60
61/// How a namespace came to exist in the registry.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub enum NamespaceOrigin {
64    /// Minted by a worker registering for a previously unseen namespace.
65    WorkerMint,
66    /// Minted by a workflow start resolving a previously unseen namespace before
67    /// any worker registered for it (the start-time safety net).
68    StartMint,
69    /// Created by an explicit operator request (`POST /namespaces`).
70    Explicit,
71    /// Back-filled lazily because durable state existed without a registry
72    /// row (e.g. a pre-upgrade namespace).
73    InferredFromState,
74}
75
76/// Lifecycle state of a namespace record.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub enum NamespaceState {
79    /// In service.
80    Active,
81    /// Retired-but-retained: no new work should target it, but its durable
82    /// history is preserved (deprecate-before-delete).
83    Deprecated,
84}
85
86/// Result of a minted-on-use upsert: whether this call brought the record into
87/// being or observed one that already existed.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum MintOutcome {
90    /// This call created the record (drives the "loud created" signal).
91    Created,
92    /// A record already existed (idempotent touch / concurrent racer won).
93    AlreadyExisted,
94}
95
96/// Reserved per-namespace policy blob.
97///
98/// Empty in Phase 1 (writes [`Default`]); Phase 2 fills retention, quotas, and
99/// auth-scope keys. Reserving it day-one makes those later additions a policy
100/// flip rather than a data migration.
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
102pub struct NamespaceConfig {
103    /// Reserved tenant / sub-grouping discriminator (`namespace-IS-tenant` vs
104    /// a sub-grouping of a larger tenant).
105    ///
106    /// **Reserved for Phase 2.** Always `None` in Phase 1. Reserved now so the
107    /// tenant⊃namespace split can be introduced later as a policy flip, not a
108    /// record-shape migration.
109    pub kind: Option<String>,
110    /// Per-tenant **cluster-wide** concurrent-in-flight-activity ceiling — the
111    /// quota dimension that maps onto the scarce agent/LLM resource (every
112    /// model/tool call is an activity). This is a CLUSTER-WIDE contract, never
113    /// per-node: a tenant who sets `Some(256)` is promised ≈256 concurrent
114    /// activities across the whole cluster, not `256 × node_count` (CP-Phase-2
115    /// §3.6). `None` (the default) means the generous platform default applies
116    /// (`[namespaces] max_in_flight_activities`, NOT a low hard cap); `Some(n)`
117    /// is an explicit per-tenant override.
118    ///
119    /// **Stored-only in this slice (P2-Q1).** Nothing reads it yet — the outbox
120    /// dispatcher's keyed backpressure (P2-Q2) consults it in a later slice. It
121    /// is carried day-one with additive serde so enforcement is a policy flip,
122    /// not a record-shape migration.
123    pub max_in_flight_activities: Option<u32>,
124}
125
126/// Placement directive for a namespace, over the existing within-pool `node`
127/// routing axis (`(namespace, task_queue, node)`).
128///
129/// [`NamespacePlacement::Unplaced`] remains the default and today's behaviour.
130/// [`NamespacePlacement::Prefer`] is a SOFT default node-label set (spill to any
131/// worker when none of the preferred labels are live); [`NamespacePlacement::Pinned`]
132/// is a HARD required node-label set (wait when none are live — opt-in isolation).
133///
134/// `nodes` are **free-form node labels** matched against a worker's advertised
135/// `node` (a locality, not a process). [`BTreeSet`] gives deterministic ordering
136/// so the encoded form is stable.
137///
138/// This directive is **enforced, not merely recorded**, and it is enforced in
139/// two places that must be read together:
140///
141/// * **At registration** (P2-I1) — `enforce_pinned_placement` in the worker
142///   registry rejects the WHOLE registration of a worker whose advertised
143///   `node` is absent from a `Pinned` namespace's label set. So a hard-pinned
144///   namespace's pool cannot contain an inadmissible worker in the first place.
145/// * **At dispatch** (P2-P3) — `worker_selection_for` resolves `Pinned` to a
146///   required label set (stall until an admissible worker is live: isolation
147///   over availability) and everything else to preference tiers with a spill to
148///   any live worker.
149///
150/// Stating it here because the enforcement lives in `aion-server` while the
151/// type lives in `aion-store`: a reader of this file alone would otherwise have
152/// to guess whether the field means anything, and for one slice of its life it
153/// genuinely did not.
154#[derive(Clone, Debug, Default, PartialEq, Eq)]
155pub enum NamespacePlacement {
156    /// No placement directive — the namespace's records scatter across shards
157    /// by name-hash like all other durable state, and its activities dispatch
158    /// to any live worker (today's behaviour).
159    #[default]
160    Unplaced,
161    /// SOFT placement: prefer workers whose advertised `node` is in `nodes`,
162    /// spilling to any live worker when none of the preferred labels are live.
163    Prefer {
164        /// The preferred free-form node-label set.
165        nodes: BTreeSet<String>,
166    },
167    /// HARD placement: require a worker whose advertised `node` is in `nodes`,
168    /// waiting when none are live (opt-in tenant isolation).
169    Pinned {
170        /// The required free-form node-label set.
171        nodes: BTreeSet<String>,
172    },
173}
174
175impl NamespaceRecord {
176    /// Builds a freshly minted record for `name`.
177    ///
178    /// `created_at` and `last_seen` are both set to `now` (a brand-new
179    /// namespace has been seen exactly once, at creation), `state` is
180    /// [`NamespaceState::Active`], and `config`/`placement` take their
181    /// reserved Phase-1 defaults.
182    #[must_use]
183    pub fn new_minted(name: &str, origin: NamespaceOrigin, now: DateTime<Utc>) -> Self {
184        Self {
185            name: name.to_owned(),
186            created_at: now,
187            last_seen: now,
188            origin,
189            config: NamespaceConfig::default(),
190            placement: NamespacePlacement::default(),
191            state: NamespaceState::Active,
192        }
193    }
194
195    /// Advances `last_seen` to `now`, leaving every other field untouched.
196    ///
197    /// Used by the idempotent mint-touch path: re-referencing an existing
198    /// namespace refreshes its staleness signal without altering existence,
199    /// origin, or lifecycle state. `now` is applied unconditionally — callers
200    /// supply a monotonic clock.
201    pub fn bump_last_seen(&mut self, now: DateTime<Utc>) {
202        self.last_seen = now;
203    }
204
205    /// Encodes the record to opaque bytes for store persistence.
206    ///
207    /// Mirrors the package codec: `serde_json` over a stable on-disk form with
208    /// instants rendered as RFC 3339 text. The store backend never parses the
209    /// result beyond [`NamespaceRecord::decode`].
210    ///
211    /// # Errors
212    ///
213    /// Returns [`StoreError::Serialization`] if the record cannot be encoded.
214    pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
215        let stored = StoredNamespace {
216            name: self.name.clone(),
217            created_at: encode_instant(self.created_at),
218            last_seen: encode_instant(self.last_seen),
219            origin: self.origin,
220            kind: self.config.kind.clone(),
221            max_in_flight_activities: self.config.max_in_flight_activities,
222            placement: self.placement.clone(),
223            state: self.state,
224        };
225        serde_json::to_vec(&stored).map_err(|error| StoreError::Serialization(error.to_string()))
226    }
227
228    /// Decodes a record previously produced by [`NamespaceRecord::encode`].
229    ///
230    /// # Errors
231    ///
232    /// Returns [`StoreError::Serialization`] if `bytes` is not a valid encoded
233    /// record (malformed JSON or an unparseable instant).
234    pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
235        let stored: StoredNamespace = serde_json::from_slice(bytes)
236            .map_err(|error| StoreError::Serialization(error.to_string()))?;
237        Ok(Self {
238            name: stored.name,
239            created_at: decode_instant(&stored.created_at)?,
240            last_seen: decode_instant(&stored.last_seen)?,
241            origin: stored.origin,
242            config: NamespaceConfig {
243                kind: stored.kind,
244                max_in_flight_activities: stored.max_in_flight_activities,
245            },
246            placement: stored.placement,
247            state: stored.state,
248        })
249    }
250}
251
252/// On-disk form of a [`NamespaceRecord`].
253///
254/// Instants are rendered as RFC 3339 text (matching the package/timer
255/// encodings) so the persisted form is backend-agnostic and human-legible.
256#[derive(Serialize, Deserialize)]
257struct StoredNamespace {
258    name: String,
259    created_at: String,
260    last_seen: String,
261    origin: NamespaceOrigin,
262    kind: Option<String>,
263    /// Additive Phase-2 quota field: an old record encoded before this key
264    /// existed decodes to `None` via `#[serde(default)]`, so the addition is a
265    /// policy flip, not a record-shape migration (CP-Phase-2 §3.3).
266    #[serde(default)]
267    max_in_flight_activities: Option<u32>,
268    placement: NamespacePlacement,
269    state: NamespaceState,
270}
271
272/// Serde tag for the `Unplaced` variant, preserved as a BARE STRING so a record
273/// encoded before `Prefer`/`Pinned` existed decodes byte-identically.
274const PLACEMENT_UNPLACED_TAG: &str = "unplaced";
275/// Serde tag for the soft `Prefer` variant (single-key map `{prefer: [..]}`).
276const PLACEMENT_PREFER_TAG: &str = "prefer";
277/// Serde tag for the hard `Pinned` variant (single-key map `{pinned: [..]}`).
278const PLACEMENT_PINNED_TAG: &str = "pinned";
279
280impl Serialize for NamespacePlacement {
281    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
282    where
283        S: serde::Serializer,
284    {
285        use serde::ser::SerializeMap;
286
287        match self {
288            // Bare string, exactly the pre-Phase-2 encoding (back-compat).
289            Self::Unplaced => serializer.serialize_str(PLACEMENT_UNPLACED_TAG),
290            // Single-key map carrying the deterministically-ordered label set.
291            Self::Prefer { nodes } => {
292                let mut map = serializer.serialize_map(Some(1))?;
293                map.serialize_entry(PLACEMENT_PREFER_TAG, nodes)?;
294                map.end()
295            }
296            Self::Pinned { nodes } => {
297                let mut map = serializer.serialize_map(Some(1))?;
298                map.serialize_entry(PLACEMENT_PINNED_TAG, nodes)?;
299                map.end()
300            }
301        }
302    }
303}
304
305impl<'de> Deserialize<'de> for NamespacePlacement {
306    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
307    where
308        D: serde::Deserializer<'de>,
309    {
310        deserializer.deserialize_any(PlacementVisitor)
311    }
312}
313
314/// Accepts either the bare `"unplaced"` string (the back-compat form) or a
315/// single-key `{prefer|pinned: [labels]}` map for the Phase-2 variants.
316struct PlacementVisitor;
317
318impl<'de> serde::de::Visitor<'de> for PlacementVisitor {
319    type Value = NamespacePlacement;
320
321    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        formatter.write_str("\"unplaced\" or a single-key {prefer|pinned: [labels]} map")
323    }
324
325    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
326    where
327        E: serde::de::Error,
328    {
329        match value {
330            PLACEMENT_UNPLACED_TAG => Ok(NamespacePlacement::Unplaced),
331            other => Err(E::custom(format!("unknown namespace placement: {other}"))),
332        }
333    }
334
335    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
336    where
337        A: serde::de::MapAccess<'de>,
338    {
339        let Some(tag) = map.next_key::<String>()? else {
340            return Err(serde::de::Error::custom(
341                "empty namespace placement map: expected one of {prefer|pinned: [labels]}",
342            ));
343        };
344        let placement = match tag.as_str() {
345            PLACEMENT_PREFER_TAG => NamespacePlacement::Prefer {
346                nodes: map.next_value()?,
347            },
348            PLACEMENT_PINNED_TAG => NamespacePlacement::Pinned {
349                nodes: map.next_value()?,
350            },
351            other => {
352                return Err(serde::de::Error::custom(format!(
353                    "unknown namespace placement: {other}"
354                )));
355            }
356        };
357        if let Some(extra) = map.next_key::<String>()? {
358            return Err(serde::de::Error::custom(format!(
359                "unexpected extra namespace placement key: {extra}"
360            )));
361        }
362        Ok(placement)
363    }
364}
365
366fn encode_instant(instant: DateTime<Utc>) -> String {
367    instant.to_rfc3339_opts(SecondsFormat::Nanos, true)
368}
369
370fn decode_instant(value: &str) -> Result<DateTime<Utc>, StoreError> {
371    DateTime::parse_from_rfc3339(value)
372        .map(|date_time| date_time.with_timezone(&Utc))
373        .map_err(|error| StoreError::Serialization(error.to_string()))
374}
375
376/// Durable persistence contract for the minted-on-use namespace registry.
377///
378/// A sibling to [`crate::PackageStore`], deliberately *not* folded into it: the
379/// registry's durability is stronger (it must survive owner-node death via the
380/// quorum-replicated path, where packages use a plain local write), and its
381/// create-if-absent / value-CAS / reconcile-on-conflict mint semantics have no
382/// analogue in the package store's unconditional `put`. Single-node / local
383/// backends satisfy the contract with a plain local upsert (no quorum to
384/// reach); the haematite backend implements the real quorum-replicated path.
385#[async_trait]
386pub trait NamespaceStore: Send + Sync + 'static {
387    /// Idempotent minted-on-use upsert.
388    ///
389    /// Create-if-absent: if no record exists for `name`, one is minted with
390    /// the given `origin`. If a record already exists, its `last_seen` is
391    /// refreshed (value-CAS touch) and `origin` is left untouched. A concurrent
392    /// racer that wrote an equivalent record first is reconciled as success —
393    /// the mint is idempotent and lock-free.
394    ///
395    /// Returns whether this call CREATED the record (drives the "loud created"
396    /// event) versus touched an existing one.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced because
401    /// this node is not the current owner of the record's shard, or
402    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
403    /// codec failure.
404    async fn register_namespace(
405        &self,
406        name: &str,
407        origin: NamespaceOrigin,
408    ) -> Result<MintOutcome, StoreError>;
409
410    /// Explicit upsert (`POST /namespaces`).
411    ///
412    /// The same idempotent upsert as [`NamespaceStore::register_namespace`],
413    /// but carrying a caller-supplied `record` (typically
414    /// [`NamespaceOrigin::Explicit`] with an initial config). Idempotent on an
415    /// existing name: an already-present record is reconciled as success
416    /// rather than overwritten wholesale.
417    ///
418    /// # Errors
419    ///
420    /// As [`NamespaceStore::register_namespace`].
421    async fn put_namespace(&self, record: NamespaceRecord) -> Result<MintOutcome, StoreError>;
422
423    /// Returns the live durable set, ascending by `created_at` (ties broken by
424    /// `name`).
425    ///
426    /// Backs `GET /namespaces`. The returned set is the raw durable truth;
427    /// grant-filtering happens at the API layer, never here.
428    ///
429    /// # Errors
430    ///
431    /// Returns [`StoreError::Backend`] / [`StoreError::Serialization`] on a
432    /// backend or codec failure.
433    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>, StoreError>;
434
435    /// Looks up a single namespace by `name`.
436    ///
437    /// The existence probe for the `closed` auto-create policy and the
438    /// resolver's existence anchor. Returns `None` for an absent name (never an
439    /// error).
440    ///
441    /// # Errors
442    ///
443    /// Returns [`StoreError::Backend`] / [`StoreError::Serialization`] on a
444    /// backend or codec failure.
445    async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceRecord>, StoreError>;
446
447    /// Idempotent quorum-CAS update of an existing namespace's [`placement`]
448    /// directive (Control-Plane Phase 2, P2-P2).
449    ///
450    /// Read-modify-writes ONLY the [`NamespaceRecord::placement`] field of an
451    /// existing record, leaving `origin`, `created_at`, `config`, and `state`
452    /// untouched; `last_seen` is refreshed as the operation is a fresh reference.
453    /// It is the same value-CAS upsert every registry mutation uses, so a
454    /// concurrent racer that wrote an equivalent placement first is reconciled as
455    /// success (idempotent, lock-free). Setting placement to the value the record
456    /// already holds is a successful no-op.
457    ///
458    /// Returns `Ok(None)` when no record exists for `name` (placement targets an
459    /// already-minted namespace; the caller surfaces a not-found rather than
460    /// minting a row here), or `Ok(Some(()))` when the placement was durably set.
461    ///
462    /// [`placement`]: NamespaceRecord::placement
463    ///
464    /// # Errors
465    ///
466    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced because this
467    /// node is not the current owner of the record's shard, or
468    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
469    /// codec failure.
470    async fn set_namespace_placement(
471        &self,
472        name: &str,
473        placement: NamespacePlacement,
474    ) -> Result<Option<()>, StoreError>;
475
476    /// Transitions a namespace from [`NamespaceState::Active`] to
477    /// [`NamespaceState::Deprecated`] (deprecate-before-delete).
478    ///
479    /// Idempotent: deprecating an already-deprecated namespace, or one with no
480    /// registry row, is a no-op rather than an error. Deprecation never strands
481    /// durable history.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced, or
486    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
487    /// codec failure.
488    async fn deprecate_namespace(&self, name: &str) -> Result<(), StoreError>;
489}
490
491#[cfg(test)]
492mod tests {
493    #![allow(clippy::expect_used)]
494
495    use super::{
496        MintOutcome, NamespaceConfig, NamespaceOrigin, NamespacePlacement, NamespaceRecord,
497        NamespaceState, StoredNamespace,
498    };
499    use chrono::{Duration, TimeZone, Utc};
500    use std::collections::BTreeSet;
501
502    fn node_set(labels: &[&str]) -> BTreeSet<String> {
503        labels.iter().map(|label| (*label).to_owned()).collect()
504    }
505
506    fn fixed_now() -> chrono::DateTime<Utc> {
507        match Utc.with_ymd_and_hms(2026, 6, 30, 12, 0, 0).single() {
508            Some(instant) => instant,
509            None => Utc::now(),
510        }
511    }
512
513    #[test]
514    fn new_minted_sets_created_equal_to_last_seen_and_origin() {
515        let now = fixed_now();
516        let record = NamespaceRecord::new_minted("orders", NamespaceOrigin::WorkerMint, now);
517
518        assert_eq!(record.name, "orders");
519        assert_eq!(record.created_at, now);
520        assert_eq!(record.last_seen, now);
521        assert_eq!(record.created_at, record.last_seen);
522        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
523        assert_eq!(record.state, NamespaceState::Active);
524        assert_eq!(record.config, NamespaceConfig::default());
525        assert_eq!(record.placement, NamespacePlacement::Unplaced);
526        assert_eq!(record.config.kind, None);
527        assert_eq!(record.config.max_in_flight_activities, None);
528    }
529
530    #[test]
531    fn bump_last_seen_advances_only_last_seen() {
532        let now = fixed_now();
533        let mut record = NamespaceRecord::new_minted("orders", NamespaceOrigin::Explicit, now);
534        let later = now + Duration::seconds(42);
535
536        record.bump_last_seen(later);
537
538        assert_eq!(record.last_seen, later);
539        assert_eq!(record.created_at, now);
540        assert_eq!(record.origin, NamespaceOrigin::Explicit);
541        assert_eq!(record.state, NamespaceState::Active);
542    }
543
544    #[test]
545    fn encode_decode_round_trips() {
546        let now = fixed_now();
547        let mut record =
548            NamespaceRecord::new_minted("billing", NamespaceOrigin::InferredFromState, now);
549        record.bump_last_seen(now + Duration::seconds(5));
550        record.state = NamespaceState::Deprecated;
551
552        let bytes = record.encode().expect("encode");
553        let decoded = NamespaceRecord::decode(&bytes).expect("decode");
554
555        assert_eq!(record, decoded);
556    }
557
558    #[test]
559    fn encode_decode_preserves_reserved_kind_discriminator() {
560        let now = fixed_now();
561        let mut record = NamespaceRecord::new_minted("tenant-a", NamespaceOrigin::Explicit, now);
562        record.config.kind = Some("tenant".to_owned());
563
564        let bytes = record.encode().expect("encode");
565        let decoded = NamespaceRecord::decode(&bytes).expect("decode");
566
567        assert_eq!(decoded.config.kind.as_deref(), Some("tenant"));
568        assert_eq!(record, decoded);
569    }
570
571    #[test]
572    fn decode_rejects_malformed_bytes() {
573        let err = NamespaceRecord::decode(b"not json").expect_err("must reject");
574        assert!(matches!(err, crate::StoreError::Serialization(_)));
575    }
576
577    #[test]
578    fn enum_derives_are_copy_clone_eq() {
579        // The trait signatures pass these by value / compare them, so they
580        // must be Clone + Copy + PartialEq + Eq.
581        let outcome = MintOutcome::Created;
582        let copied = outcome;
583        assert_eq!(outcome, copied);
584        assert_eq!(copied, MintOutcome::Created);
585        assert_ne!(MintOutcome::Created, MintOutcome::AlreadyExisted);
586
587        let origin = NamespaceOrigin::WorkerMint;
588        let origin_copy = origin;
589        assert_eq!(origin, origin_copy);
590
591        let state = NamespaceState::Active;
592        let state_copy = state;
593        assert_eq!(state, state_copy);
594        assert_ne!(NamespaceState::Active, NamespaceState::Deprecated);
595    }
596
597    /// Round-trip every placement variant through the full record codec so the
598    /// promoted `Prefer`/`Pinned` arms survive encode → decode unchanged.
599    #[test]
600    fn placement_variants_round_trip_through_record() {
601        let now = fixed_now();
602        for placement in [
603            NamespacePlacement::Unplaced,
604            NamespacePlacement::Prefer {
605                nodes: node_set(&["az-a", "az-b"]),
606            },
607            NamespacePlacement::Pinned {
608                nodes: node_set(&["gpu-pool"]),
609            },
610        ] {
611            let mut record = NamespaceRecord::new_minted("placed", NamespaceOrigin::Explicit, now);
612            record.placement = placement.clone();
613
614            let bytes = record.encode().expect("encode");
615            let decoded = NamespaceRecord::decode(&bytes).expect("decode");
616
617            assert_eq!(decoded.placement, placement);
618            assert_eq!(record, decoded);
619        }
620    }
621
622    /// `Unplaced` must encode as the BARE STRING `"unplaced"` — the exact
623    /// pre-Phase-2 on-disk form — so the promotion is byte-identical for the
624    /// default and the back-compat decode below is a real old-bytes path.
625    #[test]
626    fn unplaced_encodes_as_bare_string_tag() {
627        let placement = NamespacePlacement::Unplaced;
628        let json = serde_json::to_string(&placement).expect("serialize");
629        assert_eq!(json, "\"unplaced\"");
630    }
631
632    /// The Phase-2 struct variants encode as a single-key map keyed by the
633    /// lowercase variant tag, carrying the deterministically-ordered label set.
634    #[test]
635    fn prefer_and_pinned_encode_as_single_key_maps() {
636        let prefer = NamespacePlacement::Prefer {
637            nodes: node_set(&["b", "a"]),
638        };
639        let pinned = NamespacePlacement::Pinned {
640            nodes: node_set(&["only"]),
641        };
642        assert_eq!(
643            serde_json::to_string(&prefer).expect("serialize prefer"),
644            r#"{"prefer":["a","b"]}"#
645        );
646        assert_eq!(
647            serde_json::to_string(&pinned).expect("serialize pinned"),
648            r#"{"pinned":["only"]}"#
649        );
650    }
651
652    /// BACK-COMPAT: a whole record encoded before `Prefer`/`Pinned` and
653    /// `max_in_flight_activities` existed — placement is the bare string
654    /// `"unplaced"` and the quota key is absent — still decodes byte-identically
655    /// to `Unplaced` + `None`.
656    #[test]
657    fn old_unplaced_record_without_quota_field_decodes() {
658        let old = StoredNamespace {
659            name: "legacy".to_owned(),
660            created_at: super::encode_instant(fixed_now()),
661            last_seen: super::encode_instant(fixed_now()),
662            origin: NamespaceOrigin::WorkerMint,
663            kind: None,
664            max_in_flight_activities: None,
665            placement: NamespacePlacement::Unplaced,
666            state: NamespaceState::Active,
667        };
668        // Hand-build the pre-Phase-2 JSON: a bare `"unplaced"` placement string
669        // and NO `max_in_flight_activities` key at all.
670        let old_json = format!(
671            r#"{{"name":"legacy","created_at":"{}","last_seen":"{}","origin":"WorkerMint","kind":null,"placement":"unplaced","state":"Active"}}"#,
672            old.created_at, old.last_seen
673        );
674
675        let decoded = NamespaceRecord::decode(old_json.as_bytes()).expect("decode old bytes");
676
677        assert_eq!(decoded.placement, NamespacePlacement::Unplaced);
678        assert_eq!(decoded.config.max_in_flight_activities, None);
679        assert_eq!(decoded.config.kind, None);
680        assert_eq!(decoded.name, "legacy");
681        assert_eq!(decoded.origin, NamespaceOrigin::WorkerMint);
682        assert_eq!(decoded.state, NamespaceState::Active);
683    }
684
685    /// A record carrying an explicit `max_in_flight_activities` quota round-trips,
686    /// and a record with the field `None` round-trips too (the additive default).
687    #[test]
688    fn max_in_flight_activities_round_trips_present_and_absent() {
689        let now = fixed_now();
690
691        let mut with_quota = NamespaceRecord::new_minted("capped", NamespaceOrigin::Explicit, now);
692        with_quota.config.max_in_flight_activities = Some(256);
693        let decoded =
694            NamespaceRecord::decode(&with_quota.encode().expect("encode")).expect("decode");
695        assert_eq!(decoded.config.max_in_flight_activities, Some(256));
696        assert_eq!(with_quota, decoded);
697
698        let without = NamespaceRecord::new_minted("uncapped", NamespaceOrigin::Explicit, now);
699        let decoded = NamespaceRecord::decode(&without.encode().expect("encode")).expect("decode");
700        assert_eq!(decoded.config.max_in_flight_activities, None);
701        assert_eq!(without, decoded);
702    }
703
704    /// An unknown placement tag (string or map key) is a loud decode error, not a
705    /// silent fallback to `Unplaced`.
706    #[test]
707    fn unknown_placement_tag_is_rejected() {
708        let bad_string: Result<NamespacePlacement, _> = serde_json::from_str("\"elsewhere\"");
709        assert!(bad_string.is_err());
710        let bad_map: Result<NamespacePlacement, _> = serde_json::from_str(r#"{"banish":["x"]}"#);
711        assert!(bad_map.is_err());
712    }
713}