Skip to main content

cellos_server/
state.rs

1//! Shared application state for the HTTP control plane.
2//!
3//! `AppState` is `Clone`-able (all inner handles are `Arc`/optional) and is
4//! threaded through axum handlers via `with_state`. The in-memory registry
5//! is a *projection cache* over JetStream — it is intentionally not the
6//! source of truth (CHATROOM.md Session 16). On startup a future version
7//! will replay `cellos.events.>` to rebuild this map; for now it is
8//! populated by writes from POST /v1/formations.
9
10use std::collections::BTreeMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14use async_nats::jetstream::context::Context as JsContext;
15use async_nats::Client as NatsClient;
16use cellos_core::{OperatorId, Principal};
17use serde::{Deserialize, Serialize};
18use tokio::sync::RwLock;
19use tracing::{debug, warn};
20use uuid::Uuid;
21
22/// Sentinel operator id used when `CELLOS_SERVER_OPERATOR_ID` is unset.
23///
24/// ADR-0019 §Decision pins the doctrine: "in v0.5 producers this is whatever
25/// the bearer-token subject claim resolves to". The current server has a
26/// single shared bearer token and no IdP-issued subject claim, so every
27/// signed event traces back to the same logical operator. We pick a stable,
28/// human-readable sentinel so audit consumers see a constant principal URI
29/// across restarts — silently rotating it (e.g. to a fresh UUID per boot)
30/// would break audit-chain replay and downstream subscriptions joining on
31/// the URI. The fix lands when the IdP/OIDC integration ADR ships; until
32/// then, the sentinel IS the operator and admission events attribute to it.
33pub const DEFAULT_OPERATOR_ID: &str = "bearer-token-operator";
34
35/// Required bearer token, validated against `Authorization: Bearer <token>`.
36/// When `None` the server refuses to start (see `main.rs`).
37pub type ApiToken = Arc<String>;
38
39#[derive(Clone)]
40pub struct AppState {
41    /// NATS connection used both by the WebSocket bridge and by future
42    /// projection replay. `Option` because tests can drive the router
43    /// without a live broker.
44    pub nats: Option<NatsClient>,
45    /// JetStream context (ADR-0011, ADR-0015). Populated alongside
46    /// `nats` once the server confirms the `CELLOS_EVENTS` stream
47    /// exists. `Option` so router tests without a live broker still
48    /// build the state cleanly; the WS bridge falls through to a
49    /// "broker not configured" close if this is `None`.
50    pub jetstream: Option<JsContext>,
51    /// In-memory projection of formations. UUID-keyed for stable lookup.
52    pub formations: Arc<RwLock<BTreeMap<Uuid, FormationRecord>>>,
53    /// In-memory projection of cells.
54    pub cells: Arc<RwLock<BTreeMap<String, CellRecord>>>,
55    /// Bearer token required on every non-public route.
56    pub api_token: ApiToken,
57    /// Operator identity attributed to every signed CloudEvent this server
58    /// emits (ADR-0019 §Decision, §Implementation roadmap step 2).
59    ///
60    /// Resolved from `CELLOS_SERVER_OPERATOR_ID` at startup; falls back to
61    /// [`DEFAULT_OPERATOR_ID`]. Wrapped in `Arc` so the `Clone`-able
62    /// `AppState` does not pay a string allocation on every handler call.
63    /// `cellos-core` exposes `OperatorId` as a `Clone + Hash` newtype, but
64    /// we still want to share one heap allocation across the many
65    /// `Principal::Operator { id: state.operator_id.as_ref().clone() }`
66    /// construction sites; `Arc<OperatorId>` is the cheapest honest shape.
67    pub operator_id: Arc<OperatorId>,
68    /// Highest JetStream stream-sequence the projection has applied
69    /// (ADR-0015 §D2). The snapshot endpoint reports this as `cursor`
70    /// so clients can open `/ws/events?since=<cursor>` and know they
71    /// will not miss any event after the snapshot.
72    ///
73    /// The WebSocket bridge bumps this monotonically as it forwards
74    /// frames (see `ws.rs`). Until the bridge is migrated to a real
75    /// JetStream consumer, this counter is per-process and resets on
76    /// restart — that is acceptable for the MVP because every browser
77    /// reconnect repeats the snapshot fetch.
78    pub applied_cursor: Arc<AtomicU64>,
79}
80
81impl AppState {
82    /// Constructor used by both `main.rs` and the test harness.
83    ///
84    /// The operator identity defaults to [`DEFAULT_OPERATOR_ID`]; callers
85    /// that need a different identity (production main, tests covering
86    /// principal attribution) chain [`AppState::with_operator`]. Keeping
87    /// the constructor signature stable preserves every existing test that
88    /// builds `AppState::new(None, "test")` — the principal migration
89    /// (ADR-0019 step 2) ships without churning unrelated test setups.
90    pub fn new(nats: Option<NatsClient>, api_token: impl Into<String>) -> Self {
91        Self {
92            nats,
93            jetstream: None,
94            formations: Arc::new(RwLock::new(BTreeMap::new())),
95            cells: Arc::new(RwLock::new(BTreeMap::new())),
96            api_token: Arc::new(api_token.into()),
97            applied_cursor: Arc::new(AtomicU64::new(0)),
98            operator_id: Arc::new(OperatorId(DEFAULT_OPERATOR_ID.to_owned())),
99        }
100    }
101
102    /// Override the operator identity used to attribute admission-emitted
103    /// CloudEvents. ADR-0019 §Implementation roadmap step 2: every signed
104    /// event MUST declare its principal explicitly; this is where the
105    /// server-side `Principal::Operator` is anchored.
106    pub fn with_operator(mut self, operator_id: impl Into<String>) -> Self {
107        self.operator_id = Arc::new(OperatorId(operator_id.into()));
108        self
109    }
110
111    /// Build the [`Principal`] every server-emitted signed CloudEvent
112    /// attributes to. Pre-ADR-0019-step-3 we only emit
113    /// [`Principal::Operator`]; the structured variant is preserved so a
114    /// later wave can swap in [`Principal::Delegate`] for MCP-bridge
115    /// admission without churning every emission site again.
116    pub fn principal(&self) -> Principal {
117        Principal::Operator {
118            id: (*self.operator_id).clone(),
119        }
120    }
121
122    /// Attach a JetStream context. Called from `main.rs` after
123    /// `ensure_stream` succeeds. Returns the same `AppState` for
124    /// builder-style chaining in tests.
125    pub fn with_jetstream(mut self, ctx: JsContext) -> Self {
126        self.jetstream = Some(ctx);
127        self
128    }
129
130    /// Current snapshot cursor — the highest seq the server has applied.
131    ///
132    /// Red-team wave 2 (LOW-W2A-1): `Acquire` is sufficient here. We need
133    /// to see writes that happen-before any successful `bump_cursor`
134    /// (i.e. the projection-apply write inside `apply_event_payload`);
135    /// the previous `SeqCst` enforced a global total order with every
136    /// other `SeqCst` operation in the process (counter `fetch_add`s in
137    /// `sni_proxy`, etc.) for no extra correctness gain on this read.
138    pub fn cursor(&self) -> u64 {
139        self.applied_cursor.load(Ordering::Acquire)
140    }
141
142    /// Bump the snapshot cursor to at least `seq`. Monotonic; out-of-order
143    /// values are ignored. Used by the WebSocket bridge as it forwards
144    /// frames.
145    ///
146    /// Red-team wave 2 (LOW-W2A-1): downgraded from `SeqCst` to
147    /// `AcqRel`/`Acquire`. The CAS-loop structure (re-load on conflict,
148    /// strict `seq > current` gate) guarantees monotonicity independent
149    /// of the memory ordering chosen. `AcqRel` on success publishes the
150    /// new cursor to every subsequent `cursor()` reader (snapshot GET
151    /// handlers, primarily); `Acquire` on the reload picks up the
152    /// observed value from the winner of the conflict.
153    pub fn bump_cursor(&self, seq: u64) {
154        // CAS loop keeps the cursor monotonic across concurrent ws workers.
155        let mut current = self.applied_cursor.load(Ordering::Acquire);
156        while seq > current {
157            match self.applied_cursor.compare_exchange(
158                current,
159                seq,
160                Ordering::AcqRel,
161                Ordering::Acquire,
162            ) {
163                Ok(_) => break,
164                Err(observed) => current = observed,
165            }
166        }
167    }
168
169    /// Apply a single CloudEvent payload (raw JSON bytes from
170    /// JetStream) to the in-memory projection.
171    ///
172    /// This is shared between the boot-time replay path
173    /// (`jetstream::replay_projection`) and the live WebSocket bridge
174    /// (`ws.rs`). Centralising the apply logic guarantees that what
175    /// the server reconstructs on startup is exactly what it
176    /// applies live — there is no "replay-only" code path that could
177    /// drift from the live one.
178    ///
179    /// CloudEvent `type` discriminates the transition. Two event families
180    /// mutate state today:
181    ///
182    /// - `formation.v1.*` — server-emitted formation lifecycle, drives the
183    ///   `formations` projection (ADR-0010 admission gate output).
184    /// - `cell.lifecycle.v1.*` and `cell.command.v1.completed` —
185    ///   supervisor-emitted cell lifecycle (ARCH-001). Without this, the
186    ///   server replays cell events from JetStream but never updates the
187    ///   `cells` map and `GET /v1/cells` returns `[]`. The cell projector
188    ///   landed alongside the formation projector so `cellctl get cells`
189    ///   reflects what the supervisor actually ran.
190    ///
191    /// Unknown event types still advance the JetStream cursor at the
192    /// caller (the apply contract is independent of whether the
193    /// projection mutated), so audit gaps surface as `ApplyOutcome::Ignored`
194    /// in the replay log rather than silent drops.
195    pub async fn apply_event_payload(&self, payload: &[u8]) -> anyhow::Result<ApplyOutcome> {
196        let event: serde_json::Value = serde_json::from_slice(payload)
197            .map_err(|e| anyhow::anyhow!("event payload not JSON: {e}"))?;
198
199        let ce_type = event
200            .get("type")
201            .and_then(|v| v.as_str())
202            .unwrap_or_default()
203            .to_string();
204
205        // ── Cell projector (ARCH-001) ────────────────────────────────────
206        // The supervisor emits three cell-shaped event types we project on.
207        // Match these BEFORE the formation prefix gate; otherwise an
208        // unrelated change to the formation gate (e.g. a future
209        // canonical-prefix tweak) could swallow cell events.
210        if let Some(outcome) = self.apply_cell_event(&ce_type, &event).await? {
211            return Ok(outcome);
212        }
213
214        // Wave 2 red-team CRITICAL fix (CRIT-W2D-1): the canonical event-type
215        // URN family emitted by `cellos-core::events::cloud_event_v1_formation_*`
216        // is `dev.cellos.events.cell.formation.v1.*`. The legacy
217        // `io.cellos.formation.v1.*` prefix this reader used to require is
218        // not emitted by any production code path today — replayed formations
219        // would bail at this gate and the in-memory `status` projection would
220        // freeze at PENDING forever. We accept BOTH so older event archives
221        // still replay cleanly; new emitters MUST use the canonical family
222        // enumerated in `cellos-core/src/events.rs` and audited by
223        // `docs/audit-log-retention.md` (FC-74).
224        const CANONICAL_FORMATION_PREFIX: &str = "dev.cellos.events.cell.formation.v1.";
225        const LEGACY_FORMATION_PREFIX: &str = "io.cellos.formation.v1.";
226        let phase = if let Some(p) = ce_type.strip_prefix(CANONICAL_FORMATION_PREFIX) {
227            p
228        } else if let Some(p) = ce_type.strip_prefix(LEGACY_FORMATION_PREFIX) {
229            p
230        } else {
231            // Unknown event family currently no-ops the projection. The
232            // cursor still advances at the caller because the apply
233            // contract is independent of whether any state changed.
234            debug!(
235                ce_type,
236                "apply_event_payload: not a formation or cell event; ignored"
237            );
238            return Ok(ApplyOutcome::Ignored);
239        };
240
241        // The supervisor packs the formation id into the CloudEvent's
242        // `data` payload. Wave-2 fix (CRIT-W2D-1): the canonical emitter in
243        // `cellos-core::events::formation_data_v1` uses camelCase keys
244        // (`formationId`, `formationName`); the legacy snake_case
245        // (`formation_id`, `name`) shape is accepted for archive replay
246        // only. `subject` is the last-resort fallback when neither field
247        // is populated.
248        let data = event
249            .get("data")
250            .cloned()
251            .unwrap_or(serde_json::Value::Null);
252        let id_str = data
253            .get("formationId")
254            .and_then(|v| v.as_str())
255            .or_else(|| data.get("formation_id").and_then(|v| v.as_str()))
256            .or_else(|| event.get("subject").and_then(|v| v.as_str()))
257            .ok_or_else(|| anyhow::anyhow!("event missing formationId"))?;
258        let id = Uuid::parse_str(id_str)
259            .map_err(|e| anyhow::anyhow!("event formationId not a UUID ({id_str}): {e}"))?;
260
261        let name = data
262            .get("formationName")
263            .and_then(|v| v.as_str())
264            .or_else(|| data.get("name").and_then(|v| v.as_str()))
265            .unwrap_or("")
266            .to_string();
267
268        let status = match phase {
269            "created" => FormationStatus::Pending,
270            "launching" | "running" | "degraded" => FormationStatus::Running,
271            "completed" => FormationStatus::Succeeded,
272            "failed" => FormationStatus::Failed,
273            // `cancelled` has no canonical emitter constructor in
274            // `cellos-core` today (see red-team-findings-A.md); the arm is
275            // retained so a future `cloud_event_v1_formation_cancelled`
276            // lands on a working reader. The legacy prefix can still carry
277            // this phase.
278            "cancelled" => FormationStatus::Cancelled,
279            _ => {
280                debug!(
281                    ce_type,
282                    "apply_event_payload: unknown formation event; ignored"
283                );
284                return Ok(ApplyOutcome::Ignored);
285            }
286        };
287
288        let mut map = self.formations.write().await;
289        let entry = map.entry(id).or_insert_with(|| FormationRecord {
290            id,
291            name: name.clone(),
292            status,
293            document: data.clone(),
294        });
295        if !name.is_empty() {
296            entry.name = name;
297        }
298        entry.status = status;
299        Ok(ApplyOutcome::Applied)
300    }
301
302    /// Apply a single supervisor-emitted cell event to the `cells`
303    /// projection (ARCH-001).
304    ///
305    /// Returns:
306    /// - `Ok(Some(ApplyOutcome::Applied))` if the event matched one of the
307    ///   three supported cell types and the projection mutated.
308    /// - `Ok(Some(ApplyOutcome::Ignored))` if the event matched a cell type
309    ///   but lacked the data we need (e.g. missing `cellId`); the caller
310    ///   still advances the cursor.
311    /// - `Ok(None)` if the event is NOT a cell event — caller falls through
312    ///   to the formation projector.
313    /// - `Err(_)` for payloads that cannot be parsed at all.
314    ///
315    /// Three event types are consumed (see `cellos-core::events` and the
316    /// supervisor's emit sites in `crates/cellos-supervisor/src/supervisor.rs`):
317    /// - `dev.cellos.events.cell.lifecycle.v1.started`
318    /// - `dev.cellos.events.cell.command.v1.completed`
319    /// - `dev.cellos.events.cell.lifecycle.v1.destroyed`
320    ///
321    /// Identity / network / observability / policy cell events are not
322    /// projected here — they're audit-trail events the supervisor emits,
323    /// not cell-state transitions cellctl users care about for `get cells`.
324    async fn apply_cell_event(
325        &self,
326        ce_type: &str,
327        event: &serde_json::Value,
328    ) -> anyhow::Result<Option<ApplyOutcome>> {
329        const STARTED: &str = "dev.cellos.events.cell.lifecycle.v1.started";
330        const COMPLETED: &str = "dev.cellos.events.cell.command.v1.completed";
331        const DESTROYED: &str = "dev.cellos.events.cell.lifecycle.v1.destroyed";
332
333        let phase = match ce_type {
334            STARTED => CellPhase::Started,
335            COMPLETED => CellPhase::CommandCompleted,
336            DESTROYED => CellPhase::Destroyed,
337            _ => return Ok(None),
338        };
339
340        let data = event
341            .get("data")
342            .cloned()
343            .unwrap_or(serde_json::Value::Null);
344        let Some(cell_id) = data.get("cellId").and_then(|v| v.as_str()) else {
345            debug!(
346                ce_type,
347                "apply_cell_event: missing data.cellId; cell event ignored"
348            );
349            return Ok(Some(ApplyOutcome::Ignored));
350        };
351        let cell_id = cell_id.to_string();
352        // `time` lives on the CloudEvent envelope (RFC3339); the supervisor's
353        // `cloud_event()` helper populates it on every emit.
354        let event_time = event
355            .get("time")
356            .and_then(|v| v.as_str())
357            .map(str::to_string);
358        // RT3-MED ARCH-001-B: strict admission on `specId`. The canonical
359        // supervisor emits a non-empty `data.specId` on every cell event
360        // (`cellos-core::events::cell_*`). A cell event with the field
361        // missing or non-string is malformed; projecting it would either
362        // poison the entry's `spec_id` with `""` on first-event-wins or
363        // leave a phantom cell in the projection. Refuse to project it
364        // and surface the gap as a WARN so the audit trail keeps a
365        // record of the skipped event. Note: a cell event whose
366        // `specId` is structurally present and non-empty is accepted
367        // normally; the back-fill below still tolerates events that
368        // happen to omit fields populated on a prior event.
369        let Some(spec_id) = data.get("specId").and_then(|v| v.as_str()) else {
370            warn!(
371                ce_type,
372                cell_id = %cell_id,
373                "apply_cell_event: missing data.specId; cell event skipped (strict admission)"
374            );
375            return Ok(Some(ApplyOutcome::Ignored));
376        };
377        let spec_id = spec_id.to_string();
378        let run_id = data
379            .get("runId")
380            .and_then(|v| v.as_str())
381            .map(str::to_string);
382
383        let mut map = self.cells.write().await;
384        let entry = map.entry(cell_id.clone()).or_insert_with(|| CellRecord {
385            id: cell_id.clone(),
386            spec_id: spec_id.clone(),
387            run_id: run_id.clone(),
388            state: CellState::Pending,
389            status: String::new(),
390            formation_id: None,
391            started_at: None,
392            destroyed_at: None,
393            outcome: None,
394            exit_code: None,
395        });
396
397        // RT3-HIGH-3 (ARCH-003): once a cell is `Destroyed`, no later
398        // event may mutate its state, exit_code, destroyed_at, or
399        // outcome. JetStream replay + the live subscription can
400        // out-of-order a `started` after a `destroyed` (CHATROOM
401        // Session 17 §ordering), and without this guard the projection
402        // regressed Destroyed → Running. Terminal means terminal: log
403        // a WARN so the unexpected delivery surfaces in ops, then drop
404        // the event for projection purposes.
405        if matches!(entry.state, CellState::Destroyed) {
406            warn!(
407                ce_type = match phase {
408                    CellPhase::Started => "dev.cellos.events.cell.lifecycle.v1.started",
409                    CellPhase::CommandCompleted =>
410                        "dev.cellos.events.cell.command.v1.completed",
411                    CellPhase::Destroyed => "dev.cellos.events.cell.lifecycle.v1.destroyed",
412                },
413                cell_id = %cell_id,
414                event_time = event_time.as_deref().unwrap_or(""),
415                "apply_cell_event: post-terminal event ignored; cell already Destroyed \
416                 (likely out-of-order delivery or replay reordering)"
417            );
418            // Skip projection mutation but still tell the caller we
419            // recognised the event family, so cursor advance stays in
420            // lockstep with the on-wire seq.
421            return Ok(Some(ApplyOutcome::Applied));
422        }
423
424        // Fill in fields that arrive on later events but weren't known on
425        // the first event we saw for this cell.
426        if entry.spec_id.is_empty() && !spec_id.is_empty() {
427            entry.spec_id = spec_id;
428        }
429        if entry.run_id.is_none() {
430            entry.run_id = run_id;
431        }
432
433        match phase {
434            CellPhase::Started => {
435                entry.state = CellState::Running;
436                entry.started_at = event_time;
437            }
438            CellPhase::CommandCompleted => {
439                // RT3-MED ARCH-001-A is handled by the generic terminal-state
440                // guard above (post-Destroyed events return Applied without
441                // mutating the projection). Reaching this arm means the cell
442                // is NOT yet Destroyed, so the exit_code assignment is safe.
443                if let Some(code) = data.get("exitCode").and_then(|v| v.as_i64()) {
444                    entry.exit_code = Some(code as i32);
445                }
446            }
447            CellPhase::Destroyed => {
448                entry.state = CellState::Destroyed;
449                entry.destroyed_at = event_time;
450                if let Some(o) = data.get("outcome").and_then(|v| v.as_str()) {
451                    entry.outcome = Some(o.to_string());
452                }
453            }
454        }
455
456        // Mirror the typed state into the legacy `status: String` field so
457        // pre-ARCH-001 consumers of `CellRecord` (and the JSON response
458        // for `GET /v1/cells`) keep a single human-readable label.
459        entry.status = entry.state.as_str().to_string();
460
461        Ok(Some(ApplyOutcome::Applied))
462    }
463}
464
465/// Internal discriminator for the three cell event types the server
466/// projects on. Pulled out so the match in `apply_cell_event` is
467/// exhaustive over a closed set, not stringly-typed.
468#[derive(Debug, Clone, Copy)]
469enum CellPhase {
470    Started,
471    CommandCompleted,
472    Destroyed,
473}
474
475/// Whether `apply_event_payload` changed the projection. Distinguishing
476/// the two lets the replay path log meaningful "applied vs skipped"
477/// counts and lets tests assert that unknown events are tolerated.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub enum ApplyOutcome {
480    /// Event matched a known type and the in-memory projection was
481    /// updated (or created).
482    Applied,
483    /// Event was structurally valid but its type/discriminant is not
484    /// owned by this projection (cell events, future families).
485    Ignored,
486}
487
488/// Lifecycle status of a formation. Mirrors the projector's state machine
489/// (CHATROOM Session 16 §state-machine-table). Only `PENDING` is emitted
490/// directly on POST; all other transitions are driven by CloudEvents
491/// observed on JetStream.
492#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
493#[serde(rename_all = "UPPERCASE")]
494pub enum FormationStatus {
495    Pending,
496    Running,
497    Succeeded,
498    Failed,
499    Cancelled,
500}
501
502/// Projected view of a formation. The full submitted document is retained
503/// so GET /v1/formations/{id} can echo the original spec — clients build
504/// their own state machines from this plus the WebSocket event stream.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct FormationRecord {
507    pub id: Uuid,
508    pub name: String,
509    pub status: FormationStatus,
510    pub document: serde_json::Value,
511}
512
513/// Lifecycle state of an execution cell, derived from the supervisor's
514/// `cell.lifecycle.v1.*` event stream.
515///
516/// State transitions:
517/// - `Pending` (initial; never emitted on the wire — assigned when we
518///   first observe an event for a cell we haven't seen before, in case
519///   we eventually project a pre-spawn event family)
520/// - `Running` ← `cell.lifecycle.v1.started`
521/// - `Destroyed` ← `cell.lifecycle.v1.destroyed`
522///
523/// `cell.command.v1.completed` does NOT itself transition the state — the
524/// run is still alive until `destroyed` arrives. It only updates
525/// `exit_code` on the projected record.
526#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
527#[serde(rename_all = "lowercase")]
528pub enum CellState {
529    Pending,
530    Running,
531    Destroyed,
532}
533
534impl CellState {
535    /// Lowercase wire string, matching the serde `rename_all = "lowercase"`
536    /// representation. Used to populate `CellRecord::status` for the
537    /// human-readable label clients hit via `GET /v1/cells`.
538    pub fn as_str(self) -> &'static str {
539        match self {
540            CellState::Pending => "pending",
541            CellState::Running => "running",
542            CellState::Destroyed => "destroyed",
543        }
544    }
545}
546
547/// Projected view of an execution cell.
548///
549/// Fields are populated as cell events arrive from JetStream
550/// (`cell.lifecycle.v1.started`, `cell.command.v1.completed`,
551/// `cell.lifecycle.v1.destroyed`). The authoritative cell state lives
552/// in the event log; this struct is a query-latency cache rebuilt on
553/// every server restart by `jetstream::replay_projection`.
554///
555/// `status` is kept as a duplicated lowercase view of `state` so pre-
556/// ARCH-001 clients (and the JSON response shape) continue to see a
557/// single human-readable label without a breaking API change.
558///
559/// `formation_id` is reserved for future correlation: today the
560/// supervisor's CloudEvents do not carry a `formationId` field on the
561/// `Correlation` block (see `cellos-core::Correlation`), so this stays
562/// `None` for supervisor-emitted cells until that gap is closed
563/// upstream. The field is preserved so the wire shape doesn't churn
564/// when correlation lands.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct CellRecord {
567    /// `data.cellId` from the supervisor's cell events (the projection key).
568    pub id: String,
569    /// `data.specId` from the lifecycle events — the execution-cell-spec id
570    /// the operator declared (e.g. `"e2e-stub-echo"`).
571    pub spec_id: String,
572    /// Optional supervisor run id (`data.runId`); not always populated on
573    /// the started event so we accept `None`.
574    pub run_id: Option<String>,
575    /// Typed lifecycle state.
576    pub state: CellState,
577    /// Lowercase mirror of `state` for legacy/string consumers.
578    pub status: String,
579    /// Reserved for formation correlation once the supervisor emits a
580    /// `formationId` on `Correlation`. Today the field stays `None` for
581    /// supervisor-emitted cells.
582    pub formation_id: Option<Uuid>,
583    /// CloudEvent `time` of the `cell.lifecycle.v1.started` event
584    /// (RFC3339). `None` until the started event has arrived.
585    pub started_at: Option<String>,
586    /// CloudEvent `time` of the `cell.lifecycle.v1.destroyed` event
587    /// (RFC3339). `None` until the destroyed event has arrived.
588    pub destroyed_at: Option<String>,
589    /// `data.outcome` from the destroyed event (e.g. `"succeeded"`,
590    /// `"failed"`).
591    pub outcome: Option<String>,
592    /// `data.exitCode` from the `cell.command.v1.completed` event when
593    /// the supervisor read an authenticated exit code. Omitted on forced
594    /// terminations (see `cellos-core::LifecycleTerminalState`).
595    pub exit_code: Option<i32>,
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    /// Red-team wave 2 (LOW-W2A-1): `bump_cursor` must remain monotonic
603    /// after the SeqCst→AcqRel downgrade. The CAS-loop structure
604    /// guarantees this property independent of memory-ordering choice;
605    /// this test pins the contract so a future drive-by edit that
606    /// replaces the loop with a naive `store` cannot regress
607    /// silently. We hammer the state from many concurrent tokio tasks
608    /// with interleaved seq values and assert the final cursor equals
609    /// the highest seq ever submitted.
610    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
611    async fn bump_cursor_monotonic_under_concurrent_calls() {
612        let state = AppState::new(None, "test");
613        let workers = 8;
614        let per_worker = 500usize;
615        let mut handles = Vec::with_capacity(workers);
616        for w in 0..workers {
617            let s = state.clone();
618            handles.push(tokio::spawn(async move {
619                // Each worker submits seqs in an interleaved pattern so
620                // out-of-order arrivals are guaranteed across workers.
621                for i in 0..per_worker {
622                    let seq = (i as u64) * (workers as u64) + (w as u64);
623                    s.bump_cursor(seq);
624                }
625            }));
626        }
627        for h in handles {
628            h.await.expect("worker task");
629        }
630        let max_seq = (per_worker as u64 - 1) * (workers as u64) + (workers as u64 - 1);
631        assert_eq!(
632            state.cursor(),
633            max_seq,
634            "cursor must equal max seq across all workers; got {} expected {}",
635            state.cursor(),
636            max_seq,
637        );
638    }
639
640    /// Smaller-but-pointed test: a single stale low seq submitted AFTER
641    /// a higher one must NOT cause regression.
642    #[test]
643    fn bump_cursor_rejects_regression() {
644        // Synchronous variant — runtime is not required since the
645        // method itself is not async.
646        let state = AppState::new(None, "test");
647        state.bump_cursor(100);
648        state.bump_cursor(50); // out-of-order arrival
649        state.bump_cursor(99);
650        assert_eq!(state.cursor(), 100);
651        state.bump_cursor(101);
652        assert_eq!(state.cursor(), 101);
653    }
654
655    /// Build a minimal CloudEvent payload for a cell event with the
656    /// supervisor's canonical `data` shape. Helper for the cell-projector
657    /// guard tests below.
658    fn cell_event(ce_type: &str, cell_id: &str, spec_id: Option<&str>) -> serde_json::Value {
659        let mut data = serde_json::Map::new();
660        data.insert("cellId".to_string(), serde_json::json!(cell_id));
661        if let Some(s) = spec_id {
662            data.insert("specId".to_string(), serde_json::json!(s));
663        }
664        serde_json::json!({
665            "type": ce_type,
666            "time": "2026-05-17T12:00:00Z",
667            "data": serde_json::Value::Object(data),
668        })
669    }
670
671    /// RT3-MED ARCH-001-A: a `cell.command.v1.completed` event arriving
672    /// AFTER `cell.lifecycle.v1.destroyed` for the same cell (replay,
673    /// out-of-order JetStream delivery, late supervisor flush) must NOT
674    /// silently overwrite the projection's exit_code. The destroyed
675    /// event reported a final state — the projection must not contradict
676    /// that report. We assert the post-destruction exit_code stays
677    /// `None` (since destroyed arrived before completed in this
678    /// replay order) and the apply outcome is `Ignored` so the audit
679    /// trail surfaces the refused mutation rather than swallowing it.
680    #[tokio::test]
681    async fn cell_destroyed_then_command_completed_does_not_mutate() {
682        let state = AppState::new(None, "test");
683
684        // 1. Apply started → cell exists in Running state.
685        let started = cell_event(
686            "dev.cellos.events.cell.lifecycle.v1.started",
687            "cell-arch001a",
688            Some("e2e-stub-echo"),
689        );
690        let out = state
691            .apply_event_payload(&serde_json::to_vec(&started).unwrap())
692            .await
693            .expect("started apply");
694        assert_eq!(out, ApplyOutcome::Applied);
695
696        // 2. Apply destroyed → cell terminal.
697        let mut destroyed = cell_event(
698            "dev.cellos.events.cell.lifecycle.v1.destroyed",
699            "cell-arch001a",
700            Some("e2e-stub-echo"),
701        );
702        destroyed["data"]["outcome"] = serde_json::json!("succeeded");
703        let out = state
704            .apply_event_payload(&serde_json::to_vec(&destroyed).unwrap())
705            .await
706            .expect("destroyed apply");
707        assert_eq!(out, ApplyOutcome::Applied);
708
709        // Sanity: cell is now Destroyed, exit_code still None (no
710        // completed event has been applied yet).
711        {
712            let cells = state.cells.read().await;
713            let entry = cells.get("cell-arch001a").expect("cell present");
714            assert_eq!(entry.state, CellState::Destroyed);
715            assert_eq!(entry.exit_code, None);
716        }
717
718        // 3. Apply a LATE completed event with exitCode=42. The
719        //    terminal-state guard MUST refuse to mutate exit_code.
720        let mut completed = cell_event(
721            "dev.cellos.events.cell.command.v1.completed",
722            "cell-arch001a",
723            Some("e2e-stub-echo"),
724        );
725        completed["data"]["exitCode"] = serde_json::json!(42);
726        let out = state
727            .apply_event_payload(&serde_json::to_vec(&completed).unwrap())
728            .await
729            .expect("late completed apply");
730        assert_eq!(
731            out,
732            ApplyOutcome::Applied,
733            "late completed-after-destroyed: generic terminal-state guard (RT3-HIGH-3) recognizes the event family and advances cursor, but refuses mutation"
734        );
735
736        // Final assertion: exit_code unchanged (still None), state
737        // still Destroyed.
738        let cells = state.cells.read().await;
739        let entry = cells.get("cell-arch001a").expect("cell present");
740        assert_eq!(
741            entry.exit_code, None,
742            "exit_code must NOT be mutated after destroyed (terminal-state guard)"
743        );
744        assert_eq!(entry.state, CellState::Destroyed);
745    }
746
747    /// RT3-MED ARCH-001-B: a cell event with `specId` missing from
748    /// `data` is malformed (the supervisor's canonical emitter always
749    /// populates the field). Strict admission: refuse to project the
750    /// event so we never create a phantom cell with `spec_id: ""`,
751    /// and surface the gap as `ApplyOutcome::Ignored` (caller still
752    /// advances the cursor, audit trail records the skip).
753    #[tokio::test]
754    async fn cell_event_missing_spec_id_is_skipped() {
755        let state = AppState::new(None, "test");
756
757        // Started event with NO specId field at all.
758        let started_no_spec = cell_event(
759            "dev.cellos.events.cell.lifecycle.v1.started",
760            "cell-arch001b",
761            None,
762        );
763        let out = state
764            .apply_event_payload(&serde_json::to_vec(&started_no_spec).unwrap())
765            .await
766            .expect("apply must not error on malformed event");
767        assert_eq!(
768            out,
769            ApplyOutcome::Ignored,
770            "missing specId must result in Ignored (strict admission)"
771        );
772
773        // The projection must NOT contain a phantom cell for this id.
774        let cells = state.cells.read().await;
775        assert!(
776            !cells.contains_key("cell-arch001b"),
777            "projection must not gain a phantom cell from an event missing specId"
778        );
779        assert!(
780            cells.is_empty(),
781            "projection must remain empty after a single malformed event"
782        );
783    }
784}