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}