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 / libSQL 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. This slice (P2-P1) is **storage only** —
137/// nothing reads `Prefer`/`Pinned` yet; the dispatch-time two-tier spill (P2-P3)
138/// and admission gate (P2-I1) consult them in later slices.
139#[derive(Clone, Debug, Default, PartialEq, Eq)]
140pub enum NamespacePlacement {
141    /// No placement directive — the namespace's records scatter across shards
142    /// by name-hash like all other durable state, and its activities dispatch
143    /// to any live worker (today's behaviour).
144    #[default]
145    Unplaced,
146    /// SOFT placement: prefer workers whose advertised `node` is in `nodes`,
147    /// spilling to any live worker when none of the preferred labels are live.
148    Prefer {
149        /// The preferred free-form node-label set.
150        nodes: BTreeSet<String>,
151    },
152    /// HARD placement: require a worker whose advertised `node` is in `nodes`,
153    /// waiting when none are live (opt-in tenant isolation).
154    Pinned {
155        /// The required free-form node-label set.
156        nodes: BTreeSet<String>,
157    },
158}
159
160impl NamespaceRecord {
161    /// Builds a freshly minted record for `name`.
162    ///
163    /// `created_at` and `last_seen` are both set to `now` (a brand-new
164    /// namespace has been seen exactly once, at creation), `state` is
165    /// [`NamespaceState::Active`], and `config`/`placement` take their
166    /// reserved Phase-1 defaults.
167    #[must_use]
168    pub fn new_minted(name: &str, origin: NamespaceOrigin, now: DateTime<Utc>) -> Self {
169        Self {
170            name: name.to_owned(),
171            created_at: now,
172            last_seen: now,
173            origin,
174            config: NamespaceConfig::default(),
175            placement: NamespacePlacement::default(),
176            state: NamespaceState::Active,
177        }
178    }
179
180    /// Advances `last_seen` to `now`, leaving every other field untouched.
181    ///
182    /// Used by the idempotent mint-touch path: re-referencing an existing
183    /// namespace refreshes its staleness signal without altering existence,
184    /// origin, or lifecycle state. `now` is applied unconditionally — callers
185    /// supply a monotonic clock.
186    pub fn bump_last_seen(&mut self, now: DateTime<Utc>) {
187        self.last_seen = now;
188    }
189
190    /// Encodes the record to opaque bytes for store persistence.
191    ///
192    /// Mirrors the package codec: `serde_json` over a stable on-disk form with
193    /// instants rendered as RFC 3339 text. The store backend never parses the
194    /// result beyond [`NamespaceRecord::decode`].
195    ///
196    /// # Errors
197    ///
198    /// Returns [`StoreError::Serialization`] if the record cannot be encoded.
199    pub fn encode(&self) -> Result<Vec<u8>, StoreError> {
200        let stored = StoredNamespace {
201            name: self.name.clone(),
202            created_at: encode_instant(self.created_at),
203            last_seen: encode_instant(self.last_seen),
204            origin: self.origin,
205            kind: self.config.kind.clone(),
206            max_in_flight_activities: self.config.max_in_flight_activities,
207            placement: self.placement.clone(),
208            state: self.state,
209        };
210        serde_json::to_vec(&stored).map_err(|error| StoreError::Serialization(error.to_string()))
211    }
212
213    /// Decodes a record previously produced by [`NamespaceRecord::encode`].
214    ///
215    /// # Errors
216    ///
217    /// Returns [`StoreError::Serialization`] if `bytes` is not a valid encoded
218    /// record (malformed JSON or an unparseable instant).
219    pub fn decode(bytes: &[u8]) -> Result<Self, StoreError> {
220        let stored: StoredNamespace = serde_json::from_slice(bytes)
221            .map_err(|error| StoreError::Serialization(error.to_string()))?;
222        Ok(Self {
223            name: stored.name,
224            created_at: decode_instant(&stored.created_at)?,
225            last_seen: decode_instant(&stored.last_seen)?,
226            origin: stored.origin,
227            config: NamespaceConfig {
228                kind: stored.kind,
229                max_in_flight_activities: stored.max_in_flight_activities,
230            },
231            placement: stored.placement,
232            state: stored.state,
233        })
234    }
235}
236
237/// On-disk form of a [`NamespaceRecord`].
238///
239/// Instants are rendered as RFC 3339 text (matching the package/timer
240/// encodings) so the persisted form is backend-agnostic and human-legible.
241#[derive(Serialize, Deserialize)]
242struct StoredNamespace {
243    name: String,
244    created_at: String,
245    last_seen: String,
246    origin: NamespaceOrigin,
247    kind: Option<String>,
248    /// Additive Phase-2 quota field: an old record encoded before this key
249    /// existed decodes to `None` via `#[serde(default)]`, so the addition is a
250    /// policy flip, not a record-shape migration (CP-Phase-2 §3.3).
251    #[serde(default)]
252    max_in_flight_activities: Option<u32>,
253    placement: NamespacePlacement,
254    state: NamespaceState,
255}
256
257/// Serde tag for the `Unplaced` variant, preserved as a BARE STRING so a record
258/// encoded before `Prefer`/`Pinned` existed decodes byte-identically.
259const PLACEMENT_UNPLACED_TAG: &str = "unplaced";
260/// Serde tag for the soft `Prefer` variant (single-key map `{prefer: [..]}`).
261const PLACEMENT_PREFER_TAG: &str = "prefer";
262/// Serde tag for the hard `Pinned` variant (single-key map `{pinned: [..]}`).
263const PLACEMENT_PINNED_TAG: &str = "pinned";
264
265impl Serialize for NamespacePlacement {
266    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
267    where
268        S: serde::Serializer,
269    {
270        use serde::ser::SerializeMap;
271
272        match self {
273            // Bare string, exactly the pre-Phase-2 encoding (back-compat).
274            Self::Unplaced => serializer.serialize_str(PLACEMENT_UNPLACED_TAG),
275            // Single-key map carrying the deterministically-ordered label set.
276            Self::Prefer { nodes } => {
277                let mut map = serializer.serialize_map(Some(1))?;
278                map.serialize_entry(PLACEMENT_PREFER_TAG, nodes)?;
279                map.end()
280            }
281            Self::Pinned { nodes } => {
282                let mut map = serializer.serialize_map(Some(1))?;
283                map.serialize_entry(PLACEMENT_PINNED_TAG, nodes)?;
284                map.end()
285            }
286        }
287    }
288}
289
290impl<'de> Deserialize<'de> for NamespacePlacement {
291    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292    where
293        D: serde::Deserializer<'de>,
294    {
295        deserializer.deserialize_any(PlacementVisitor)
296    }
297}
298
299/// Accepts either the bare `"unplaced"` string (the back-compat form) or a
300/// single-key `{prefer|pinned: [labels]}` map for the Phase-2 variants.
301struct PlacementVisitor;
302
303impl<'de> serde::de::Visitor<'de> for PlacementVisitor {
304    type Value = NamespacePlacement;
305
306    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        formatter.write_str("\"unplaced\" or a single-key {prefer|pinned: [labels]} map")
308    }
309
310    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
311    where
312        E: serde::de::Error,
313    {
314        match value {
315            PLACEMENT_UNPLACED_TAG => Ok(NamespacePlacement::Unplaced),
316            other => Err(E::custom(format!("unknown namespace placement: {other}"))),
317        }
318    }
319
320    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
321    where
322        A: serde::de::MapAccess<'de>,
323    {
324        let Some(tag) = map.next_key::<String>()? else {
325            return Err(serde::de::Error::custom(
326                "empty namespace placement map: expected one of {prefer|pinned: [labels]}",
327            ));
328        };
329        let placement = match tag.as_str() {
330            PLACEMENT_PREFER_TAG => NamespacePlacement::Prefer {
331                nodes: map.next_value()?,
332            },
333            PLACEMENT_PINNED_TAG => NamespacePlacement::Pinned {
334                nodes: map.next_value()?,
335            },
336            other => {
337                return Err(serde::de::Error::custom(format!(
338                    "unknown namespace placement: {other}"
339                )));
340            }
341        };
342        if let Some(extra) = map.next_key::<String>()? {
343            return Err(serde::de::Error::custom(format!(
344                "unexpected extra namespace placement key: {extra}"
345            )));
346        }
347        Ok(placement)
348    }
349}
350
351fn encode_instant(instant: DateTime<Utc>) -> String {
352    instant.to_rfc3339_opts(SecondsFormat::Nanos, true)
353}
354
355fn decode_instant(value: &str) -> Result<DateTime<Utc>, StoreError> {
356    DateTime::parse_from_rfc3339(value)
357        .map(|date_time| date_time.with_timezone(&Utc))
358        .map_err(|error| StoreError::Serialization(error.to_string()))
359}
360
361/// Durable persistence contract for the minted-on-use namespace registry.
362///
363/// A sibling to [`crate::PackageStore`], deliberately *not* folded into it: the
364/// registry's durability is stronger (it must survive owner-node death via the
365/// quorum-replicated path, where packages use a plain local write), and its
366/// create-if-absent / value-CAS / reconcile-on-conflict mint semantics have no
367/// analogue in the package store's unconditional `put`. Single-node / local
368/// backends satisfy the contract with a plain local upsert (no quorum to
369/// reach); the haematite backend implements the real quorum-replicated path.
370#[async_trait]
371pub trait NamespaceStore: Send + Sync + 'static {
372    /// Idempotent minted-on-use upsert.
373    ///
374    /// Create-if-absent: if no record exists for `name`, one is minted with
375    /// the given `origin`. If a record already exists, its `last_seen` is
376    /// refreshed (value-CAS touch) and `origin` is left untouched. A concurrent
377    /// racer that wrote an equivalent record first is reconciled as success —
378    /// the mint is idempotent and lock-free.
379    ///
380    /// Returns whether this call CREATED the record (drives the "loud created"
381    /// event) versus touched an existing one.
382    ///
383    /// # Errors
384    ///
385    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced because
386    /// this node is not the current owner of the record's shard, or
387    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
388    /// codec failure.
389    async fn register_namespace(
390        &self,
391        name: &str,
392        origin: NamespaceOrigin,
393    ) -> Result<MintOutcome, StoreError>;
394
395    /// Explicit upsert (`POST /namespaces`).
396    ///
397    /// The same idempotent upsert as [`NamespaceStore::register_namespace`],
398    /// but carrying a caller-supplied `record` (typically
399    /// [`NamespaceOrigin::Explicit`] with an initial config). Idempotent on an
400    /// existing name: an already-present record is reconciled as success
401    /// rather than overwritten wholesale.
402    ///
403    /// # Errors
404    ///
405    /// As [`NamespaceStore::register_namespace`].
406    async fn put_namespace(&self, record: NamespaceRecord) -> Result<MintOutcome, StoreError>;
407
408    /// Returns the live durable set, ascending by `created_at` (ties broken by
409    /// `name`).
410    ///
411    /// Backs `GET /namespaces`. The returned set is the raw durable truth;
412    /// grant-filtering happens at the API layer, never here.
413    ///
414    /// # Errors
415    ///
416    /// Returns [`StoreError::Backend`] / [`StoreError::Serialization`] on a
417    /// backend or codec failure.
418    async fn list_namespaces(&self) -> Result<Vec<NamespaceRecord>, StoreError>;
419
420    /// Looks up a single namespace by `name`.
421    ///
422    /// The existence probe for the `closed` auto-create policy and the
423    /// resolver's existence anchor. Returns `None` for an absent name (never an
424    /// error).
425    ///
426    /// # Errors
427    ///
428    /// Returns [`StoreError::Backend`] / [`StoreError::Serialization`] on a
429    /// backend or codec failure.
430    async fn get_namespace(&self, name: &str) -> Result<Option<NamespaceRecord>, StoreError>;
431
432    /// Idempotent quorum-CAS update of an existing namespace's [`placement`]
433    /// directive (Control-Plane Phase 2, P2-P2).
434    ///
435    /// Read-modify-writes ONLY the [`NamespaceRecord::placement`] field of an
436    /// existing record, leaving `origin`, `created_at`, `config`, and `state`
437    /// untouched; `last_seen` is refreshed as the operation is a fresh reference.
438    /// It is the same value-CAS upsert every registry mutation uses, so a
439    /// concurrent racer that wrote an equivalent placement first is reconciled as
440    /// success (idempotent, lock-free). Setting placement to the value the record
441    /// already holds is a successful no-op.
442    ///
443    /// Returns `Ok(None)` when no record exists for `name` (placement targets an
444    /// already-minted namespace; the caller surfaces a not-found rather than
445    /// minting a row here), or `Ok(Some(()))` when the placement was durably set.
446    ///
447    /// [`placement`]: NamespaceRecord::placement
448    ///
449    /// # Errors
450    ///
451    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced because this
452    /// node is not the current owner of the record's shard, or
453    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
454    /// codec failure.
455    async fn set_namespace_placement(
456        &self,
457        name: &str,
458        placement: NamespacePlacement,
459    ) -> Result<Option<()>, StoreError>;
460
461    /// Transitions a namespace from [`NamespaceState::Active`] to
462    /// [`NamespaceState::Deprecated`] (deprecate-before-delete).
463    ///
464    /// Idempotent: deprecating an already-deprecated namespace, or one with no
465    /// registry row, is a no-op rather than an error. Deprecation never strands
466    /// durable history.
467    ///
468    /// # Errors
469    ///
470    /// Returns [`StoreError::NotOwner`] if a quorum write is fenced, or
471    /// [`StoreError::Backend`] / [`StoreError::Serialization`] on a backend or
472    /// codec failure.
473    async fn deprecate_namespace(&self, name: &str) -> Result<(), StoreError>;
474}
475
476#[cfg(test)]
477mod tests {
478    #![allow(clippy::expect_used)]
479
480    use super::{
481        MintOutcome, NamespaceConfig, NamespaceOrigin, NamespacePlacement, NamespaceRecord,
482        NamespaceState, StoredNamespace,
483    };
484    use chrono::{Duration, TimeZone, Utc};
485    use std::collections::BTreeSet;
486
487    fn node_set(labels: &[&str]) -> BTreeSet<String> {
488        labels.iter().map(|label| (*label).to_owned()).collect()
489    }
490
491    fn fixed_now() -> chrono::DateTime<Utc> {
492        match Utc.with_ymd_and_hms(2026, 6, 30, 12, 0, 0).single() {
493            Some(instant) => instant,
494            None => Utc::now(),
495        }
496    }
497
498    #[test]
499    fn new_minted_sets_created_equal_to_last_seen_and_origin() {
500        let now = fixed_now();
501        let record = NamespaceRecord::new_minted("orders", NamespaceOrigin::WorkerMint, now);
502
503        assert_eq!(record.name, "orders");
504        assert_eq!(record.created_at, now);
505        assert_eq!(record.last_seen, now);
506        assert_eq!(record.created_at, record.last_seen);
507        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
508        assert_eq!(record.state, NamespaceState::Active);
509        assert_eq!(record.config, NamespaceConfig::default());
510        assert_eq!(record.placement, NamespacePlacement::Unplaced);
511        assert_eq!(record.config.kind, None);
512        assert_eq!(record.config.max_in_flight_activities, None);
513    }
514
515    #[test]
516    fn bump_last_seen_advances_only_last_seen() {
517        let now = fixed_now();
518        let mut record = NamespaceRecord::new_minted("orders", NamespaceOrigin::Explicit, now);
519        let later = now + Duration::seconds(42);
520
521        record.bump_last_seen(later);
522
523        assert_eq!(record.last_seen, later);
524        assert_eq!(record.created_at, now);
525        assert_eq!(record.origin, NamespaceOrigin::Explicit);
526        assert_eq!(record.state, NamespaceState::Active);
527    }
528
529    #[test]
530    fn encode_decode_round_trips() {
531        let now = fixed_now();
532        let mut record =
533            NamespaceRecord::new_minted("billing", NamespaceOrigin::InferredFromState, now);
534        record.bump_last_seen(now + Duration::seconds(5));
535        record.state = NamespaceState::Deprecated;
536
537        let bytes = record.encode().expect("encode");
538        let decoded = NamespaceRecord::decode(&bytes).expect("decode");
539
540        assert_eq!(record, decoded);
541    }
542
543    #[test]
544    fn encode_decode_preserves_reserved_kind_discriminator() {
545        let now = fixed_now();
546        let mut record = NamespaceRecord::new_minted("tenant-a", NamespaceOrigin::Explicit, now);
547        record.config.kind = Some("tenant".to_owned());
548
549        let bytes = record.encode().expect("encode");
550        let decoded = NamespaceRecord::decode(&bytes).expect("decode");
551
552        assert_eq!(decoded.config.kind.as_deref(), Some("tenant"));
553        assert_eq!(record, decoded);
554    }
555
556    #[test]
557    fn decode_rejects_malformed_bytes() {
558        let err = NamespaceRecord::decode(b"not json").expect_err("must reject");
559        assert!(matches!(err, crate::StoreError::Serialization(_)));
560    }
561
562    #[test]
563    fn enum_derives_are_copy_clone_eq() {
564        // The trait signatures pass these by value / compare them, so they
565        // must be Clone + Copy + PartialEq + Eq.
566        let outcome = MintOutcome::Created;
567        let copied = outcome;
568        assert_eq!(outcome, copied);
569        assert_eq!(copied, MintOutcome::Created);
570        assert_ne!(MintOutcome::Created, MintOutcome::AlreadyExisted);
571
572        let origin = NamespaceOrigin::WorkerMint;
573        let origin_copy = origin;
574        assert_eq!(origin, origin_copy);
575
576        let state = NamespaceState::Active;
577        let state_copy = state;
578        assert_eq!(state, state_copy);
579        assert_ne!(NamespaceState::Active, NamespaceState::Deprecated);
580    }
581
582    /// Round-trip every placement variant through the full record codec so the
583    /// promoted `Prefer`/`Pinned` arms survive encode → decode unchanged.
584    #[test]
585    fn placement_variants_round_trip_through_record() {
586        let now = fixed_now();
587        for placement in [
588            NamespacePlacement::Unplaced,
589            NamespacePlacement::Prefer {
590                nodes: node_set(&["az-a", "az-b"]),
591            },
592            NamespacePlacement::Pinned {
593                nodes: node_set(&["gpu-pool"]),
594            },
595        ] {
596            let mut record = NamespaceRecord::new_minted("placed", NamespaceOrigin::Explicit, now);
597            record.placement = placement.clone();
598
599            let bytes = record.encode().expect("encode");
600            let decoded = NamespaceRecord::decode(&bytes).expect("decode");
601
602            assert_eq!(decoded.placement, placement);
603            assert_eq!(record, decoded);
604        }
605    }
606
607    /// `Unplaced` must encode as the BARE STRING `"unplaced"` — the exact
608    /// pre-Phase-2 on-disk form — so the promotion is byte-identical for the
609    /// default and the back-compat decode below is a real old-bytes path.
610    #[test]
611    fn unplaced_encodes_as_bare_string_tag() {
612        let placement = NamespacePlacement::Unplaced;
613        let json = serde_json::to_string(&placement).expect("serialize");
614        assert_eq!(json, "\"unplaced\"");
615    }
616
617    /// The Phase-2 struct variants encode as a single-key map keyed by the
618    /// lowercase variant tag, carrying the deterministically-ordered label set.
619    #[test]
620    fn prefer_and_pinned_encode_as_single_key_maps() {
621        let prefer = NamespacePlacement::Prefer {
622            nodes: node_set(&["b", "a"]),
623        };
624        let pinned = NamespacePlacement::Pinned {
625            nodes: node_set(&["only"]),
626        };
627        assert_eq!(
628            serde_json::to_string(&prefer).expect("serialize prefer"),
629            r#"{"prefer":["a","b"]}"#
630        );
631        assert_eq!(
632            serde_json::to_string(&pinned).expect("serialize pinned"),
633            r#"{"pinned":["only"]}"#
634        );
635    }
636
637    /// BACK-COMPAT: a whole record encoded before `Prefer`/`Pinned` and
638    /// `max_in_flight_activities` existed — placement is the bare string
639    /// `"unplaced"` and the quota key is absent — still decodes byte-identically
640    /// to `Unplaced` + `None`.
641    #[test]
642    fn old_unplaced_record_without_quota_field_decodes() {
643        let old = StoredNamespace {
644            name: "legacy".to_owned(),
645            created_at: super::encode_instant(fixed_now()),
646            last_seen: super::encode_instant(fixed_now()),
647            origin: NamespaceOrigin::WorkerMint,
648            kind: None,
649            max_in_flight_activities: None,
650            placement: NamespacePlacement::Unplaced,
651            state: NamespaceState::Active,
652        };
653        // Hand-build the pre-Phase-2 JSON: a bare `"unplaced"` placement string
654        // and NO `max_in_flight_activities` key at all.
655        let old_json = format!(
656            r#"{{"name":"legacy","created_at":"{}","last_seen":"{}","origin":"WorkerMint","kind":null,"placement":"unplaced","state":"Active"}}"#,
657            old.created_at, old.last_seen
658        );
659
660        let decoded = NamespaceRecord::decode(old_json.as_bytes()).expect("decode old bytes");
661
662        assert_eq!(decoded.placement, NamespacePlacement::Unplaced);
663        assert_eq!(decoded.config.max_in_flight_activities, None);
664        assert_eq!(decoded.config.kind, None);
665        assert_eq!(decoded.name, "legacy");
666        assert_eq!(decoded.origin, NamespaceOrigin::WorkerMint);
667        assert_eq!(decoded.state, NamespaceState::Active);
668    }
669
670    /// A record carrying an explicit `max_in_flight_activities` quota round-trips,
671    /// and a record with the field `None` round-trips too (the additive default).
672    #[test]
673    fn max_in_flight_activities_round_trips_present_and_absent() {
674        let now = fixed_now();
675
676        let mut with_quota = NamespaceRecord::new_minted("capped", NamespaceOrigin::Explicit, now);
677        with_quota.config.max_in_flight_activities = Some(256);
678        let decoded =
679            NamespaceRecord::decode(&with_quota.encode().expect("encode")).expect("decode");
680        assert_eq!(decoded.config.max_in_flight_activities, Some(256));
681        assert_eq!(with_quota, decoded);
682
683        let without = NamespaceRecord::new_minted("uncapped", NamespaceOrigin::Explicit, now);
684        let decoded = NamespaceRecord::decode(&without.encode().expect("encode")).expect("decode");
685        assert_eq!(decoded.config.max_in_flight_activities, None);
686        assert_eq!(without, decoded);
687    }
688
689    /// An unknown placement tag (string or map key) is a loud decode error, not a
690    /// silent fallback to `Unplaced`.
691    #[test]
692    fn unknown_placement_tag_is_rejected() {
693        let bad_string: Result<NamespacePlacement, _> = serde_json::from_str("\"elsewhere\"");
694        assert!(bad_string.is_err());
695        let bad_map: Result<NamespacePlacement, _> = serde_json::from_str(r#"{"banish":["x"]}"#);
696        assert!(bad_map.is_err());
697    }
698}