Skip to main content

breathe_api_server/
lib.rs

1//! `breathe-api-server` — the REST surface over the [`breathe_facade`] core.
2//!
3//! Every route is a thin call into the same `BreatheStore` the MCP drives, so the
4//! two surfaces can never diverge. The path/verb set mirrors
5//! `spec/breathe.openapi.yaml` (the source of truth); the handlers are kube-rs
6//! facade calls — the one place breathe legitimately hand-serves (a generic
7//! `kube::Api<T>` dispatch that forge-gen's HTTP-client model fits poorly).
8
9use std::sync::Arc;
10
11use axum::{
12    extract::{Path, Query, State},
13    http::StatusCode,
14    response::{IntoResponse, Response},
15    routing::{get, patch},
16    Json, Router,
17};
18use breathe_facade::{BreatheStore, DimensionId, StoreError};
19use serde::Deserialize;
20use serde_json::{json, Value};
21
22pub type SharedStore = Arc<dyn BreatheStore>;
23
24/// The full HTTP router: REST + a `/graphql` endpoint, both over the shared
25/// facade. `store` is the real `KubeStore` or a mock.
26#[must_use]
27pub fn router(store: SharedStore) -> Router {
28    let schema = graphql::schema(store.clone());
29    Router::new()
30        .route("/healthz", get(|| async { "ok" }))
31        .route("/api/v1/catalog", get(catalog))
32        .route("/api/v1/bands/:kind", get(list_bands))
33        .route("/api/v1/bands/:kind/:namespace/:name", get(get_band).patch(patch_band))
34        .route("/api/v1/bands/:kind/:namespace/:name/dry-run", patch(set_dry_run))
35        .route("/api/v1/bands/:kind/:namespace/:name/write-intent", patch(set_write_intent))
36        .route("/api/v1/bands/:kind/:namespace/:name/confirm", patch(confirm_band))
37        .route("/api/v1/nodepools", get(list_pools))
38        .route("/api/v1/nodepools/:name", get(get_pool))
39        .route("/api/v1/nodepools/:name/write-enabled", patch(set_write_enabled))
40        .route_service("/graphql", async_graphql_axum::GraphQL::new(schema))
41        .with_state(store)
42}
43
44/// Map a facade result to an HTTP response.
45fn respond(r: Result<Value, StoreError>) -> Response {
46    match r {
47        Ok(v) => (StatusCode::OK, Json(v)).into_response(),
48        Err(StoreError::BadRequest(m)) => (StatusCode::BAD_REQUEST, Json(json!({ "error": m }))).into_response(),
49        Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e.to_string() }))).into_response(),
50    }
51}
52
53fn kind_or_400(s: &str) -> Result<DimensionId, Response> {
54    DimensionId::parse(s).ok_or_else(|| {
55        let known: Vec<&str> = DimensionId::ALL.iter().map(|d| d.as_str()).collect();
56        (StatusCode::BAD_REQUEST, Json(json!({ "error": format!("unknown band kind '{s}'"), "known": known })))
57            .into_response()
58    })
59}
60
61#[derive(Deserialize)]
62struct NsQuery {
63    namespace: Option<String>,
64}
65
66async fn catalog(State(store): State<SharedStore>) -> Response {
67    (StatusCode::OK, Json(store.catalog())).into_response()
68}
69
70async fn list_bands(State(store): State<SharedStore>, Path(kind): Path<String>, Query(q): Query<NsQuery>) -> Response {
71    match kind_or_400(&kind) {
72        Ok(k) => respond(store.list_bands(k, q.namespace).await),
73        Err(r) => r,
74    }
75}
76
77async fn get_band(State(store): State<SharedStore>, Path((kind, ns, name)): Path<(String, String, String)>) -> Response {
78    match kind_or_400(&kind) {
79        Ok(k) => respond(store.get_band(k, ns, name).await),
80        Err(r) => r,
81    }
82}
83
84async fn patch_band(
85    State(store): State<SharedStore>,
86    Path((kind, ns, name)): Path<(String, String, String)>,
87    Json(spec): Json<Value>,
88) -> Response {
89    match kind_or_400(&kind) {
90        Ok(k) => respond(store.patch_band_spec(k, ns, name, spec).await),
91        Err(r) => r,
92    }
93}
94
95#[derive(Deserialize)]
96struct DryRunBody {
97    #[serde(rename = "dryRun")]
98    dry_run: bool,
99}
100
101/// RETIRED for eight of the ten kinds.
102///
103/// This route used to patch `spec.dryRun` on any kind and return 200. On every
104/// kind but `host-param` and `kube-param` that patch has changed nothing since
105/// 2026-06-19, so the 200 was a report of a safety gate that had not been
106/// applied. It now returns 400 there, names the retirement, and points at
107/// `writeIntent`; on the two kinds that DO read the field it still applies it,
108/// because a blanket refusal would be the opposite lie.
109async fn set_dry_run(
110    State(store): State<SharedStore>,
111    Path((kind, ns, name)): Path<(String, String, String)>,
112    Json(b): Json<DryRunBody>,
113) -> Response {
114    match kind_or_400(&kind) {
115        Ok(k) if !k.dry_run_is_honored() => (
116            StatusCode::BAD_REQUEST,
117            Json(json!({
118                "error": "spec.dryRun has no effect on this band kind",
119                "kind": k.as_str(),
120                "retiredSince": "breathe@76924b0 (2026-06-19)",
121                "useInstead": "PATCH /api/v1/bands/{kind}/{namespace}/{name}/write-intent",
122                "honoredBy": ["host-param", "kube-param"],
123                "wroteNothing": true,
124            })),
125        )
126            .into_response(),
127        Ok(k) => respond(store.patch_band_spec(k, ns, name, json!({ "dryRun": b.dry_run })).await),
128        Err(r) => r,
129    }
130}
131
132#[derive(Deserialize)]
133struct WriteIntentBody {
134    intent: String,
135    #[serde(rename = "confirmAfterSeconds", default)]
136    confirm_after_seconds: Option<u64>,
137    #[serde(rename = "authorizedBy", default)]
138    authorized_by: Option<String>,
139}
140
141/// Author `spec.writeIntent` — the authorization gate that actually holds.
142async fn set_write_intent(
143    State(store): State<SharedStore>,
144    Path((kind, ns, name)): Path<(String, String, String)>,
145    Json(b): Json<WriteIntentBody>,
146) -> Response {
147    let k = match kind_or_400(&kind) {
148        Ok(k) => k,
149        Err(r) => return r,
150    };
151    // Refuse an unattributed go-live here rather than writing a CR the controller
152    // would only ever resolve to a fail-safe shadow. Same verdict, one round-trip
153    // earlier, and the operator learns why while still holding the decision.
154    if b.intent == "write" && b.authorized_by.as_deref().map(str::trim).unwrap_or_default().is_empty() {
155        return (
156            StatusCode::BAD_REQUEST,
157            Json(json!({
158                "error": "intent=write requires authorizedBy",
159                "why": "a live carve must name who authorized it; the witness is carried into \
160                        status.effectiveGate so 'why is this band writing?' is answerable from the CR alone",
161                "wroteNothing": true,
162            })),
163        )
164            .into_response();
165    }
166    let mut intent = json!({ "intent": b.intent });
167    if let Some(secs) = b.confirm_after_seconds {
168        intent["confirmAfterSeconds"] = json!(secs);
169    }
170    if let Some(by) = b.authorized_by {
171        intent["authorizedBy"] = json!(by);
172    }
173    respond(store.patch_band_spec(k, ns, name, json!({ "writeIntent": intent })).await)
174}
175
176#[derive(Deserialize)]
177struct ConfirmBody {
178    confirmed: bool,
179}
180
181/// Set or clear `breathe.pleme.io/confirmed` — the operator fast-path that
182/// promotes a calibrating band without waiting out its confirm window.
183async fn confirm_band(
184    State(store): State<SharedStore>,
185    Path((kind, ns, name)): Path<(String, String, String)>,
186    Json(b): Json<ConfirmBody>,
187) -> Response {
188    match kind_or_400(&kind) {
189        // JSON null in a merge-patch REMOVES the key — the honest "un-confirm".
190        Ok(k) => {
191            let v = if b.confirmed { json!("true") } else { Value::Null };
192            respond(store.annotate_band(k, ns, name, json!({ breathe_provider::CONFIRMED_ANNOTATION: v })).await)
193        }
194        Err(r) => r,
195    }
196}
197
198async fn list_pools(State(store): State<SharedStore>) -> Response {
199    respond(store.list_pools().await)
200}
201
202async fn get_pool(State(store): State<SharedStore>, Path(name): Path<String>) -> Response {
203    respond(store.get_pool(name).await)
204}
205
206#[derive(Deserialize)]
207struct WriteEnabledBody {
208    #[serde(rename = "writeEnabled")]
209    write_enabled: bool,
210}
211
212async fn set_write_enabled(
213    State(store): State<SharedStore>,
214    Path(name): Path<String>,
215    Json(b): Json<WriteEnabledBody>,
216) -> Response {
217    respond(store.patch_pool_spec(name, json!({ "writeEnabled": b.write_enabled })).await)
218}
219
220// ───────────────────────────── GraphQL ─────────────────────────────
221
222/// The GraphQL surface (async-graphql) over the same facade. Resolvers return
223/// the CRD JSON via the `Json` scalar so there is one typed source of truth.
224pub mod graphql {
225    use super::{DimensionId, SharedStore, StoreError};
226    use async_graphql::{Context, EmptySubscription, Json, Object, Schema};
227    use serde_json::Value;
228
229    fn gql(e: StoreError) -> async_graphql::Error {
230        async_graphql::Error::new(e.to_string())
231    }
232    fn parse_kind(s: &str) -> async_graphql::Result<DimensionId> {
233        DimensionId::parse(s).ok_or_else(|| {
234            let known: Vec<&str> = DimensionId::ALL.iter().map(|d| d.as_str()).collect();
235            async_graphql::Error::new(format!("unknown band kind '{s}' (known: {})", known.join(", ")))
236        })
237    }
238    fn store<'a>(ctx: &Context<'a>) -> async_graphql::Result<&'a SharedStore> {
239        ctx.data::<SharedStore>()
240    }
241
242    pub struct Query;
243    #[Object]
244    impl Query {
245        /// The self-describing dimension catalog.
246        async fn catalog(&self, ctx: &Context<'_>) -> async_graphql::Result<Json<Value>> {
247            Ok(Json(store(ctx)?.catalog()))
248        }
249        /// List bands of a dimension, optionally namespace-scoped.
250        async fn bands(&self, ctx: &Context<'_>, kind: String, namespace: Option<String>) -> async_graphql::Result<Json<Value>> {
251            Ok(Json(store(ctx)?.list_bands(parse_kind(&kind)?, namespace).await.map_err(gql)?))
252        }
253        /// One band CR.
254        async fn band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String) -> async_graphql::Result<Json<Value>> {
255            Ok(Json(store(ctx)?.get_band(parse_kind(&kind)?, namespace, name).await.map_err(gql)?))
256        }
257        /// All node pools.
258        async fn nodepools(&self, ctx: &Context<'_>) -> async_graphql::Result<Json<Value>> {
259            Ok(Json(store(ctx)?.list_pools().await.map_err(gql)?))
260        }
261        async fn nodepool(&self, ctx: &Context<'_>, name: String) -> async_graphql::Result<Json<Value>> {
262            Ok(Json(store(ctx)?.get_pool(name).await.map_err(gql)?))
263        }
264    }
265
266    pub struct Mutation;
267    #[Object]
268    impl Mutation {
269        /// Merge-patch a band's spec.
270        async fn patch_band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, spec: Json<Value>) -> async_graphql::Result<Json<Value>> {
271            Ok(Json(store(ctx)?.patch_band_spec(parse_kind(&kind)?, namespace, name, spec.0).await.map_err(gql)?))
272        }
273        /// Author a band's `writeIntent` — THE authorization gate on every kind.
274        /// `intent` is observe | calibrateThenWrite | write | frozen;
275        /// `authorizedBy` is required when `intent` is `write`.
276        async fn set_write_intent(
277            &self,
278            ctx: &Context<'_>,
279            kind: String,
280            namespace: String,
281            name: String,
282            intent: String,
283            confirm_after_seconds: Option<u64>,
284            authorized_by: Option<String>,
285        ) -> async_graphql::Result<Json<Value>> {
286            if intent == "write" && authorized_by.as_deref().map(str::trim).unwrap_or_default().is_empty() {
287                return Err(async_graphql::Error::new(
288                    "intent=write requires authorizedBy — a live carve must name who authorized it; nothing was written",
289                ));
290            }
291            let mut body = serde_json::json!({ "intent": intent });
292            if let Some(secs) = confirm_after_seconds {
293                body["confirmAfterSeconds"] = serde_json::json!(secs);
294            }
295            if let Some(by) = authorized_by {
296                body["authorizedBy"] = serde_json::json!(by);
297            }
298            let spec = serde_json::json!({ "writeIntent": body });
299            Ok(Json(store(ctx)?.patch_band_spec(parse_kind(&kind)?, namespace, name, spec).await.map_err(gql)?))
300        }
301        /// Set or clear `breathe.pleme.io/confirmed` — promote a calibrating band
302        /// to writing now instead of waiting out its confirm window.
303        async fn confirm_band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, confirmed: bool) -> async_graphql::Result<Json<Value>> {
304            let v = if confirmed { serde_json::json!("true") } else { Value::Null };
305            let ann = serde_json::json!({ breathe_provider::CONFIRMED_ANNOTATION: v });
306            Ok(Json(store(ctx)?.annotate_band(parse_kind(&kind)?, namespace, name, ann).await.map_err(gql)?))
307        }
308        /// Write a band's `dryRun`. RETIRED for eight of the ten kinds — only
309        /// `host-param` and `kube-param` read it; elsewhere this errors rather
310        /// than reporting success for a patch that changes nothing. Use
311        /// `setWriteIntent`.
312        async fn set_dry_run(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, dry_run: bool) -> async_graphql::Result<Json<Value>> {
313            let k = parse_kind(&kind)?;
314            if !k.dry_run_is_honored() {
315                return Err(async_graphql::Error::new(format!(
316                    "spec.dryRun has no effect on {kind} bands (retired breathe@76924b0, 2026-06-19); \
317                     use setWriteIntent. Only host-param and kube-param read it. Nothing was written."
318                )));
319            }
320            Ok(Json(store(ctx)?.patch_band_spec(k, namespace, name, serde_json::json!({ "dryRun": dry_run })).await.map_err(gql)?))
321        }
322        /// Flip a BreatheNodePool's writeEnabled master switch. Bounds HOST-plane
323        /// writes for bands enrolled on that pool; not a cluster-wide kill switch.
324        async fn set_write_enabled(&self, ctx: &Context<'_>, name: String, write_enabled: bool) -> async_graphql::Result<Json<Value>> {
325            Ok(Json(store(ctx)?.patch_pool_spec(name, serde_json::json!({ "writeEnabled": write_enabled })).await.map_err(gql)?))
326        }
327    }
328
329    pub type BreatheSchema = Schema<Query, Mutation, EmptySubscription>;
330
331    #[must_use]
332    pub fn schema(store: SharedStore) -> BreatheSchema {
333        Schema::build(Query, Mutation, EmptySubscription).data(store).finish()
334    }
335}
336
337// ─────────────────────────────── gRPC ──────────────────────────────
338
339/// The gRPC surface (tonic) over the same facade — now with TYPED proto messages
340/// (no JSON-string envelope). `proto/breathe.proto` is generated by grpc-forge
341/// from `spec/breathe.openapi.yaml`; pbjson makes the messages serde-capable, so
342/// the facade's `serde_json::Value` bridges straight into the typed responses via
343/// [`typed`]. The handler is the one place breathe legitimately hand-serves (a
344/// generic `kube::Api<T>` dispatch) — it just maps facade JSON ↔ typed proto.
345pub mod grpc {
346    use super::{DimensionId, SharedStore, StoreError};
347    use serde_json::Value;
348    use tonic::{Request, Response, Status};
349
350    /// The generated proto messages, enums, and the service definition, plus the
351    /// pbjson serde impls. A submodule so the proto `BandKind` enum doesn't clash
352    /// with the facade's [`super::DimensionId`].
353    pub mod pb {
354        tonic::include_proto!("breathe.v1");
355        include!(concat!(env!("OUT_DIR"), "/breathe.v1.serde.rs"));
356    }
357
358    fn st(e: StoreError) -> Status {
359        match e {
360            StoreError::BadRequest(m) => Status::invalid_argument(m),
361            other => Status::internal(other.to_string()),
362        }
363    }
364
365    /// Map a facade JSON `Value` into a typed proto response. pbjson is STRICT:
366    /// a parse failure means the live CRD JSON drifted from
367    /// `spec/breathe.openapi.yaml` — surfaced as a typed error, never a silent
368    /// wrong answer (the spec-first standard keeps the two in sync).
369    fn typed<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, Status> {
370        serde_json::from_value(v)
371            .map_err(|e| Status::internal(format!("response did not match the typed schema (spec drift?): {e}")))
372    }
373
374    fn opt_ns(s: String) -> Option<String> {
375        if s.is_empty() { None } else { Some(s) }
376    }
377
378    /// proto `BandKind` (an `i32` enum on the wire) → the canonical
379    /// [`DimensionId`].
380    ///
381    /// The match over `P` is exhaustive apart from `Unspecified`, so adding a
382    /// dimension to the proto without mapping it here is `E0004` rather than a
383    /// gRPC method that silently rejects a kind the rest of the substrate
384    /// supports — which is exactly how five kinds stayed unreachable.
385    pub fn kind_of(k: i32) -> Result<DimensionId, Status> {
386        use pb::BandKind as P;
387        let p = P::try_from(k).map_err(|_| Status::invalid_argument("unknown band kind"))?;
388        match p {
389            P::Memory => Ok(DimensionId::Memory),
390            P::Cpu => Ok(DimensionId::Cpu),
391            P::Storage => Ok(DimensionId::Storage),
392            P::Replica => Ok(DimensionId::Replica),
393            P::Arc => Ok(DimensionId::Arc),
394            P::Cgroup => Ok(DimensionId::Cgroup),
395            P::CgroupCpu => Ok(DimensionId::CgroupCpu),
396            P::HostParam => Ok(DimensionId::HostParam),
397            P::KubeParam => Ok(DimensionId::KubeParam),
398            P::AppParam => Ok(DimensionId::AppParam),
399            P::Request => Ok(DimensionId::Request),
400            P::Unspecified => Err(Status::invalid_argument("band kind unspecified")),
401        }
402    }
403
404    pub struct GrpcService {
405        pub store: SharedStore,
406    }
407
408    #[tonic::async_trait]
409    impl pb::breathe_server::Breathe for GrpcService {
410        async fn band_list(&self, req: Request<pb::BandListRequest>) -> Result<Response<pb::BandListResponse>, Status> {
411            let r = req.into_inner();
412            let v = self.store.list_bands(kind_of(r.kind)?, opt_ns(r.namespace)).await.map_err(st)?;
413            Ok(Response::new(pb::BandListResponse { items: typed(v)? }))
414        }
415        async fn band_get(&self, req: Request<pb::BandGetRequest>) -> Result<Response<pb::Band>, Status> {
416            let r = req.into_inner();
417            let v = self.store.get_band(kind_of(r.kind)?, r.namespace, r.name).await.map_err(st)?;
418            Ok(Response::new(typed(v)?))
419        }
420        async fn band_patch(&self, req: Request<pb::BandPatchRequest>) -> Result<Response<pb::Band>, Status> {
421            let r = req.into_inner();
422            // BandSpec scalars are proto3 `optional` (field presence): pbjson emits
423            // the fields the client SET (Some — incl. a zero like dryRun=false) and
424            // omits the rest (None) → correct RFC-7386 merge: present writes, absent
425            // leaves unchanged. (Without presence, proto3 would drop zero values.)
426            let spec = serde_json::to_value(r.body.unwrap_or_default()).map_err(|e| Status::internal(e.to_string()))?;
427            // Same border check as the REST and MCP surfaces: a go-live must name
428            // its author. Refusing here keeps all four surfaces answering
429            // identically, instead of gRPC being the one that writes a CR the
430            // controller will only ever resolve to a fail-safe shadow.
431            if spec["writeIntent"]["intent"] == "write"
432                && spec["writeIntent"]["authorizedBy"].as_str().map(str::trim).unwrap_or_default().is_empty()
433            {
434                return Err(Status::invalid_argument(
435                    "writeIntent.intent=write requires authorizedBy — a live carve must name who \
436                     authorized it. Nothing was written.",
437                ));
438            }
439            let v = self.store.patch_band_spec(kind_of(r.kind)?, r.namespace, r.name, spec).await.map_err(st)?;
440            Ok(Response::new(typed(v)?))
441        }
442        /// RETIRED for eight of the ten kinds — see [`super::set_dry_run`]. Only
443        /// `host-param` and `kube-param` read `spec.dryRun`; elsewhere this
444        /// returns `FAILED_PRECONDITION` rather than an OK for a write that
445        /// changes nothing.
446        async fn band_set_dry_run(&self, req: Request<pb::BandSetDryRunRequest>) -> Result<Response<pb::Band>, Status> {
447            let r = req.into_inner();
448            let k = kind_of(r.kind)?;
449            if !k.dry_run_is_honored() {
450                return Err(Status::failed_precondition(
451                    "spec.dryRun has no effect on this band kind (retired breathe@76924b0, 2026-06-19); \
452                     set BandSpec.write_intent via BandPatch instead. Only host-param and kube-param \
453                     read dryRun. Nothing was written.",
454                ));
455            }
456            let v = self.store.patch_band_spec(k, r.namespace, r.name, serde_json::json!({ "dryRun": r.dry_run })).await.map_err(st)?;
457            Ok(Response::new(typed(v)?))
458        }
459        async fn catalog_list(&self, _req: Request<pb::CatalogListRequest>) -> Result<Response<pb::Catalog>, Status> {
460            Ok(Response::new(typed(self.store.catalog())?))
461        }
462        async fn nodepool_list(&self, _req: Request<pb::NodepoolListRequest>) -> Result<Response<pb::NodepoolListResponse>, Status> {
463            let v = self.store.list_pools().await.map_err(st)?;
464            Ok(Response::new(pb::NodepoolListResponse { items: typed(v)? }))
465        }
466        async fn nodepool_get(&self, req: Request<pb::NodepoolGetRequest>) -> Result<Response<pb::NodePool>, Status> {
467            let v = self.store.get_pool(req.into_inner().name).await.map_err(st)?;
468            Ok(Response::new(typed(v)?))
469        }
470        async fn nodepool_set_write_enabled(&self, req: Request<pb::NodepoolSetWriteEnabledRequest>) -> Result<Response<pb::NodePool>, Status> {
471            let r = req.into_inner();
472            let v = self.store.patch_pool_spec(r.name, serde_json::json!({ "writeEnabled": r.write_enabled })).await.map_err(st)?;
473            Ok(Response::new(typed(v)?))
474        }
475        async fn healthz(&self, _req: Request<pb::HealthzRequest>) -> Result<Response<::pbjson_types::Empty>, Status> {
476            Ok(Response::new(::pbjson_types::Empty {}))
477        }
478    }
479
480    #[must_use]
481    pub fn server(store: SharedStore) -> pb::breathe_server::BreatheServer<GrpcService> {
482        pb::breathe_server::BreatheServer::new(GrpcService { store })
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use async_trait::async_trait;
490    use axum::body::Body;
491    use axum::http::Request;
492    use http_body_util::BodyExt;
493    use std::sync::Mutex;
494    use tower::ServiceExt;
495
496    #[derive(Default)]
497    struct MockStore {
498        patches: Mutex<Vec<(String, Value)>>,
499    }
500    #[async_trait]
501    impl BreatheStore for MockStore {
502        async fn list_bands(&self, kind: DimensionId, _ns: Option<String>) -> Result<Value, StoreError> {
503            Ok(json!([{ "kind": kind.as_str() }]))
504        }
505        async fn get_band(&self, _kind: DimensionId, ns: String, name: String) -> Result<Value, StoreError> {
506            // schema-faithful Band JSON (what the real KubeStore serializes): the
507            // typed gRPC surface deserializes this strictly into pb::Band.
508            Ok(json!({
509                "apiVersion": "breathe.pleme.io/v1",
510                "kind": "ArcBand",
511                "metadata": { "name": name, "namespace": ns, "resourceVersion": "42" },
512                "spec": { "setpoint": 0.8, "dryRun": false },
513                "status": { "phase": "Holding" }
514            }))
515        }
516        async fn patch_band_spec(&self, _k: DimensionId, _ns: String, name: String, spec: Value) -> Result<Value, StoreError> {
517            self.patches.lock().unwrap().push((name, spec.clone()));
518            Ok(json!({ "spec": spec }))
519        }
520        async fn annotate_band(&self, _k: DimensionId, _ns: String, name: String, ann: Value) -> Result<Value, StoreError> {
521            self.patches.lock().unwrap().push((name, ann.clone()));
522            Ok(json!({ "metadata": { "annotations": ann } }))
523        }
524        async fn list_pools(&self) -> Result<Value, StoreError> {
525            Ok(json!([{ "metadata": { "name": "rio" }, "spec": { "nodeName": "rio", "arcMaxGiB": 6 } }]))
526        }
527        async fn get_pool(&self, name: String) -> Result<Value, StoreError> {
528            Ok(json!({
529                "apiVersion": "breathe.pleme.io/v1",
530                "kind": "BreatheNodePool",
531                "metadata": { "name": name },
532                "spec": { "nodeName": name, "arcMaxGiB": 6, "writeEnabled": true },
533                "status": { "phase": "Active" }
534            }))
535        }
536        async fn patch_pool_spec(&self, name: String, spec: Value) -> Result<Value, StoreError> {
537            self.patches.lock().unwrap().push((name, spec.clone()));
538            Ok(json!({ "spec": spec }))
539        }
540        async fn list_postures(&self) -> Result<Value, StoreError> {
541            Ok(json!([{ "metadata": { "name": "platform-default" }, "spec": { "setpoint": 0.8 } }]))
542        }
543        async fn get_posture(&self, name: String) -> Result<Value, StoreError> {
544            Ok(json!({
545                "apiVersion": "breathe.pleme.io/v1",
546                "kind": "BreathePosture",
547                "metadata": { "name": name },
548                "spec": { "setpoint": 0.8, "growAbove": 0.85, "growFactor": 1.25, "shrinkBelow": 0.7, "shrinkFactor": 0.9, "cooldownSeconds": 600, "maxStalenessSeconds": 120, "disruptionPolicy": "restartFreeOnly" }
549            }))
550        }
551        async fn patch_posture_spec(&self, name: String, spec: Value) -> Result<Value, StoreError> {
552            self.patches.lock().unwrap().push((name, spec.clone()));
553            Ok(json!({ "spec": spec }))
554        }
555        fn catalog(&self) -> Value {
556            breathe_facade::catalog_json()
557        }
558    }
559
560    async fn body_json(resp: Response) -> Value {
561        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
562        serde_json::from_slice(&bytes).unwrap()
563    }
564
565    /// The REST half of the D3 fix: an `arc` band does not read `dryRun`, so the
566    /// route refuses instead of returning 200 for a patch that changes nothing.
567    /// (It used to return 200 and land the patch — the test that asserted this
568    /// encoded the false model and stayed green through the whole defect.)
569    #[tokio::test]
570    async fn set_dry_run_route_refuses_where_the_field_is_inert() {
571        let mock = Arc::new(MockStore::default());
572        let app = router(mock.clone());
573        let resp = app
574            .oneshot(
575                Request::builder()
576                    .method("PATCH")
577                    .uri("/api/v1/bands/arc/pangea-system/rio-arc/dry-run")
578                    .header("content-type", "application/json")
579                    .body(Body::from(r#"{"dryRun":false}"#))
580                    .unwrap(),
581            )
582            .await
583            .unwrap();
584        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
585        assert!(mock.patches.lock().unwrap().is_empty(), "a refused call must not reach the store");
586    }
587
588    /// …and still applies on the two kinds that genuinely read it.
589    #[tokio::test]
590    async fn set_dry_run_route_applies_on_a_param_kind() {
591        let mock = Arc::new(MockStore::default());
592        let app = router(mock.clone());
593        let resp = app
594            .oneshot(
595                Request::builder()
596                    .method("PATCH")
597                    .uri("/api/v1/bands/host-param/camelot/vm-dirty/dry-run")
598                    .header("content-type", "application/json")
599                    .body(Body::from(r#"{"dryRun":true}"#))
600                    .unwrap(),
601            )
602            .await
603            .unwrap();
604        assert_eq!(resp.status(), StatusCode::OK);
605        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": true }));
606    }
607
608    /// Every shipped dimension is routable. Five of these ten returned 400
609    /// "unknown band kind" before this stage, despite shipping as CRDs.
610    #[tokio::test]
611    async fn every_shipped_dimension_is_routable() {
612        for kind in DimensionId::ALL {
613            let app = router(Arc::new(MockStore::default()));
614            let uri = ["/api/v1/bands/", kind.as_str()].concat();
615            let resp = app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()).await.unwrap();
616            assert_eq!(resp.status(), StatusCode::OK, "{kind} must be routable");
617        }
618    }
619
620    #[tokio::test]
621    async fn write_intent_route_writes_the_intent_and_refuses_an_unattributed_go_live() {
622        let mock = Arc::new(MockStore::default());
623        let app = router(mock.clone());
624        let patch_intent = |app: Router, body: &'static str| async move {
625            app.oneshot(
626                Request::builder()
627                    .method("PATCH")
628                    .uri("/api/v1/bands/cpu/camelot/coredns/write-intent")
629                    .header("content-type", "application/json")
630                    .body(Body::from(body))
631                    .unwrap(),
632            )
633            .await
634            .unwrap()
635        };
636        let resp = patch_intent(app.clone(), r#"{"intent":"write"}"#).await;
637        assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "an unattributed go-live is refused");
638        assert!(mock.patches.lock().unwrap().is_empty());
639
640        let resp = patch_intent(app, r#"{"intent":"write","authorizedBy":"drzzln 2026-07-26"}"#).await;
641        assert_eq!(resp.status(), StatusCode::OK);
642        assert_eq!(
643            mock.patches.lock().unwrap()[0].1,
644            json!({ "writeIntent": { "intent": "write", "authorizedBy": "drzzln 2026-07-26" } })
645        );
646    }
647
648    #[tokio::test]
649    async fn confirm_route_sets_the_operator_annotation() {
650        let mock = Arc::new(MockStore::default());
651        let app = router(mock.clone());
652        let resp = app
653            .oneshot(
654                Request::builder()
655                    .method("PATCH")
656                    .uri("/api/v1/bands/memory/camelot/b/confirm")
657                    .header("content-type", "application/json")
658                    .body(Body::from(r#"{"confirmed":true}"#))
659                    .unwrap(),
660            )
661            .await
662            .unwrap();
663        assert_eq!(resp.status(), StatusCode::OK);
664        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ breathe_provider::CONFIRMED_ANNOTATION: "true" }));
665    }
666
667    #[tokio::test]
668    async fn unknown_band_kind_is_400() {
669        let app = router(Arc::new(MockStore::default()));
670        let resp = app
671            .oneshot(Request::builder().uri("/api/v1/bands/bogus").body(Body::empty()).unwrap())
672            .await
673            .unwrap();
674        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
675    }
676
677    #[tokio::test]
678    async fn catalog_route_returns_all_dimensions() {
679        let app = router(Arc::new(MockStore::default()));
680        let resp = app
681            .oneshot(Request::builder().uri("/api/v1/catalog").body(Body::empty()).unwrap())
682            .await
683            .unwrap();
684        assert_eq!(resp.status(), StatusCode::OK);
685        let v = body_json(resp).await;
686        // every catalogued dimension is exposed on the REST surface (not a literal —
687        // tracks breathe_catalog::ALL_DIMENSIONS so it never goes stale).
688        assert_eq!(v["dimensions"].as_array().unwrap().len(), breathe_catalog::ALL_DIMENSIONS.len());
689    }
690
691    #[tokio::test]
692    async fn write_enabled_route_patches_the_pool() {
693        let mock = Arc::new(MockStore::default());
694        let app = router(mock.clone());
695        let resp = app
696            .oneshot(
697                Request::builder()
698                    .method("PATCH")
699                    .uri("/api/v1/nodepools/rio/write-enabled")
700                    .header("content-type", "application/json")
701                    .body(Body::from(r#"{"writeEnabled":true}"#))
702                    .unwrap(),
703            )
704            .await
705            .unwrap();
706        assert_eq!(resp.status(), StatusCode::OK);
707        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeEnabled": true }));
708    }
709
710    #[tokio::test]
711    async fn graphql_set_dry_run_errors_where_the_field_is_inert() {
712        let mock = Arc::new(MockStore::default());
713        let schema = graphql::schema(mock.clone());
714        let resp = schema
715            .execute(r#"mutation { setDryRun(kind:"arc", namespace:"pangea-system", name:"rio-arc", dryRun:false) }"#)
716            .await;
717        assert!(!resp.errors.is_empty(), "an inert dryRun write must not report success");
718        assert!(mock.patches.lock().unwrap().is_empty());
719    }
720
721    #[tokio::test]
722    async fn graphql_set_write_intent_mutation_patches_the_band() {
723        let mock = Arc::new(MockStore::default());
724        let schema = graphql::schema(mock.clone());
725        let resp = schema
726            .execute(r#"mutation { setWriteIntent(kind:"cpu", namespace:"camelot", name:"coredns", intent:"observe") }"#)
727            .await;
728        assert!(resp.errors.is_empty(), "{:?}", resp.errors);
729        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeIntent": { "intent": "observe" } }));
730    }
731
732    /// GraphQL reaches the five dimensions it could not name before.
733    #[tokio::test]
734    async fn graphql_reaches_the_previously_invisible_kinds() {
735        let schema = graphql::schema(Arc::new(MockStore::default()));
736        for kind in ["cgroup-cpu", "host-param", "kube-param", "app-param", "replica"] {
737            let q = ["{ bands(kind:\"", kind, "\") }"].concat();
738            let resp = schema.execute(q).await;
739            assert!(resp.errors.is_empty(), "{kind}: {:?}", resp.errors);
740        }
741    }
742
743    #[tokio::test]
744    async fn graphql_catalog_query_returns_dimensions() {
745        let schema = graphql::schema(Arc::new(MockStore::default()));
746        let resp = schema.execute("{ catalog }").await;
747        assert!(resp.errors.is_empty(), "{:?}", resp.errors);
748        assert!(resp.data.to_string().contains("dimensions"));
749    }
750
751    #[tokio::test]
752    async fn grpc_set_write_enabled_returns_typed_nodepool() {
753        use grpc::pb::breathe_server::Breathe;
754        let mock = Arc::new(MockStore::default());
755        let svc = grpc::GrpcService { store: mock.clone() };
756        let resp = svc
757            .nodepool_set_write_enabled(tonic::Request::new(grpc::pb::NodepoolSetWriteEnabledRequest { name: "rio".into(), write_enabled: true }))
758            .await
759            .unwrap();
760        // the response is a TYPED NodePool, not a JSON string envelope.
761        assert!(resp.into_inner().spec.unwrap().write_enabled);
762        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeEnabled": true }));
763    }
764
765    #[tokio::test]
766    async fn grpc_band_get_returns_typed_band() {
767        use grpc::pb::breathe_server::Breathe;
768        let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
769        let resp = svc
770            .band_get(tonic::Request::new(grpc::pb::BandGetRequest {
771                kind: grpc::pb::BandKind::Arc as i32,
772                namespace: "pangea-system".into(),
773                name: "rio-arc".into(),
774            }))
775            .await
776            .unwrap();
777        let band = resp.into_inner();
778        assert_eq!(band.api_version, "breathe.pleme.io/v1");
779        // BandSpec scalars are `optional` (field presence) → Option<f64> here.
780        assert!((band.spec.unwrap().setpoint.unwrap() - 0.8).abs() < 1e-9);
781        assert_eq!(band.status.unwrap().phase, "Holding");
782    }
783
784    #[tokio::test]
785    async fn grpc_band_patch_transmits_zero_values_via_field_presence() {
786        // the merge-patch fix: a client setting dryRun=false (a zero value) over
787        // a typed BandSpec must actually transmit it — proto3 `optional` presence
788        // makes Some(false) serialize as `{"dryRun": false}`, not get dropped.
789        use grpc::pb::breathe_server::Breathe;
790        let mock = Arc::new(MockStore::default());
791        let svc = grpc::GrpcService { store: mock.clone() };
792        let body = grpc::pb::BandSpec { dry_run: Some(false), ..Default::default() };
793        svc.band_patch(tonic::Request::new(grpc::pb::BandPatchRequest {
794            kind: grpc::pb::BandKind::Arc as i32,
795            namespace: "pangea-system".into(),
796            name: "rio-arc".into(),
797            body: Some(body),
798        }))
799        .await
800        .unwrap();
801        // exactly the one set field reached the facade — no other scalars leaked in.
802        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": false }));
803    }
804
805    #[tokio::test]
806    async fn grpc_band_list_returns_typed_items() {
807        use grpc::pb::breathe_server::Breathe;
808        let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
809        let resp = svc
810            .band_list(tonic::Request::new(grpc::pb::BandListRequest {
811                kind: grpc::pb::BandKind::Arc as i32,
812                namespace: String::new(),
813            }))
814            .await
815            .unwrap();
816        assert_eq!(resp.into_inner().items.len(), 1);
817    }
818
819    #[tokio::test]
820    async fn grpc_catalog_list_returns_typed_catalog() {
821        // exercises the full bridge incl. nullable `upstreamMirror` (proto3 string).
822        use grpc::pb::breathe_server::Breathe;
823        let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
824        let resp = svc.catalog_list(tonic::Request::new(grpc::pb::CatalogListRequest {})).await.unwrap();
825        let cat = resp.into_inner();
826        // the gRPC bridge preserves every catalogued dimension (tracks the canonical
827        // count, never a literal — see breathe_catalog::ALL_DIMENSIONS).
828        assert_eq!(cat.dimensions.len(), breathe_catalog::ALL_DIMENSIONS.len());
829        assert!(cat.dimensions.iter().any(|d| d.id == "arc" && d.is_host));
830        assert!(cat.dimensions.iter().any(|d| d.id == "cgroup-cpu" && d.is_host));
831        assert!(cat.dimensions.iter().any(|d| d.id == "memory" && !d.is_host));
832    }
833
834    #[tokio::test]
835    async fn grpc_set_dry_run_refuses_where_inert_and_applies_where_honored() {
836        use grpc::pb::breathe_server::Breathe;
837        let mock = Arc::new(MockStore::default());
838        let svc = grpc::GrpcService { store: mock.clone() };
839        let call = |kind: grpc::pb::BandKind| {
840            tonic::Request::new(grpc::pb::BandSetDryRunRequest {
841                kind: kind as i32,
842                namespace: "pangea-system".into(),
843                name: "b".into(),
844                dry_run: false,
845            })
846        };
847        let err = svc.band_set_dry_run(call(grpc::pb::BandKind::Arc)).await.unwrap_err();
848        assert_eq!(err.code(), tonic::Code::FailedPrecondition);
849        assert!(mock.patches.lock().unwrap().is_empty());
850
851        let resp = svc.band_set_dry_run(call(grpc::pb::BandKind::HostParam)).await.unwrap();
852        assert_eq!(resp.into_inner().spec.unwrap().dry_run, Some(false));
853        assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": false }));
854    }
855
856    /// gRPC can finally author `writeIntent` at all — its `BandSpec` carried only
857    /// `dry_run`, which is inert on eight kinds, so this surface had no way to
858    /// hold or release a band. And it applies the same unattributed-go-live
859    /// refusal as REST and MCP, so all four surfaces answer identically.
860    #[tokio::test]
861    async fn grpc_band_patch_carries_write_intent_and_refuses_an_unattributed_go_live() {
862        use grpc::pb::breathe_server::Breathe;
863        let mock = Arc::new(MockStore::default());
864        let svc = grpc::GrpcService { store: mock.clone() };
865        let call = |wi: grpc::pb::WriteIntent| {
866            tonic::Request::new(grpc::pb::BandPatchRequest {
867                kind: grpc::pb::BandKind::Cpu as i32,
868                namespace: "camelot".into(),
869                name: "coredns".into(),
870                body: Some(grpc::pb::BandSpec { write_intent: Some(wi), ..Default::default() }),
871            })
872        };
873        let unattributed =
874            grpc::pb::WriteIntent { intent: "write".into(), confirm_after_seconds: None, authorized_by: None };
875        let err = svc.band_patch(call(unattributed)).await.unwrap_err();
876        assert_eq!(err.code(), tonic::Code::InvalidArgument);
877        assert!(mock.patches.lock().unwrap().is_empty(), "a refused go-live must not reach the store");
878
879        let observe =
880            grpc::pb::WriteIntent { intent: "observe".into(), confirm_after_seconds: None, authorized_by: None };
881        svc.band_patch(call(observe)).await.unwrap();
882        assert_eq!(mock.patches.lock().unwrap()[0].1["writeIntent"]["intent"], "observe");
883    }
884
885    /// **The spec-drift gate.** `spec/breathe.openapi.yaml` calls itself the
886    /// single source of truth and forge-gen emits SDKs and docs from it, so a
887    /// stale `BandKind` enum there ships into every generated artifact — which is
888    /// how the five-arm list survived five new dimensions. This parses the real
889    /// file (which also proves it is still valid YAML) and pins its enum, and its
890    /// two `/bands/{kind}/…` gate routes, to the code.
891    ///
892    /// Tier: **CI forcing-function**. It pins the enum and the route set, not the
893    /// prose around them — a `summary:` can still go stale without failing here.
894    #[test]
895    fn openapi_spec_band_kinds_match_the_code() {
896        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../spec/breathe.openapi.yaml");
897        let raw = std::fs::read_to_string(path).expect("the spec is where the crate doc says it is");
898        let spec: serde_yaml::Value = serde_yaml::from_str(&raw).expect("spec/breathe.openapi.yaml must be valid YAML");
899
900        let declared: Vec<String> = spec["components"]["schemas"]["BandKind"]["enum"]
901            .as_sequence()
902            .expect("BandKind.enum is a sequence")
903            .iter()
904            .map(|v| v.as_str().expect("each enum value is a string").to_owned())
905            .collect();
906        let canonical: Vec<String> = DimensionId::ALL.iter().map(|d| d.as_str().to_owned()).collect();
907        assert_eq!(declared, canonical, "the OpenAPI BandKind enum has drifted from DimensionId::ALL");
908
909        // The authorization routes the surface actually serves must exist in the
910        // spec, or a generated SDK cannot reach the gate that replaced dryRun.
911        let paths = spec["paths"].as_mapping().expect("paths is a mapping");
912        for route in [
913            "/api/v1/bands/{kind}/{namespace}/{name}/write-intent",
914            "/api/v1/bands/{kind}/{namespace}/{name}/confirm",
915        ] {
916            assert!(
917                paths.contains_key(serde_yaml::Value::from(route)),
918                "{route} is served by the router but absent from the spec"
919            );
920        }
921    }
922
923    /// The five proto enum values added this stage map to the five dimensions
924    /// gRPC could not previously address — and the pre-existing numbers 1-5 are
925    /// unchanged, so old clients keep working.
926    #[test]
927    fn grpc_band_kind_covers_every_dimension_without_renumbering() {
928        use grpc::pb::BandKind as P;
929        for (p, d) in [
930            (P::Memory, DimensionId::Memory),
931            (P::Cpu, DimensionId::Cpu),
932            (P::Storage, DimensionId::Storage),
933            (P::Replica, DimensionId::Replica),
934            (P::Arc, DimensionId::Arc),
935            (P::Cgroup, DimensionId::Cgroup),
936            (P::CgroupCpu, DimensionId::CgroupCpu),
937            (P::HostParam, DimensionId::HostParam),
938            (P::KubeParam, DimensionId::KubeParam),
939            (P::AppParam, DimensionId::AppParam),
940            (P::Request, DimensionId::Request),
941        ] {
942            assert_eq!(grpc::kind_of(p as i32).unwrap(), d);
943        }
944        // the frozen wire numbers — renumbering these silently breaks every client
945        assert_eq!(P::Memory as i32, 1);
946        assert_eq!(P::Cpu as i32, 2);
947        assert_eq!(P::Storage as i32, 3);
948        assert_eq!(P::Arc as i32, 4);
949        assert_eq!(P::Cgroup as i32, 5);
950        // The eleventh kind took the next free number rather than reusing one.
951        assert_eq!(P::Request as i32, 11);
952    }
953
954    /// The wire enum must stay a BIJECTION with `DimensionId::ALL`. The loop
955    /// above proves every proto arm maps somewhere; this proves no dimension is
956    /// missing from the proto — the direction that actually goes wrong, and the
957    /// one that left five kinds unreachable over gRPC before.
958    #[test]
959    fn every_dimension_has_a_wire_number() {
960        for d in DimensionId::ALL {
961            let found = (1..=64).any(|n| grpc::kind_of(n).ok() == Some(d));
962            assert!(found, "dimension {d} has no BandKind wire number — add one, never renumber");
963        }
964    }
965}