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}