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}