Skip to main content

cellos_server/routes/
formations.rs

1//! `/v1/formations` — CRUD handlers.
2//!
3//! POST validates the submitted `FormationDocument` per ADR-0010
4//! (single coordinator + every non-coordinator member carries
5//! `authorizedBy`). The full DAG/cycle/scope-narrowing admission gate
6//! lives in `cellos-supervisor`; we surface the same RFC 9457
7//! discriminants here so cellctl can render either source uniformly.
8
9use axum::extract::{Path, State};
10use axum::http::{HeaderMap, StatusCode};
11use axum::response::IntoResponse;
12use axum::Json;
13use cellos_core::events::{
14    cloud_event_v1_formation_completed, cloud_event_v1_formation_created,
15    cloud_event_v1_formation_degraded, cloud_event_v1_formation_failed,
16    cloud_event_v1_formation_launching, cloud_event_v1_formation_running,
17};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::auth::require_bearer;
22use crate::error::{AppError, AppErrorKind};
23use crate::state::{AppState, FormationRecord, FormationStatus};
24
25/// Subset of the `formation-v1` document the admission gate cares about.
26/// Additional fields are tolerated and preserved verbatim in
27/// `FormationRecord::document` (via the captured `serde_json::Value`).
28///
29/// **Wire shapes accepted.** Operators may submit either:
30///
31/// 1. **Flat** (server-internal canonical form):
32///    `{ "name": "...", "coordinator": "...", "members": [ { "id": "...", "authorizedBy": "..." } ] }`
33/// 2. **Kubectl-style** (matches `contracts/schemas/formation-v1.schema.json`):
34///    `{ "apiVersion": "cellos.dev/v1", "kind": "Formation",
35///       "metadata": { "name": "..." },
36///       "spec": { "coordinator": "...", "members": [ { "name": "...", "authorizedBy": "..." } ] } }`
37///
38/// `normalize_formation_document` runs first; everything below operates on
39/// the canonical flat shape. See ADR-0010 §Enforcement for why admission
40/// re-runs server-side regardless of client behaviour.
41#[derive(Debug, Deserialize)]
42pub struct FormationDocument {
43    pub name: String,
44    pub coordinator: String,
45    pub members: Vec<FormationMember>,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct FormationMember {
50    pub id: String,
51    /// Required on every non-coordinator member; forbidden on the
52    /// coordinator (ADR-0010 §Enforcement).
53    #[serde(rename = "authorizedBy")]
54    pub authorized_by: Option<String>,
55}
56
57#[derive(Debug, Serialize)]
58pub struct FormationCreated {
59    pub id: Uuid,
60    pub name: String,
61    pub status: FormationStatus,
62}
63
64/// POST /v1/formations — admit a new formation. Returns 201 with the
65/// generated id on success; RFC 9457 problem+json on validation failure.
66pub async fn create_formation(
67    State(state): State<AppState>,
68    headers: HeaderMap,
69    body: axum::body::Bytes,
70) -> Result<impl IntoResponse, AppError> {
71    require_bearer(&headers, &state.api_token)?;
72
73    // Parse the wire payload, then normalize kubectl-style → flat. The
74    // canonical internal form is the flat `{name, coordinator, members:
75    // [{id, authorizedBy}]}` shape; the public schema documents the
76    // kubectl form. `normalize_formation_document` collapses both into
77    // the flat form before any admission validation runs, so existing
78    // ADR-0010 checks below operate on a single shape. We then parse
79    // twice: once into our validated struct, once kept as a generic
80    // Value (already-normalized) so GET echoes a stable internal shape.
81    let raw: serde_json::Value = serde_json::from_slice(&body)?;
82    let normalized = normalize_formation_document(&raw)?;
83    let doc: FormationDocument = serde_json::from_value(normalized.clone())?;
84
85    // FUZZ-HIGH-1: name-validation MUST run before structural admission.
86    // The fuzz wave admitted `""`, `"   "`, and `"a\nb"` as formation
87    // names — names then corrupted by-name lookup (URL routing can't
88    // carry newlines), log lines, and cellctl rendering. Reject hostile
89    // names with the generic `/problems/bad-request` discriminant before
90    // any other check so the operator sees the precise rule violated.
91    validate_formation_name(&doc.name)?;
92
93    validate_formation(&doc)?;
94
95    // FUZZ-HIGH-2: enforce name uniqueness at admission. Without this
96    // check two formations can share a name; `GET /v1/formations/by-name/{name}`
97    // then returns the first match by UUID order and silently hides the
98    // rest. Both lookup and admission must agree that names are unique.
99    //
100    // The duplicate-name check and the insert happen under the SAME
101    // write lock so two concurrent admissions with the same name cannot
102    // both succeed (TOCTOU: check-then-insert under a read lock would
103    // race). The sibling FIX-RT3-HIGH-3 hardens
104    // `delete_formation_by_name` against legacy duplicates that may
105    // already exist in long-lived projections.
106    let id = Uuid::new_v4();
107    let record = FormationRecord {
108        id,
109        name: doc.name.clone(),
110        status: FormationStatus::Pending,
111        // Store the normalized (flat) form so GET, replay projection,
112        // and downstream consumers see one stable shape regardless of
113        // whether the operator submitted kubectl-style or flat-style.
114        document: normalized,
115    };
116
117    {
118        let mut map = state.formations.write().await;
119        if let Some(existing) = map.values().find(|r| r.name == doc.name) {
120            return Err(AppError::new(
121                AppErrorKind::Conflict,
122                format!(
123                    "formation name '{}' already in use by {}",
124                    doc.name, existing.id
125                ),
126            ));
127        }
128        map.insert(id, record);
129    }
130
131    // Emit formation.v1.created so the WebSocket stream and audit log see it.
132    //
133    // EVT-CONTENT-001: the second positional argument is the CloudEvents 1.0
134    // `time` field (RFC3339 timestamp); published 0.5.0 incorrectly passed
135    // the formation UUID here, producing spec-non-compliant envelopes that
136    // failed schema-validating consumers (gateways, audit log, etc.).
137    let cell_count = doc.members.len() as u32;
138    let no_failed: &[String] = &[];
139    let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
140    let event = cloud_event_v1_formation_created(
141        &id.to_string(),
142        &now_rfc3339,
143        &id.to_string(),
144        &doc.name,
145        cell_count,
146        no_failed,
147        None,
148    );
149    let subject = format!("cellos.events.formations.{id}.created");
150    publish_event(&state, &subject, event).await;
151
152    let body = FormationCreated {
153        id,
154        name: doc.name,
155        status: FormationStatus::Pending,
156    };
157    Ok((StatusCode::CREATED, Json(body)))
158}
159
160/// Response shape for `GET /v1/formations` per ADR-0015 §D2. The
161/// `cursor` is the highest JetStream stream-sequence the server's
162/// projection has applied; clients hand it back as
163/// `/ws/events?since=<cursor>` so they can resume the live stream
164/// without missing any event between the snapshot and the WS open.
165#[derive(Debug, Serialize)]
166pub struct FormationsSnapshot {
167    pub formations: Vec<FormationRecord>,
168    pub cursor: u64,
169}
170
171/// GET /v1/formations — list all known formations plus the current
172/// projection cursor (ADR-0015).
173pub async fn list_formations(
174    State(state): State<AppState>,
175    headers: HeaderMap,
176) -> Result<Json<FormationsSnapshot>, AppError> {
177    require_bearer(&headers, &state.api_token)?;
178    let map = state.formations.read().await;
179    Ok(Json(FormationsSnapshot {
180        formations: map.values().cloned().collect(),
181        cursor: state.cursor(),
182    }))
183}
184
185/// GET /v1/formations/{id} — fetch one formation by uuid.
186pub async fn get_formation(
187    State(state): State<AppState>,
188    headers: HeaderMap,
189    Path(id): Path<Uuid>,
190) -> Result<Json<FormationRecord>, AppError> {
191    require_bearer(&headers, &state.api_token)?;
192    let map = state.formations.read().await;
193    map.get(&id)
194        .cloned()
195        .map(Json)
196        .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))
197}
198
199/// GET /v1/formations/by-name/{name} — fetch one formation by name.
200///
201/// CTL-002 (E2E report): `cellctl describe formation <name>` and
202/// `cellctl delete formation <name>` previously sent the name verbatim
203/// to `/v1/formations/{id}` which rejected with `Invalid URL: UUID
204/// parsing failed`. This parallel route lets cellctl address formations
205/// by name without changing the existing UUID extractor on
206/// `/v1/formations/{id}` (no parser ambiguity, one round-trip).
207///
208/// Name uniqueness is NOT currently enforced at admission (see CTL-002
209/// follow-up); when multiple formations share a name this route returns
210/// the first match by UUID order (BTreeMap iteration order). That is a
211/// known limitation tracked separately from CTL-002.
212pub async fn get_formation_by_name(
213    State(state): State<AppState>,
214    headers: HeaderMap,
215    Path(name): Path<String>,
216) -> Result<Json<FormationRecord>, AppError> {
217    require_bearer(&headers, &state.api_token)?;
218    let map = state.formations.read().await;
219    map.values()
220        .find(|r| r.name == name)
221        .cloned()
222        .map(Json)
223        .ok_or_else(|| AppError::not_found(format!("formation '{name}' not found")))
224}
225
226/// DELETE /v1/formations/by-name/{name} — name-addressed counterpart of
227/// [`delete_formation`]. Looks up the formation by name and delegates to
228/// the same cancellation path so both routes emit the same
229/// `formation.v1.failed` event and surface identical projection state.
230pub async fn delete_formation_by_name(
231    State(state): State<AppState>,
232    headers: HeaderMap,
233    Path(name): Path<String>,
234) -> Result<StatusCode, AppError> {
235    require_bearer(&headers, &state.api_token)?;
236    // Resolve name → id under a read lock; release before re-acquiring
237    // the write lock in `delete_formation`. The two-step resolve is
238    // race-tolerant: if the formation is deleted between resolve and
239    // delegate, the second step surfaces the same 404 the UUID-addressed
240    // route would.
241    //
242    // RT3-HIGH-3 (CTL-002-A): admission today does not yet enforce
243    // name-uniqueness across formations (sibling fix-agent stream). When
244    // two formations share a name, picking the BTreeMap-first UUID and
245    // deleting THAT one is a silent wrong-deletion — exactly the
246    // operator-trust failure the red-team flagged for DELETE. Defense
247    // in depth: enumerate all matches; if there is more than one,
248    // refuse and force the operator to disambiguate by UUID. (GET by
249    // name still picks first; that is read-only and low-stakes.)
250    let id = {
251        let map = state.formations.read().await;
252        let matches: Vec<Uuid> = map
253            .values()
254            .filter(|r| r.name == name)
255            .map(|r| r.id)
256            .collect();
257        match matches.len() {
258            0 => return Err(AppError::not_found(format!("formation '{name}' not found"))),
259            1 => matches[0],
260            _ => {
261                // Sort so the detail string is deterministic across
262                // BTreeMap-iteration variants (the test relies on this).
263                let mut ids = matches;
264                ids.sort();
265                let id_list = ids
266                    .iter()
267                    .map(Uuid::to_string)
268                    .collect::<Vec<_>>()
269                    .join(", ");
270                return Err(AppError::new(
271                    AppErrorKind::Conflict,
272                    format!(
273                        "multiple formations share name '{name}': [{id_list}]; \
274                         delete by UUID via /v1/formations/{{id}} to disambiguate"
275                    ),
276                ));
277            }
278        }
279    };
280    delete_formation(State(state), headers, Path(id)).await
281}
282
283/// DELETE /v1/formations/{id} — best-effort cancellation. The actual
284/// teardown is performed asynchronously by the supervisor once the
285/// `formation.cancelled` event lands on JetStream; we only mark the
286/// in-memory projection.
287pub async fn delete_formation(
288    State(state): State<AppState>,
289    headers: HeaderMap,
290    Path(id): Path<Uuid>,
291) -> Result<StatusCode, AppError> {
292    require_bearer(&headers, &state.api_token)?;
293    let mut map = state.formations.write().await;
294    let (name, cell_count) = {
295        let entry = map
296            .get_mut(&id)
297            .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))?;
298        entry.status = FormationStatus::Cancelled;
299        let members = entry
300            .document
301            .get("members")
302            .and_then(|m| m.as_array())
303            .map(|a| a.len() as u32)
304            .unwrap_or(0);
305        (entry.name.clone(), members)
306    };
307    drop(map);
308
309    let no_failed: &[String] = &[];
310    // EVT-CONTENT-001: second arg is the CloudEvents 1.0 `time` field
311    // (RFC3339); published 0.5.0 passed the UUID here. See create_formation.
312    let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
313    let event = cloud_event_v1_formation_failed(
314        &id.to_string(),
315        &now_rfc3339,
316        &id.to_string(),
317        &name,
318        cell_count,
319        no_failed,
320        Some("deleted by operator"),
321    );
322    let subject = format!("cellos.events.formations.{id}.failed");
323    publish_event(&state, &subject, event).await;
324
325    Ok(StatusCode::NO_CONTENT)
326}
327
328/// POST /v1/formations/{id}/status — receive a state-transition notification
329/// from the supervisor or an operator tool. Updates the in-memory projection
330/// and emits the matching `formation.v1.*` CloudEvent to NATS so the
331/// WebSocket stream carries it to connected web-view clients.
332#[derive(Debug, Deserialize)]
333pub struct StatusTransition {
334    pub state: String,
335    pub reason: Option<String>,
336    pub failed_cells: Option<Vec<String>>,
337}
338
339pub async fn update_formation_status(
340    State(state): State<AppState>,
341    headers: HeaderMap,
342    Path(id): Path<Uuid>,
343    Json(body): Json<StatusTransition>,
344) -> Result<StatusCode, AppError> {
345    require_bearer(&headers, &state.api_token)?;
346
347    let (new_status, name, cell_count, failed) = {
348        let mut map = state.formations.write().await;
349        let entry = map
350            .get_mut(&id)
351            .ok_or_else(|| AppError::not_found(format!("formation {id} not found")))?;
352
353        let new_status = match body.state.to_uppercase().as_str() {
354            "RUNNING" | "LAUNCHING" => FormationStatus::Running,
355            "DEGRADED" => FormationStatus::Running, // DEGRADED keeps running
356            "COMPLETED" => FormationStatus::Succeeded,
357            "FAILED" => FormationStatus::Failed,
358            other => {
359                // RFC-9457 §3.1: this is a generic bad-request, not an
360                // ADR-0010 admission-gate rejection. Returning the
361                // `FormationNoCoordinator` discriminant here would
362                // hijack a load-bearing identifier that clients switch
363                // on per ADR-0010 §Enforcement.
364                return Err(AppError::new(
365                    AppErrorKind::BadRequest,
366                    format!("unknown state: {other}"),
367                ));
368            }
369        };
370        entry.status = new_status;
371
372        let members = entry
373            .document
374            .get("members")
375            .and_then(|m| m.as_array())
376            .map(|a| a.len() as u32)
377            .unwrap_or(0);
378        let failed = body.failed_cells.unwrap_or_default();
379        (new_status, entry.name.clone(), members, failed)
380    };
381
382    let sid = id.to_string();
383    let reason = body.reason.as_deref();
384    let empty: &[String] = &[];
385    // EVT-CONTENT-001: the second positional arg to every
386    // `cloud_event_v1_formation_*` constructor is the CloudEvents 1.0 `time`
387    // field (RFC3339); published 0.5.0 incorrectly passed the formation
388    // UUID here. Capture one timestamp per HTTP request so all phases on a
389    // single state transition share an envelope time.
390    let now_rfc3339 = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
391
392    let (event, phase) = match body.state.to_uppercase().as_str() {
393        "LAUNCHING" => (
394            cloud_event_v1_formation_launching(
395                &sid,
396                &now_rfc3339,
397                &sid,
398                &name,
399                cell_count,
400                empty,
401                reason,
402            ),
403            "launching",
404        ),
405        "RUNNING" => (
406            cloud_event_v1_formation_running(
407                &sid,
408                &now_rfc3339,
409                &sid,
410                &name,
411                cell_count,
412                empty,
413                reason,
414            ),
415            "running",
416        ),
417        "DEGRADED" => (
418            cloud_event_v1_formation_degraded(
419                &sid,
420                &now_rfc3339,
421                &sid,
422                &name,
423                cell_count,
424                &failed,
425                reason,
426            ),
427            "degraded",
428        ),
429        "COMPLETED" => (
430            cloud_event_v1_formation_completed(
431                &sid,
432                &now_rfc3339,
433                &sid,
434                &name,
435                cell_count,
436                empty,
437                reason,
438            ),
439            "completed",
440        ),
441        _ => (
442            cloud_event_v1_formation_failed(
443                &sid,
444                &now_rfc3339,
445                &sid,
446                &name,
447                cell_count,
448                &failed,
449                reason,
450            ),
451            "failed",
452        ),
453    };
454
455    let subject = format!("cellos.events.formations.{id}.{phase}");
456    publish_event(&state, &subject, event).await;
457
458    let _ = new_status; // used above
459    Ok(StatusCode::NO_CONTENT)
460}
461
462/// Publish a CloudEvent JSON payload to NATS if a client is connected.
463/// Failures are logged and swallowed — event loss is surfaced via the DLQ
464/// (P3-03) once that crate lands; the HTTP response is never blocked by NATS.
465async fn publish_event(state: &AppState, subject: &str, event: impl serde::Serialize) {
466    let Some(nats) = &state.nats else { return };
467    let payload = match serde_json::to_vec(&event) {
468        Ok(b) => b,
469        Err(e) => {
470            tracing::warn!(subject, error = %e, "failed to serialise formation CloudEvent");
471            return;
472        }
473    };
474    if let Err(e) = nats.publish(subject.to_owned(), payload.into()).await {
475        tracing::warn!(subject, error = %e, "failed to publish formation CloudEvent to NATS");
476    }
477}
478
479/// Detect the wire shape of an incoming formation document and
480/// normalize to the server's canonical flat form.
481///
482/// Two shapes are accepted (CTL-003 / SCHEMA-001 fix):
483///
484/// - **Flat** (canonical, what the server consumed historically):
485///   `{ "name", "coordinator", "members": [ { "id", "authorizedBy"? } ] }`
486/// - **Kubectl-style** (matches `contracts/schemas/formation-v1.schema.json`):
487///   `{ "apiVersion": "cellos.dev/v1", "kind": "Formation",
488///      "metadata": { "name", ... }, "spec": { "coordinator", "members": [...] } }`
489///
490/// Mapping (kubectl → flat):
491///
492/// | kubectl path                       | flat path                |
493/// |------------------------------------|--------------------------|
494/// | `metadata.name`                    | `name`                   |
495/// | `spec.coordinator`                 | `coordinator`            |
496/// | `spec.members[].name`              | `members[].id`           |
497/// | `spec.members[].authorizedBy`      | `members[].authorizedBy` |
498///
499/// **Hybrid documents are rejected.** A document carrying BOTH a
500/// top-level `name`/`coordinator`/`members` AND any of `apiVersion`,
501/// `kind`, `metadata`, `spec` is ambiguous: the operator likely meant
502/// one form but accidentally typed both. We surface a generic
503/// `/problems/bad-request` (RFC 9457 §3.1) listing the conflicting
504/// fields so cellctl can render a precise error.
505///
506/// `apiVersion` and `kind` MUST match the kubectl envelope literals
507/// (`cellos.dev/v1` and `Formation`); other values are rejected.
508///
509/// Non-object roots (arrays, strings, etc.) are passed through
510/// unchanged — the subsequent `serde_json::from_value::<FormationDocument>`
511/// will fail with the same descriptive error operators already see.
512fn normalize_formation_document(raw: &serde_json::Value) -> Result<serde_json::Value, AppError> {
513    // Detection rules.
514    //
515    // Flat signal:    top-level `name` or `members` (the two fields a
516    //                 flat document is required to carry).
517    // Kubectl signal: top-level `apiVersion`, `kind`, `metadata`, or
518    //                 `spec` (any of the four envelope fields).
519    //
520    // We look at the union so we can detect hybrids precisely.
521    let Some(obj) = raw.as_object() else {
522        // Non-object: let the downstream typed parse produce the
523        // canonical error message.
524        return Ok(raw.clone());
525    };
526
527    const FLAT_KEYS: &[&str] = &["name", "coordinator", "members"];
528    const KUBECTL_KEYS: &[&str] = &["apiVersion", "kind", "metadata", "spec"];
529
530    let flat_keys_present: Vec<&str> = FLAT_KEYS
531        .iter()
532        .copied()
533        .filter(|k| obj.contains_key(*k))
534        .collect();
535    let kubectl_keys_present: Vec<&str> = KUBECTL_KEYS
536        .iter()
537        .copied()
538        .filter(|k| obj.contains_key(*k))
539        .collect();
540
541    let has_flat = !flat_keys_present.is_empty();
542    let has_kubectl = !kubectl_keys_present.is_empty();
543
544    if has_flat && has_kubectl {
545        return Err(AppError::bad_request(format!(
546            "hybrid formation document: top-level flat field(s) {flat:?} \
547             conflict with kubectl-style envelope field(s) {kubectl:?}; \
548             pick exactly one shape (see contracts/schemas/formation-v1.schema.json)",
549            flat = flat_keys_present,
550            kubectl = kubectl_keys_present,
551        )));
552    }
553
554    if !has_kubectl {
555        // No envelope fields → flat (or so malformed the typed parse
556        // will reject it). Pass through.
557        return Ok(raw.clone());
558    }
559
560    // Kubectl-style. Validate envelope literals.
561    let api_version = obj
562        .get("apiVersion")
563        .and_then(|v| v.as_str())
564        .ok_or_else(|| {
565            AppError::bad_request(
566                "kubectl-style formation: missing or non-string 'apiVersion' (expected \"cellos.dev/v1\")"
567                    .to_string(),
568            )
569        })?;
570    if api_version != "cellos.dev/v1" {
571        return Err(AppError::bad_request(format!(
572            "kubectl-style formation: unsupported apiVersion '{api_version}' (expected \"cellos.dev/v1\")"
573        )));
574    }
575
576    let kind = obj.get("kind").and_then(|v| v.as_str()).ok_or_else(|| {
577        AppError::bad_request(
578            "kubectl-style formation: missing or non-string 'kind' (expected \"Formation\")"
579                .to_string(),
580        )
581    })?;
582    if kind != "Formation" {
583        return Err(AppError::bad_request(format!(
584            "kubectl-style formation: unsupported kind '{kind}' (expected \"Formation\")"
585        )));
586    }
587
588    let metadata = obj
589        .get("metadata")
590        .and_then(|v| v.as_object())
591        .ok_or_else(|| {
592            AppError::bad_request("kubectl-style formation: missing 'metadata' object".to_string())
593        })?;
594    let name = metadata
595        .get("name")
596        .and_then(|v| v.as_str())
597        .ok_or_else(|| {
598            AppError::bad_request("kubectl-style formation: missing 'metadata.name'".to_string())
599        })?;
600
601    let spec = obj.get("spec").and_then(|v| v.as_object()).ok_or_else(|| {
602        AppError::bad_request("kubectl-style formation: missing 'spec' object".to_string())
603    })?;
604
605    let coordinator = spec
606        .get("coordinator")
607        .and_then(|v| v.as_str())
608        .ok_or_else(|| {
609            AppError::bad_request("kubectl-style formation: missing 'spec.coordinator'".to_string())
610        })?;
611
612    let members_raw = spec
613        .get("members")
614        .and_then(|v| v.as_array())
615        .ok_or_else(|| {
616            AppError::bad_request(
617                "kubectl-style formation: missing or non-array 'spec.members'".to_string(),
618            )
619        })?;
620
621    // Rewrite each member: `name` → `id`. `authorizedBy` carries
622    // through. Any extra fields (`critical`, `spec`, future fields)
623    // are preserved verbatim — the admission gate only inspects
624    // `id`/`authorizedBy`, but we keep the rest so downstream
625    // consumers (supervisor, projection) see what the operator wrote.
626    let mut members_flat = Vec::with_capacity(members_raw.len());
627    for (idx, m) in members_raw.iter().enumerate() {
628        let m_obj = m.as_object().ok_or_else(|| {
629            AppError::bad_request(format!(
630                "kubectl-style formation: spec.members[{idx}] is not an object"
631            ))
632        })?;
633        let member_name = m_obj.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
634            AppError::bad_request(format!(
635                "kubectl-style formation: spec.members[{idx}] missing 'name'"
636            ))
637        })?;
638
639        // RT3-HIGH-3 (CTL-003-A): kubectl convention says `metadata.name`
640        // (and at member level, `name`) is the canonical identifier;
641        // spec-level fields MUST NOT shadow it. Earlier code populated
642        // `id := name` and then iterated the member's keys with
643        // last-write-wins, so an operator who declared BOTH
644        // `name: alice` AND `id: bob` ended up with `id: bob` — the
645        // operator's mistake silently overrode the kubectl identifier.
646        // Admission is strict: reject with bad-request and name the
647        // conflict so the operator knows which field to drop.
648        if m_obj.contains_key("id") {
649            return Err(AppError::bad_request(format!(
650                "kubectl-style formation: spec.members[{idx}] declares both 'name' \
651                 and 'id'; kubectl manifests address members by 'name' only — \
652                 remove the 'id' field"
653            )));
654        }
655
656        let mut rewritten = serde_json::Map::with_capacity(m_obj.len());
657        rewritten.insert(
658            "id".to_string(),
659            serde_json::Value::String(member_name.to_string()),
660        );
661        for (k, v) in m_obj.iter() {
662            if k == "name" {
663                continue; // already rewritten to `id`
664            }
665            // `id` is rejected above; the loop now only carries through
666            // `authorizedBy` and operator-supplied extras.
667            rewritten.insert(k.clone(), v.clone());
668        }
669        members_flat.push(serde_json::Value::Object(rewritten));
670    }
671
672    let mut flat = serde_json::Map::with_capacity(3);
673    flat.insert(
674        "name".to_string(),
675        serde_json::Value::String(name.to_string()),
676    );
677    flat.insert(
678        "coordinator".to_string(),
679        serde_json::Value::String(coordinator.to_string()),
680    );
681    flat.insert(
682        "members".to_string(),
683        serde_json::Value::Array(members_flat),
684    );
685
686    Ok(serde_json::Value::Object(flat))
687}
688
689/// Validate a formation `name` against the conservative character set
690/// the by-name lookup, URL routing, and log rendering can all carry
691/// safely. FUZZ-WAVE-1 (FUZZ-HIGH-1) found admission accepted `""`,
692/// `"   "`, and `"a\nb"`; downstream routing then broke (URLs can't
693/// carry newlines, empty names are ambiguous) and log lines were
694/// corrupted by control characters.
695///
696/// Rules (deliberately conservative — relaxing is non-breaking, but
697/// tightening after release would be):
698///
699/// - **Length**: `1 ≤ name.len() ≤ 253` (matches DNS label length).
700/// - **Characters**: `[A-Za-z0-9._-]` only. Reject every control
701///   character, whitespace, newline, slash, and every byte outside
702///   ASCII.
703/// - **Edges**: cannot start or end with `-` or `.`.
704/// - **Reserved**: cannot be `.` or `..` (these collide with relative
705///   filesystem paths cellctl renders into).
706///
707/// All violations surface as `/problems/bad-request` (RFC 9457 §3.1)
708/// with a `detail` string naming the rule. Tighter formation-specific
709/// discriminants would be a breaking expansion of the type-uri namespace
710/// for what is, fundamentally, a malformed input.
711fn validate_formation_name(name: &str) -> Result<(), AppError> {
712    // Length.
713    if name.is_empty() {
714        return Err(AppError::bad_request(
715            "formation name must not be empty".to_string(),
716        ));
717    }
718    if name.len() > 253 {
719        return Err(AppError::bad_request(format!(
720            "formation name length {} exceeds maximum of 253 bytes",
721            name.len()
722        )));
723    }
724
725    // Reserved names.
726    if name == "." || name == ".." {
727        return Err(AppError::bad_request(format!(
728            "formation name '{name}' is reserved"
729        )));
730    }
731
732    // Character class — operate on bytes; the allowed set is pure ASCII,
733    // so any non-ASCII byte (and thus any multi-byte UTF-8 sequence)
734    // fails this check and the operator gets a precise reason.
735    for (idx, b) in name.as_bytes().iter().enumerate() {
736        let ok = matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.');
737        if !ok {
738            // Render the offending byte: printable ASCII as itself,
739            // otherwise as a `\xNN` escape so newlines/control bytes
740            // don't smuggle themselves into the response body.
741            let rendered = if b.is_ascii_graphic() {
742                format!("'{}'", *b as char)
743            } else {
744                format!("\\x{b:02x}")
745            };
746            return Err(AppError::bad_request(format!(
747                "formation name contains disallowed character {rendered} at byte offset {idx} \
748                 (allowed: A-Z a-z 0-9 . - _)"
749            )));
750        }
751    }
752
753    // Edges. Safe to index by byte: ASCII-only at this point.
754    let first = name.as_bytes()[0];
755    let last = name.as_bytes()[name.len() - 1];
756    if first == b'-' || first == b'.' {
757        return Err(AppError::bad_request(format!(
758            "formation name '{name}' must not start with '-' or '.'"
759        )));
760    }
761    if last == b'-' || last == b'.' {
762        return Err(AppError::bad_request(format!(
763            "formation name '{name}' must not end with '-' or '.'"
764        )));
765    }
766
767    Ok(())
768}
769
770/// Apply the structural admission-gate checks ADR-0010 §Enforcement
771/// requires the server to re-run regardless of client behaviour:
772///
773/// 1. **noCoordinator** — the coordinator named in `coordinator` MUST
774///    appear in `members`.
775/// 2. **multipleCoordinators** — every `members[*].id` MUST be unique.
776///    The JSON schema declares `uniqueItems`; we re-enforce because
777///    the server cannot assume schema validation ran on the client.
778/// 3. **authorityNotNarrowing** — the coordinator MUST NOT carry
779///    `authorizedBy`; every non-coordinator MUST carry it AND the
780///    referenced parent MUST exist in `members` (an orphan parent is
781///    an unbounded A₀ — exactly the failure mode ADR-0010 §Proof
782///    forbids).
783/// 4. **cycle** — the `authorizedBy` edges MUST form a DAG. A cycle
784///    (including the self-edge `authorizedBy: self`) breaks the
785///    induction that proves every member's authority chains back to
786///    the coordinator.
787///
788/// The per-edge authority-subset check (`A_c ⊆ A_p`) lives in the
789/// supervisor today because the `formation-v1` document parsed here
790/// does not yet carry per-member declared authority sets; that is the
791/// only ADR-0010 check the server still defers.
792fn validate_formation(doc: &FormationDocument) -> Result<(), AppError> {
793    use std::collections::{HashMap, HashSet};
794
795    // 1. noCoordinator.
796    let coord_present = doc.members.iter().any(|m| m.id == doc.coordinator);
797    if !coord_present {
798        return Err(AppError::new(
799            AppErrorKind::FormationNoCoordinator,
800            format!(
801                "coordinator '{}' must appear in members list",
802                doc.coordinator
803            ),
804        ));
805    }
806
807    // 2. multipleCoordinators — duplicate member ids. We treat the
808    // duplicate-id failure under this discriminant because the ADR
809    // §Consequences canonical case is "two members both named
810    // `coord`": admission cannot pick which one is the coordinator.
811    let mut seen: HashSet<&str> = HashSet::new();
812    for m in &doc.members {
813        if !seen.insert(m.id.as_str()) {
814            return Err(AppError::new(
815                AppErrorKind::FormationMultipleCoordinators,
816                format!("duplicate member id '{}'", m.id),
817            ));
818        }
819    }
820
821    // 3. authorityNotNarrowing — coord-forbid, non-coord require, plus
822    //    orphan-parent rejection. An `authorizedBy` reference that has
823    //    no member entry has no parent edge → no narrowing → admission
824    //    fails.
825    for m in &doc.members {
826        let is_coord = m.id == doc.coordinator;
827        match (is_coord, &m.authorized_by) {
828            (true, Some(_)) => {
829                return Err(AppError::new(
830                    AppErrorKind::FormationAuthorityNotNarrowing,
831                    format!("coordinator '{}' must not declare authorizedBy", m.id),
832                ));
833            }
834            (false, None) => {
835                return Err(AppError::new(
836                    AppErrorKind::FormationAuthorityNotNarrowing,
837                    format!("non-coordinator member '{}' missing authorizedBy", m.id),
838                ));
839            }
840            (false, Some(parent)) => {
841                if !seen.contains(parent.as_str()) {
842                    return Err(AppError::new(
843                        AppErrorKind::FormationAuthorityNotNarrowing,
844                        format!("member '{}' references unknown parent '{}'", m.id, parent),
845                    ));
846                }
847            }
848            _ => {}
849        }
850    }
851
852    // 4. cycle — walk each non-coordinator's authorizedBy chain. In a
853    //    valid DAG with exactly one out-edge per non-root, the walk
854    //    terminates at the coordinator within strictly fewer hops than
855    //    members.len(). Self-loops are caught on the first hop.
856    let parent: HashMap<&str, &str> = doc
857        .members
858        .iter()
859        .filter_map(|m| m.authorized_by.as_deref().map(|p| (m.id.as_str(), p)))
860        .collect();
861
862    for m in &doc.members {
863        if m.id == doc.coordinator {
864            continue;
865        }
866        let mut cursor = m.id.as_str();
867        for _ in 0..doc.members.len() {
868            let Some(&p) = parent.get(cursor) else {
869                // No outgoing edge from cursor → cursor is the
870                // coordinator (proven in check 1 to be present). Done.
871                break;
872            };
873            if p == m.id {
874                return Err(AppError::new(
875                    AppErrorKind::FormationCycle,
876                    format!("authorizedBy cycle detected involving member '{}'", m.id),
877                ));
878            }
879            cursor = p;
880        }
881        if parent.contains_key(cursor) {
882            // Exhausted hop budget without reaching the coordinator —
883            // a cycle exists on the chain (not necessarily through
884            // `m.id` itself).
885            return Err(AppError::new(
886                AppErrorKind::FormationCycle,
887                format!(
888                    "authorizedBy cycle detected on chain starting at '{}'",
889                    m.id
890                ),
891            ));
892        }
893    }
894
895    Ok(())
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use crate::router;
902    use axum::body::Body;
903    use axum::http::{header, Request};
904    use http_body_util::BodyExt;
905    use tower::ServiceExt;
906
907    const TOKEN: &str = "test-token";
908
909    fn test_state() -> AppState {
910        AppState::new(None, TOKEN)
911    }
912
913    fn auth_req(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
914        let mut b = Request::builder()
915            .method(method)
916            .uri(uri)
917            .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"));
918        if body.is_some() {
919            b = b.header(header::CONTENT_TYPE, "application/json");
920        }
921        b.body(
922            body.map(|s| Body::from(s.to_owned()))
923                .unwrap_or_else(Body::empty),
924        )
925        .expect("build request")
926    }
927
928    #[tokio::test]
929    async fn post_valid_formation_returns_201() {
930        let app = router(test_state());
931        let body = serde_json::json!({
932            "name": "demo",
933            "coordinator": "coord",
934            "members": [
935                { "id": "coord" },
936                { "id": "worker-a", "authorizedBy": "coord" },
937                { "id": "worker-b", "authorizedBy": "coord" }
938            ]
939        })
940        .to_string();
941
942        let resp = app
943            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
944            .await
945            .expect("router response");
946        assert_eq!(resp.status(), StatusCode::CREATED);
947
948        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
949        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
950        assert_eq!(parsed["status"], "PENDING");
951        assert_eq!(parsed["name"], "demo");
952        assert!(parsed["id"].as_str().is_some());
953    }
954
955    #[tokio::test]
956    async fn post_formation_missing_coordinator_returns_400() {
957        let app = router(test_state());
958        // coordinator names "coord" but no such member exists.
959        let body = serde_json::json!({
960            "name": "demo",
961            "coordinator": "coord",
962            "members": [
963                { "id": "worker-a", "authorizedBy": "coord" }
964            ]
965        })
966        .to_string();
967
968        let resp = app
969            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
970            .await
971            .expect("router response");
972        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
973        let ct = resp
974            .headers()
975            .get(header::CONTENT_TYPE)
976            .and_then(|v| v.to_str().ok())
977            .unwrap_or_default()
978            .to_owned();
979        assert!(
980            ct.starts_with("application/problem+json"),
981            "expected RFC 9457 media type, got {ct:?}"
982        );
983        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
984        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
985        assert_eq!(parsed["type"], "/problems/formation/no-coordinator");
986    }
987
988    #[tokio::test]
989    async fn post_formation_member_missing_authorized_by_returns_400() {
990        let app = router(test_state());
991        let body = serde_json::json!({
992            "name": "demo",
993            "coordinator": "coord",
994            "members": [
995                { "id": "coord" },
996                { "id": "worker-a" } // missing authorizedBy
997            ]
998        })
999        .to_string();
1000
1001        let resp = app
1002            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1003            .await
1004            .expect("router response");
1005        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1006        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1007        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1008        assert_eq!(
1009            parsed["type"], "/problems/formation/authority-not-narrowing",
1010            "expected authority-not-narrowing discriminant, got {parsed}"
1011        );
1012    }
1013
1014    #[tokio::test]
1015    async fn get_formations_returns_snapshot_with_cursor() {
1016        // ADR-0015 §D2: GET /v1/formations is `{ formations: [...], cursor: u64 }`.
1017        let app = router(test_state());
1018        let resp = app
1019            .oneshot(auth_req("GET", "/v1/formations", None))
1020            .await
1021            .expect("router response");
1022        assert_eq!(resp.status(), StatusCode::OK);
1023        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1024        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1025        assert!(parsed.is_object(), "expected snapshot object, got {parsed}");
1026        let arr = parsed["formations"].as_array().expect("formations array");
1027        assert_eq!(arr.len(), 0);
1028        assert!(
1029            parsed["cursor"].is_u64(),
1030            "cursor field must be an unsigned integer, got {}",
1031            parsed["cursor"]
1032        );
1033        assert_eq!(parsed["cursor"].as_u64(), Some(0));
1034    }
1035
1036    #[tokio::test]
1037    async fn snapshot_returns_cursor() {
1038        // ADR-0015 §D2 + §E: after POSTing a formation, the snapshot
1039        // response MUST carry a `cursor` field of integer type so the
1040        // client can hand it to `/ws/events?since=<cursor>`.
1041        let app = router(test_state());
1042        let body = serde_json::json!({
1043            "name": "with-cursor",
1044            "coordinator": "coord",
1045            "members": [
1046                { "id": "coord" },
1047                { "id": "worker-a", "authorizedBy": "coord" }
1048            ]
1049        })
1050        .to_string();
1051
1052        let resp = app
1053            .clone()
1054            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1055            .await
1056            .expect("router response");
1057        assert_eq!(resp.status(), StatusCode::CREATED);
1058
1059        let resp = app
1060            .oneshot(auth_req("GET", "/v1/formations", None))
1061            .await
1062            .expect("router response");
1063        assert_eq!(resp.status(), StatusCode::OK);
1064        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1065        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1066        assert!(
1067            parsed["cursor"].is_u64(),
1068            "cursor must be unsigned integer; got {}",
1069            parsed["cursor"]
1070        );
1071        let formations = parsed["formations"].as_array().expect("formations array");
1072        assert_eq!(formations.len(), 1, "expected 1 formation after POST");
1073        assert_eq!(formations[0]["name"], "with-cursor");
1074    }
1075
1076    #[tokio::test]
1077    async fn missing_bearer_returns_401() {
1078        let app = router(test_state());
1079        let resp = app
1080            .oneshot(
1081                Request::builder()
1082                    .method("GET")
1083                    .uri("/v1/formations")
1084                    .body(Body::empty())
1085                    .unwrap(),
1086            )
1087            .await
1088            .expect("router response");
1089        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1090    }
1091
1092    /// ADR-0010 §Enforcement `multipleCoordinators` discriminant.
1093    /// Duplicate `members[*].id` MUST be rejected with this type even
1094    /// when the duplicates carry valid `authorizedBy`. The JSON schema
1095    /// has `uniqueItems` but the server cannot assume schema validation
1096    /// ran on the client.
1097    #[tokio::test]
1098    async fn rejects_duplicate_member_ids_with_multiple_coordinators_type() {
1099        let app = router(test_state());
1100        let body = serde_json::json!({
1101            "name": "dup-ids",
1102            "coordinator": "coord",
1103            "members": [
1104                { "id": "coord" },
1105                { "id": "coord", "authorizedBy": "coord" }
1106            ]
1107        })
1108        .to_string();
1109        let resp = app
1110            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1111            .await
1112            .expect("router response");
1113        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1114        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1115        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1116        assert_eq!(
1117            parsed["type"], "/problems/formation/multiple-coordinators",
1118            "duplicate member ids must surface multipleCoordinators"
1119        );
1120    }
1121
1122    /// ADR-0010 §Enforcement `cycle` discriminant. `authorizedBy: self`
1123    /// is the minimal cycle.
1124    #[tokio::test]
1125    async fn rejects_self_authorized_cycle() {
1126        let app = router(test_state());
1127        let body = serde_json::json!({
1128            "name": "self-cycle",
1129            "coordinator": "coord",
1130            "members": [
1131                { "id": "coord" },
1132                { "id": "worker-a", "authorizedBy": "worker-a" }
1133            ]
1134        })
1135        .to_string();
1136        let resp = app
1137            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1138            .await
1139            .expect("router response");
1140        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1141        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1142        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1143        assert_eq!(parsed["type"], "/problems/formation/cycle");
1144    }
1145
1146    /// Two-node cycle a→b→a; neither chains back to coordinator.
1147    #[tokio::test]
1148    async fn rejects_two_node_cycle() {
1149        let app = router(test_state());
1150        let body = serde_json::json!({
1151            "name": "two-cycle",
1152            "coordinator": "coord",
1153            "members": [
1154                { "id": "coord" },
1155                { "id": "a", "authorizedBy": "b" },
1156                { "id": "b", "authorizedBy": "a" }
1157            ]
1158        })
1159        .to_string();
1160        let resp = app
1161            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1162            .await
1163            .expect("router response");
1164        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1165        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1166        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1167        assert_eq!(parsed["type"], "/problems/formation/cycle");
1168    }
1169
1170    /// Orphan parent: `authorizedBy: ghost` where `ghost` is not in
1171    /// `members`. Without a parent edge the member has no narrowing
1172    /// path → `authorityNotNarrowing`.
1173    #[tokio::test]
1174    async fn rejects_orphan_parent_reference() {
1175        let app = router(test_state());
1176        let body = serde_json::json!({
1177            "name": "orphan-parent",
1178            "coordinator": "coord",
1179            "members": [
1180                { "id": "coord" },
1181                { "id": "worker-a", "authorizedBy": "ghost" }
1182            ]
1183        })
1184        .to_string();
1185        let resp = app
1186            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1187            .await
1188            .expect("router response");
1189        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1190        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1191        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1192        assert_eq!(
1193            parsed["type"],
1194            "/problems/formation/authority-not-narrowing"
1195        );
1196    }
1197
1198    /// Red-team finding: POST /v1/formations was using axum's default
1199    /// 2 MiB body limit. We cap at 64 KiB so a >64 KiB payload returns
1200    /// 413 Payload Too Large rather than burning CPU on serde parsing.
1201    #[tokio::test]
1202    async fn post_formation_oversized_body_returns_413() {
1203        let app = router(test_state());
1204        // Build a JSON document larger than 64 KiB by stuffing a long
1205        // `name` field. The body-limit layer rejects before serde
1206        // parses, so the document does not need to be semantically
1207        // valid past the limit.
1208        let big = "x".repeat(70 * 1024);
1209        let body =
1210            format!(r#"{{"name":"{big}","coordinator":"coord","members":[{{"id":"coord"}}]}}"#,);
1211        let resp = app
1212            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1213            .await
1214            .expect("router response");
1215        assert_eq!(
1216            resp.status(),
1217            StatusCode::PAYLOAD_TOO_LARGE,
1218            "oversized body must surface 413; got {:?}",
1219            resp.status(),
1220        );
1221    }
1222
1223    /// Sanity-probe: the parameterized route `/v1/formations/{id}`
1224    /// actually captures the path segment. Existing tests only hit the
1225    /// non-parameterized `/v1/formations`; if the route registration
1226    /// ever regresses to literal-brace matching (e.g. on a future axum
1227    /// upgrade that changes path syntax), this test fails loudly.
1228    ///
1229    /// We POST a real formation then GET it by id and check the
1230    /// returned record matches. A 404 with empty/non-problem+json
1231    /// content-type indicates router-level miss (literal route); a
1232    /// 404 with problem+json indicates handler-level not-found
1233    /// (route matched, formation absent). We expect 200.
1234    #[tokio::test]
1235    async fn get_formation_by_id_captures_path() {
1236        let state = test_state();
1237        let body = serde_json::json!({
1238            "name": "probe",
1239            "coordinator": "coord",
1240            "members": [
1241                { "id": "coord" },
1242                { "id": "worker-a", "authorizedBy": "coord" }
1243            ]
1244        })
1245        .to_string();
1246        let resp = router(state.clone())
1247            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1248            .await
1249            .expect("router response");
1250        assert_eq!(resp.status(), StatusCode::CREATED);
1251        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1252        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1253        let id = parsed["id"].as_str().expect("uuid string");
1254
1255        let resp = router(state)
1256            .oneshot(auth_req("GET", &format!("/v1/formations/{id}"), None))
1257            .await
1258            .expect("router response");
1259        assert_eq!(
1260            resp.status(),
1261            StatusCode::OK,
1262            "GET /v1/formations/<id> must capture the path segment; got {:?}",
1263            resp.status(),
1264        );
1265        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1266        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1267        assert_eq!(parsed["name"], "probe");
1268    }
1269
1270    /// CTL-003 / SCHEMA-001 — kubectl-style happy path.
1271    /// `contracts/schemas/formation-v1.schema.json` documents the
1272    /// kubectl envelope; the server now accepts it via an admission
1273    /// adapter and normalizes to flat internally.
1274    #[tokio::test]
1275    async fn post_kubectl_style_formation_returns_201() {
1276        let app = router(test_state());
1277        let body = serde_json::json!({
1278            "apiVersion": "cellos.dev/v1",
1279            "kind": "Formation",
1280            "metadata": { "name": "kubectl-demo" },
1281            "spec": {
1282                "coordinator": "coord",
1283                "members": [
1284                    { "name": "coord" },
1285                    { "name": "worker-a", "authorizedBy": "coord" },
1286                    { "name": "worker-b", "authorizedBy": "coord" }
1287                ]
1288            }
1289        })
1290        .to_string();
1291
1292        let resp = app
1293            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1294            .await
1295            .expect("router response");
1296        assert_eq!(resp.status(), StatusCode::CREATED);
1297
1298        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1299        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1300        assert_eq!(parsed["status"], "PENDING");
1301        assert_eq!(parsed["name"], "kubectl-demo");
1302        assert!(parsed["id"].as_str().is_some());
1303    }
1304
1305    /// Kubectl-style → ADR-0010 admission still fires on the
1306    /// normalized flat form. Missing-coordinator on a kubectl-shaped
1307    /// payload must surface the same `/problems/formation/no-coordinator`
1308    /// discriminant the flat path produces.
1309    #[tokio::test]
1310    async fn post_kubectl_style_missing_coordinator_returns_no_coordinator() {
1311        let app = router(test_state());
1312        let body = serde_json::json!({
1313            "apiVersion": "cellos.dev/v1",
1314            "kind": "Formation",
1315            "metadata": { "name": "missing-coord" },
1316            "spec": {
1317                "coordinator": "coord",
1318                "members": [
1319                    { "name": "worker-a", "authorizedBy": "coord" }
1320                ]
1321            }
1322        })
1323        .to_string();
1324
1325        let resp = app
1326            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1327            .await
1328            .expect("router response");
1329        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1330        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1331        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1332        assert_eq!(parsed["type"], "/problems/formation/no-coordinator");
1333    }
1334
1335    /// Hybrid: top-level `name` + top-level `apiVersion`. Operator
1336    /// ambiguity — reject with 400 `/problems/bad-request` listing the
1337    /// conflicting fields.
1338    #[tokio::test]
1339    async fn post_hybrid_formation_returns_400_bad_request() {
1340        let app = router(test_state());
1341        let body = serde_json::json!({
1342            "apiVersion": "cellos.dev/v1",
1343            "kind": "Formation",
1344            "metadata": { "name": "hybrid" },
1345            "spec": {
1346                "coordinator": "coord",
1347                "members": [ { "name": "coord" } ]
1348            },
1349            // Stray flat-style field — operator confused two shapes.
1350            "name": "hybrid",
1351            "members": [ { "id": "coord" } ]
1352        })
1353        .to_string();
1354
1355        let resp = app
1356            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1357            .await
1358            .expect("router response");
1359        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1360        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1361        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1362        assert_eq!(
1363            parsed["type"], "/problems/bad-request",
1364            "hybrid shape must surface a generic bad-request, not an admission discriminant"
1365        );
1366        let detail = parsed["detail"].as_str().unwrap_or_default();
1367        assert!(
1368            detail.contains("hybrid"),
1369            "detail must mention 'hybrid'; got {detail:?}"
1370        );
1371    }
1372
1373    /// Kubectl-style with wrong `apiVersion` is rejected as bad-request
1374    /// before admission runs.
1375    #[tokio::test]
1376    async fn post_kubectl_style_wrong_api_version_returns_400() {
1377        let app = router(test_state());
1378        let body = serde_json::json!({
1379            "apiVersion": "cellos.dev/v2",
1380            "kind": "Formation",
1381            "metadata": { "name": "wrong-api" },
1382            "spec": {
1383                "coordinator": "coord",
1384                "members": [ { "name": "coord" } ]
1385            }
1386        })
1387        .to_string();
1388
1389        let resp = app
1390            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1391            .await
1392            .expect("router response");
1393        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1394        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1395        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1396        assert_eq!(parsed["type"], "/problems/bad-request");
1397        let detail = parsed["detail"].as_str().unwrap_or_default();
1398        assert!(
1399            detail.contains("apiVersion") && detail.contains("cellos.dev/v2"),
1400            "detail must name the bad apiVersion; got {detail:?}"
1401        );
1402    }
1403
1404    /// Kubectl-style with wrong `kind` is rejected as bad-request.
1405    #[tokio::test]
1406    async fn post_kubectl_style_wrong_kind_returns_400() {
1407        let app = router(test_state());
1408        let body = serde_json::json!({
1409            "apiVersion": "cellos.dev/v1",
1410            "kind": "Cell",
1411            "metadata": { "name": "wrong-kind" },
1412            "spec": {
1413                "coordinator": "coord",
1414                "members": [ { "name": "coord" } ]
1415            }
1416        })
1417        .to_string();
1418
1419        let resp = app
1420            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1421            .await
1422            .expect("router response");
1423        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1424        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1425        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1426        assert_eq!(parsed["type"], "/problems/bad-request");
1427        let detail = parsed["detail"].as_str().unwrap_or_default();
1428        assert!(
1429            detail.contains("kind") && detail.contains("Cell"),
1430            "detail must name the bad kind; got {detail:?}"
1431        );
1432    }
1433
1434    /// After a kubectl-style POST, the GET round-trip MUST echo the
1435    /// normalized (flat) shape so downstream consumers see one stable
1436    /// document layout.
1437    #[tokio::test]
1438    async fn kubectl_style_post_then_get_returns_normalized_flat_document() {
1439        let state = test_state();
1440        let body = serde_json::json!({
1441            "apiVersion": "cellos.dev/v1",
1442            "kind": "Formation",
1443            "metadata": { "name": "roundtrip" },
1444            "spec": {
1445                "coordinator": "coord",
1446                "members": [
1447                    { "name": "coord" },
1448                    { "name": "worker-a", "authorizedBy": "coord" }
1449                ]
1450            }
1451        })
1452        .to_string();
1453
1454        let resp = router(state.clone())
1455            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1456            .await
1457            .expect("router response");
1458        assert_eq!(resp.status(), StatusCode::CREATED);
1459        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1460        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1461        let id = parsed["id"].as_str().expect("uuid string");
1462
1463        let resp = router(state)
1464            .oneshot(auth_req("GET", &format!("/v1/formations/{id}"), None))
1465            .await
1466            .expect("router response");
1467        assert_eq!(resp.status(), StatusCode::OK);
1468        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1469        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1470        let doc = &parsed["document"];
1471        assert_eq!(doc["name"], "roundtrip", "flat 'name' present");
1472        assert_eq!(doc["coordinator"], "coord", "flat 'coordinator' present");
1473        let members = doc["members"]
1474            .as_array()
1475            .expect("members array on normalized doc");
1476        assert_eq!(members.len(), 2);
1477        assert_eq!(members[0]["id"], "coord");
1478        assert_eq!(members[1]["id"], "worker-a");
1479        assert_eq!(members[1]["authorizedBy"], "coord");
1480        // Envelope fields stripped on normalization.
1481        assert!(
1482            doc.get("apiVersion").is_none(),
1483            "kubectl envelope must not leak into normalized doc"
1484        );
1485        assert!(doc.get("kind").is_none());
1486        assert!(doc.get("metadata").is_none());
1487        assert!(doc.get("spec").is_none());
1488    }
1489
1490    /// RT3-HIGH-3 (CTL-003-A): a kubectl-style member that declares
1491    /// BOTH `name` and `id` is a manifest mistake — kubectl manifests
1492    /// address members by `name` only, and the previous normalization
1493    /// loop silently let the operator-supplied `id` win over the
1494    /// canonical name (`Map::insert` is last-write-wins). Admission is
1495    /// strict: reject with `/problems/bad-request` and name the conflict.
1496    #[tokio::test]
1497    async fn kubectl_member_with_explicit_id_returns_400() {
1498        let app = router(test_state());
1499        let body = serde_json::json!({
1500            "apiVersion": "cellos.dev/v1",
1501            "kind": "Formation",
1502            "metadata": { "name": "rt3-ctl-003-a" },
1503            "spec": {
1504                "coordinator": "alice",
1505                "members": [
1506                    // The mistake: declaring BOTH name AND id. The old
1507                    // code would set id := "name", then overwrite with
1508                    // id := "bob" from the spec field. New behaviour:
1509                    // reject the manifest outright.
1510                    { "name": "alice", "id": "bob" },
1511                    { "name": "worker-a", "authorizedBy": "alice" }
1512                ]
1513            }
1514        })
1515        .to_string();
1516
1517        let resp = app
1518            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1519            .await
1520            .expect("router response");
1521        assert_eq!(
1522            resp.status(),
1523            StatusCode::BAD_REQUEST,
1524            "manifest with both name+id at member level must be rejected"
1525        );
1526        let ct = resp
1527            .headers()
1528            .get(header::CONTENT_TYPE)
1529            .and_then(|v| v.to_str().ok())
1530            .unwrap_or_default()
1531            .to_owned();
1532        assert!(
1533            ct.starts_with("application/problem+json"),
1534            "expected RFC 9457 media type, got {ct:?}"
1535        );
1536        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1537        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1538        assert_eq!(
1539            parsed["type"], "/problems/bad-request",
1540            "kubectl id-conflict is a generic bad-request, not an ADR-0010 discriminant"
1541        );
1542        let detail = parsed["detail"].as_str().unwrap_or_default();
1543        assert!(
1544            detail.contains("'name'") && detail.contains("'id'"),
1545            "detail must name both conflicting fields; got {detail:?}"
1546        );
1547    }
1548
1549    /// RT3-HIGH-3 (CTL-002-A): when two formations share a name,
1550    /// `DELETE /v1/formations/by-name/{name}` MUST refuse with 409
1551    /// Conflict instead of silently deleting the BTreeMap-first match.
1552    /// Silent wrong-deletion is the operator-trust failure mode the
1553    /// red-team flagged; admission-time uniqueness is being added in a
1554    /// sibling stream, but defense in depth keeps this guard in place.
1555    #[tokio::test]
1556    async fn delete_by_name_with_duplicates_returns_409() {
1557        let state = test_state();
1558
1559        // Inject two FormationRecords with the same name directly into
1560        // the projection. This bypasses admission (which is the whole
1561        // point of the test: admission may or may not catch this, but
1562        // DELETE must NOT silently pick one).
1563        let id_a = Uuid::new_v4();
1564        let id_b = Uuid::new_v4();
1565        {
1566            let mut map = state.formations.write().await;
1567            for id in [id_a, id_b] {
1568                map.insert(
1569                    id,
1570                    FormationRecord {
1571                        id,
1572                        name: "rt3-dup".to_string(),
1573                        status: FormationStatus::Pending,
1574                        document: serde_json::json!({"name": "rt3-dup"}),
1575                    },
1576                );
1577            }
1578        }
1579
1580        let resp = router(state.clone())
1581            .oneshot(auth_req("DELETE", "/v1/formations/by-name/rt3-dup", None))
1582            .await
1583            .expect("router response");
1584        assert_eq!(
1585            resp.status(),
1586            StatusCode::CONFLICT,
1587            "duplicate-name DELETE must surface 409, not silently delete"
1588        );
1589
1590        let ct = resp
1591            .headers()
1592            .get(header::CONTENT_TYPE)
1593            .and_then(|v| v.to_str().ok())
1594            .unwrap_or_default()
1595            .to_owned();
1596        assert!(
1597            ct.starts_with("application/problem+json"),
1598            "expected RFC 9457 media type, got {ct:?}"
1599        );
1600        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1601        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1602        assert_eq!(parsed["type"], "/problems/conflict");
1603        let detail = parsed["detail"].as_str().unwrap_or_default();
1604        assert!(
1605            detail.contains(&id_a.to_string()) && detail.contains(&id_b.to_string()),
1606            "detail must list BOTH conflicting UUIDs so the operator can disambiguate; \
1607             got {detail:?}"
1608        );
1609        assert!(
1610            detail.contains("rt3-dup"),
1611            "detail must name the conflicting formation name; got {detail:?}"
1612        );
1613
1614        // Defense in depth: neither formation should have been mutated.
1615        let map = state.formations.read().await;
1616        assert!(map.contains_key(&id_a), "id_a must still exist after 409");
1617        assert!(map.contains_key(&id_b), "id_b must still exist after 409");
1618        assert_eq!(map.get(&id_a).unwrap().status, FormationStatus::Pending);
1619        assert_eq!(map.get(&id_b).unwrap().status, FormationStatus::Pending);
1620    }
1621
1622    /// Red-team finding: `update_formation_status` previously returned
1623    /// the ADR-0010 `no-coordinator` discriminant for unknown state
1624    /// strings, hijacking a load-bearing admission-gate identifier.
1625    /// Unknown state is a generic bad-request.
1626    #[tokio::test]
1627    async fn unknown_state_returns_bad_request_problem_type() {
1628        let state = test_state();
1629        let body = serde_json::json!({
1630            "name": "demo",
1631            "coordinator": "coord",
1632            "members": [
1633                { "id": "coord" },
1634                { "id": "worker-a", "authorizedBy": "coord" }
1635            ]
1636        })
1637        .to_string();
1638        let resp = router(state.clone())
1639            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1640            .await
1641            .expect("router response");
1642        assert_eq!(resp.status(), StatusCode::CREATED);
1643        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1644        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1645        let id = parsed["id"].as_str().expect("uuid string").to_owned();
1646
1647        let bad = serde_json::json!({ "state": "TELEPORTING" }).to_string();
1648        let resp = router(state)
1649            .oneshot(auth_req(
1650                "POST",
1651                &format!("/v1/formations/{id}/status"),
1652                Some(&bad),
1653            ))
1654            .await
1655            .expect("router response");
1656        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1657        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1658        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1659        assert_eq!(
1660            parsed["type"], "/problems/bad-request",
1661            "unknown state must surface generic bad-request, not an ADR-0010 discriminant"
1662        );
1663    }
1664
1665    // ----------------------------------------------------------------
1666    // FUZZ-HIGH-1 — name validation
1667    //
1668    // Fuzz wave 1 admitted hostile names (`""`, `"   "`, `"a\nb"`).
1669    // Every negative case below MUST return 400 problem+json with the
1670    // generic `/problems/bad-request` discriminant; every positive case
1671    // must reach the ADR-0010 admission gate and succeed with 201.
1672    // ----------------------------------------------------------------
1673
1674    /// Build a minimal valid POST body for a given name. Used by both
1675    /// name-validation and uniqueness tests below. Keeps the test cases
1676    /// focused on the field actually under test.
1677    fn minimal_body(name: &str) -> String {
1678        serde_json::json!({
1679            "name": name,
1680            "coordinator": "coord",
1681            "members": [
1682                { "id": "coord" },
1683                { "id": "worker-a", "authorizedBy": "coord" }
1684            ]
1685        })
1686        .to_string()
1687    }
1688
1689    /// Assert that POSTing `name` yields 400 `/problems/bad-request`.
1690    /// The `expect_in_detail` substring lets each case prove its OWN
1691    /// rule fired, not a different one — empty-name and 254-byte-name
1692    /// both produce bad-request, but for different reasons.
1693    async fn assert_name_rejected_bad_request(name: &str, expect_in_detail: &str) {
1694        let app = router(test_state());
1695        let body = minimal_body(name);
1696        let resp = app
1697            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1698            .await
1699            .expect("router response");
1700        assert_eq!(
1701            resp.status(),
1702            StatusCode::BAD_REQUEST,
1703            "name {name:?} must be rejected with 400; got {:?}",
1704            resp.status()
1705        );
1706        let ct = resp
1707            .headers()
1708            .get(header::CONTENT_TYPE)
1709            .and_then(|v| v.to_str().ok())
1710            .unwrap_or_default()
1711            .to_owned();
1712        assert!(
1713            ct.starts_with("application/problem+json"),
1714            "name {name:?} must surface RFC 9457 media type; got {ct:?}"
1715        );
1716        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1717        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1718        assert_eq!(
1719            parsed["type"], "/problems/bad-request",
1720            "name {name:?} must surface generic bad-request, not an admission discriminant; got {parsed}"
1721        );
1722        let detail = parsed["detail"].as_str().unwrap_or_default();
1723        assert!(
1724            detail.contains(expect_in_detail),
1725            "detail for {name:?} must contain {expect_in_detail:?}; got {detail:?}"
1726        );
1727    }
1728
1729    /// FUZZ-HIGH-1 / F14 — admission accepted `""`. Reject as bad-request.
1730    #[tokio::test]
1731    async fn rejects_empty_name() {
1732        assert_name_rejected_bad_request("", "empty").await;
1733    }
1734
1735    /// FUZZ-HIGH-1 — whitespace-only name. Spaces are not in the
1736    /// allow-set, so the character-class check fires.
1737    #[tokio::test]
1738    async fn rejects_whitespace_only_name() {
1739        assert_name_rejected_bad_request("   ", "disallowed character").await;
1740    }
1741
1742    /// FUZZ-HIGH-1 / F44 — newline in name. The fuzz report flagged this
1743    /// because it corrupts log lines and breaks URL routing.
1744    #[tokio::test]
1745    async fn rejects_newline_in_name() {
1746        assert_name_rejected_bad_request("a\nb", "disallowed character").await;
1747    }
1748
1749    /// FUZZ-HIGH-1 — tab character. Same class as newline: control byte
1750    /// outside the allow-set.
1751    #[tokio::test]
1752    async fn rejects_tab_in_name() {
1753        assert_name_rejected_bad_request("a\tb", "disallowed character").await;
1754    }
1755
1756    /// FUZZ-HIGH-1 — embedded NUL byte. Breaks C-string boundaries in
1757    /// any downstream consumer that ever hands the name to a syscall.
1758    #[tokio::test]
1759    async fn rejects_nul_byte_in_name() {
1760        assert_name_rejected_bad_request("a\0b", "disallowed character").await;
1761    }
1762
1763    /// FUZZ-HIGH-1 — non-ASCII (UTF-8 multi-byte). The allow-set is
1764    /// pure ASCII; emoji and accented characters fail the byte-class
1765    /// check.
1766    #[tokio::test]
1767    async fn rejects_non_ascii_in_name() {
1768        assert_name_rejected_bad_request("café", "disallowed character").await;
1769    }
1770
1771    /// FUZZ-HIGH-1 — length cap. A 254-byte name (one over the DNS
1772    /// label limit) is rejected.
1773    #[tokio::test]
1774    async fn rejects_overlong_name() {
1775        let long = "a".repeat(254);
1776        assert_name_rejected_bad_request(&long, "exceeds maximum").await;
1777    }
1778
1779    /// FUZZ-HIGH-1 — leading `-`. Mirrors DNS label rules; many
1780    /// downstream tools special-case leading hyphens as flags.
1781    #[tokio::test]
1782    async fn rejects_leading_hyphen() {
1783        assert_name_rejected_bad_request("-leading", "start with").await;
1784    }
1785
1786    /// FUZZ-HIGH-1 — trailing `.`. Trailing dots collide with relative
1787    /// filesystem paths and DNS-style fully-qualified-name conventions.
1788    #[tokio::test]
1789    async fn rejects_trailing_dot() {
1790        assert_name_rejected_bad_request("trailing.", "end with").await;
1791    }
1792
1793    /// FUZZ-HIGH-1 — reserved name `.`. Would alias the current-directory
1794    /// path segment in cellctl rendering. The reserved-name rule runs
1795    /// before the edge rule, so the operator sees the precise reason.
1796    #[tokio::test]
1797    async fn rejects_single_dot_reserved_name() {
1798        assert_name_rejected_bad_request(".", "reserved").await;
1799    }
1800
1801    /// FUZZ-HIGH-1 — reserved name `..`. Same class as `.`; also begins
1802    /// with `.` so the edge rule fires first. We assert bad-request
1803    /// without pinning which rule trips — both are correct rejections.
1804    #[tokio::test]
1805    async fn rejects_double_dot_reserved_name() {
1806        let app = router(test_state());
1807        let body = minimal_body("..");
1808        let resp = app
1809            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1810            .await
1811            .expect("router response");
1812        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1813        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1814        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1815        assert_eq!(parsed["type"], "/problems/bad-request");
1816    }
1817
1818    /// FUZZ-HIGH-1 positive — simple lowercase name.
1819    #[tokio::test]
1820    async fn accepts_simple_lowercase_name() {
1821        let app = router(test_state());
1822        let body = minimal_body("demo");
1823        let resp = app
1824            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1825            .await
1826            .expect("router response");
1827        assert_eq!(resp.status(), StatusCode::CREATED);
1828    }
1829
1830    /// FUZZ-HIGH-1 positive — hyphenated name (the dominant convention
1831    /// in the existing test corpus: `demo`, `with-cursor`, `valid-dag`).
1832    #[tokio::test]
1833    async fn accepts_hyphenated_name() {
1834        let app = router(test_state());
1835        let body = minimal_body("my-formation-v2");
1836        let resp = app
1837            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1838            .await
1839            .expect("router response");
1840        assert_eq!(resp.status(), StatusCode::CREATED);
1841    }
1842
1843    /// FUZZ-HIGH-1 positive — dotted name (mid-name dots and underscores
1844    /// are both allowed; only edge dots/hyphens are rejected).
1845    #[tokio::test]
1846    async fn accepts_dotted_and_underscored_name() {
1847        let app = router(test_state());
1848        let body = minimal_body("team.alpha_one");
1849        let resp = app
1850            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1851            .await
1852            .expect("router response");
1853        assert_eq!(resp.status(), StatusCode::CREATED);
1854    }
1855
1856    // ----------------------------------------------------------------
1857    // FUZZ-HIGH-2 — name uniqueness
1858    //
1859    // Two formations sharing a name break `GET /v1/formations/by-name/{name}`
1860    // (it returns the first match and hides the rest). Admission must
1861    // enforce uniqueness so by-name lookup is total.
1862    // ----------------------------------------------------------------
1863
1864    /// FUZZ-HIGH-2 / F-dup-name — POST `name=demo` twice. The first
1865    /// request succeeds with 201; the second MUST return 409 with the
1866    /// `/problems/conflict` discriminant. The first formation must
1867    /// remain queryable by-name (proves we didn't accidentally evict it).
1868    #[tokio::test]
1869    async fn duplicate_name_returns_409() {
1870        let state = test_state();
1871        let body = minimal_body("demo");
1872
1873        // First POST — succeeds.
1874        let resp = router(state.clone())
1875            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1876            .await
1877            .expect("router response");
1878        assert_eq!(
1879            resp.status(),
1880            StatusCode::CREATED,
1881            "first POST must succeed"
1882        );
1883        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1884        let first: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1885        let first_id = first["id"]
1886            .as_str()
1887            .expect("first POST returned uuid")
1888            .to_owned();
1889
1890        // Second POST with same name — MUST conflict.
1891        let resp = router(state.clone())
1892            .oneshot(auth_req("POST", "/v1/formations", Some(&body)))
1893            .await
1894            .expect("router response");
1895        assert_eq!(
1896            resp.status(),
1897            StatusCode::CONFLICT,
1898            "duplicate name must surface 409; got {:?}",
1899            resp.status()
1900        );
1901        let ct = resp
1902            .headers()
1903            .get(header::CONTENT_TYPE)
1904            .and_then(|v| v.to_str().ok())
1905            .unwrap_or_default()
1906            .to_owned();
1907        assert!(
1908            ct.starts_with("application/problem+json"),
1909            "409 must use RFC 9457 media type; got {ct:?}"
1910        );
1911        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1912        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1913        assert_eq!(parsed["type"], "/problems/conflict");
1914        let detail = parsed["detail"].as_str().unwrap_or_default();
1915        assert!(
1916            detail.contains(&first_id),
1917            "conflict detail must name the existing UUID {first_id}; got {detail:?}"
1918        );
1919        assert!(
1920            detail.contains("demo"),
1921            "conflict detail must name the conflicting name; got {detail:?}"
1922        );
1923
1924        // First formation MUST still be queryable by-name (proves the
1925        // second request didn't clobber or shadow it).
1926        let resp = router(state)
1927            .oneshot(auth_req("GET", "/v1/formations/by-name/demo", None))
1928            .await
1929            .expect("router response");
1930        assert_eq!(resp.status(), StatusCode::OK);
1931        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
1932        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1933        assert_eq!(
1934            parsed["id"].as_str(),
1935            Some(first_id.as_str()),
1936            "first formation must remain addressable by name"
1937        );
1938    }
1939}