Skip to main content

boatramp_server/
function_api.rs

1//! The function management API (FA-1/FA-2): list function summaries and
2//! deploy, version, alias, roll back, and remove function definitions. This is
3//! the always-on control surface (no wasm engine required); the runtime that
4//! invokes functions lives in `function_runtime`. Pulls the serve-pipeline
5//! scope in via `use super::*`.
6
7use super::*;
8
9use boatramp_core::function::FunctionSummary;
10
11/// `?site=` filter for the functions view.
12#[derive(serde::Deserialize)]
13pub(super) struct FunctionQuery {
14    site: Option<String>,
15}
16
17/// `GET /api/functions[?site=…]` — the derived, **read-only** site-scoped function
18/// view (FA-1): desugar each site's active manifest into functions + triggers and
19/// resolve component paths to their blob-hash version ids. A pure projection of the
20/// manifests — the serve path is untouched, so a site's handlers are unchanged.
21/// `system·read`.
22pub(super) async fn list_functions(
23    State(deploy): State<DeployStore>,
24    Extension(project): axum::extract::Extension<ProjectContext>,
25    axum::extract::Query(query): axum::extract::Query<FunctionQuery>,
26) -> Response {
27    use boatramp_core::function;
28    let sites = match &query.site {
29        Some(s) => vec![s.clone()],
30        None => match deploy.all_sites(project.as_ref()).await {
31            Ok(s) => s,
32            Err(err) => return deploy_error_response(err),
33        },
34    };
35    let mut out: Vec<FunctionSummary> = Vec::new();
36    for site in sites {
37        let manifest = match deploy.current_manifest(project.as_ref(), &site).await {
38            Ok(Some(m)) => m,
39            Ok(None) => continue,
40            Err(err) => return deploy_error_response(err),
41        };
42        let (specs, triggers) = function::desugar(&manifest.config);
43        for f in function::materialize(&specs, &site, &manifest.files, 0) {
44            let trigs = triggers
45                .iter()
46                .filter(|t| t.target.as_ref().map(|r| r.name.as_str()) == Some(f.name.as_str()))
47                .map(std::string::ToString::to_string)
48                .collect();
49            out.push(FunctionSummary {
50                name: format!("{site}/{}", f.name),
51                owner: format!("site:{site}"),
52                runtime: f.config.runtime.as_str().to_string(),
53                version: f.active,
54                triggers: trigs,
55            });
56        }
57    }
58    // Top-level (independently-stored) functions — FA-2. A `?site=` filter is
59    // site-scoped only, so it excludes these.
60    if query.site.is_none() {
61        match deploy.list_stored_functions(project.as_ref()).await {
62            Ok(stored) => {
63                for f in stored {
64                    out.push(FunctionSummary {
65                        name: f.name.clone(),
66                        owner: f.owner.to_string(),
67                        runtime: f.config.runtime.as_str().to_string(),
68                        version: f.active,
69                        // A top-level function has a stable invoke URL (FA-3).
70                        triggers: vec![format!("invoke {}", f.name)],
71                    });
72                }
73            }
74            Err(err) => return deploy_error_response(err),
75        }
76    }
77    Json(out).into_response()
78}
79
80/// Body of `PUT /api/functions/:name` — deploy a version of a top-level function.
81#[derive(serde::Deserialize)]
82pub(super) struct FunctionUpsert {
83    /// The component blob hash (uploaded first via `PUT /api/blobs/<hash>`).
84    pub(super) component: String,
85    /// Binding/capability config.
86    #[serde(default)]
87    pub(super) config: boatramp_core::function::FunctionConfig,
88    /// Version lifecycle (defaults to `deploy-pinned`; top-level functions choose
89    /// `independent`).
90    #[serde(default)]
91    pub(super) lifecycle: boatramp_core::function::Lifecycle,
92}
93
94/// Query for `PUT /api/functions/:name`.
95#[derive(serde::Deserialize, Default)]
96pub(super) struct DeployFunctionQuery {
97    /// When this function is an **already-registered** federation subgraph, whether to refresh
98    /// its registered SDL from the new version and block the deploy if the new schema no longer
99    /// composes (default `true`). Set `false` to deploy without touching the registry — the
100    /// escape hatch for a coordinated multi-subgraph migration.
101    #[serde(default)]
102    register_subgraph: Option<bool>,
103}
104
105/// Invoke `f` with the bytes of every `boatramp:function-manifest` custom section in a
106/// component — descending into its embedded core module(s), where the guest's `#[link_section]`
107/// manifest lives. Best-effort: a malformed component just yields nothing.
108#[cfg(feature = "handlers")]
109fn scan_manifest_sections(bytes: &[u8], f: &mut impl FnMut(&[u8])) {
110    use wasmparser::{Parser, Payload};
111    for payload in Parser::new(0).parse_all(bytes) {
112        match payload {
113            Ok(Payload::CustomSection(reader)) if reader.name() == "boatramp:function-manifest" => {
114                f(reader.data());
115            }
116            Ok(Payload::ModuleSection {
117                unchecked_range, ..
118            }) => scan_manifest_sections(&bytes[unchecked_range], f),
119            Ok(_) => {}
120            Err(_) => return,
121        }
122    }
123}
124
125/// Whether a component **self-declares** a GraphQL federation subgraph: any NDJSON line of its
126/// `boatramp:function-manifest` section carries `"subgraph": true` (the shim's
127/// `#[graphql_subgraph]` emits this). This is the intent signal that lets a first deploy
128/// auto-register the subgraph — a function that does not declare it is never touched.
129#[cfg(feature = "handlers")]
130fn component_declares_subgraph(component: &[u8]) -> bool {
131    let mut declared = false;
132    scan_manifest_sections(component, &mut |data| {
133        for line in data.split(|&b| b == b'\n') {
134            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
135                if v.get("subgraph").and_then(serde_json::Value::as_bool) == Some(true) {
136                    declared = true;
137                }
138            }
139        }
140    });
141    declared
142}
143
144/// Whether a component's `boatramp:function-manifest` self-declares that the route with request
145/// path `route` is a **streaming** handler (`#[handler(stream)]` emits `"streaming": true` on that
146/// function's manifest line). Matches by the http trigger's path (the part after an optional
147/// `METHOD `), so a multi-route component reports per route. Returns `false` when no line matches
148/// or none declares streaming — including for a guest built before the flag existed.
149#[cfg(feature = "handlers")]
150pub(crate) fn component_declares_streaming_route(component: &[u8], route: &str) -> bool {
151    let mut declared = false;
152    scan_manifest_sections(component, &mut |data| {
153        for line in data.split(|&b| b == b'\n') {
154            let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) else {
155                continue;
156            };
157            if v.get("streaming").and_then(serde_json::Value::as_bool) != Some(true) {
158                continue;
159            }
160            // This line is streaming — does one of its http triggers target `route`?
161            let Some(triggers) = v.get("triggers").and_then(serde_json::Value::as_array) else {
162                continue;
163            };
164            for t in triggers {
165                if t.get("on").and_then(serde_json::Value::as_str) != Some("http") {
166                    continue;
167                }
168                if let Some(r) = t.get("route").and_then(serde_json::Value::as_str) {
169                    // `route` is `"METHOD /path"` or `"/path"`; compare the path portion.
170                    let path = r.rsplit(char::is_whitespace).next().unwrap_or(r);
171                    if path == route {
172                        declared = true;
173                    }
174                }
175            }
176        }
177    });
178    declared
179}
180
181/// The stability of a capability feature — an honest, present-tense signal to operators and
182/// guest authors, and the seam the future deprecation clock extends (a deprecated capability is
183/// just this with a removal target). `Experimental` = new or off-by-default surface whose shape
184/// or behaviour may still change; `Stable` = safe to build on. Not a quality judgement — a
185/// stable capability isn't "better", it's *settled*.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
187#[serde(rename_all = "lowercase")]
188pub enum Lifecycle {
189    Stable,
190    Experimental,
191}
192
193impl Lifecycle {
194    /// Lowercase label for human output (matches the JSON serialization).
195    #[must_use]
196    pub fn as_str(self) -> &'static str {
197        match self {
198            Self::Stable => "stable",
199            Self::Experimental => "experimental",
200        }
201    }
202}
203
204/// A capability feature this host implements, paired with its stability. The `name` is the token
205/// a guest names in its manifest `requires`; admission checks the name, and `boatramp
206/// capabilities` shows the name + lifecycle so operators can see what's settled vs. provisional.
207#[derive(Debug, Clone, serde::Serialize)]
208pub struct CapabilityFeature {
209    pub name: &'static str,
210    pub lifecycle: Lifecycle,
211}
212
213/// The capability **features** this host build implements, with lifecycle — what a guest's
214/// manifest `requires` is checked against. The base grantable capabilities are always present
215/// with the handler engine (the `KNOWN_IMPORTS` tokens, all settled); the granular surface
216/// features are always-on in 0.3.0 except the correlated roll-up, which is cargo-gated. This is
217/// the admission side of the contract: availability lives in metadata, not the linkable WIT
218/// (PLAN v2).
219#[cfg(feature = "handlers")]
220pub fn host_capability_features_detailed() -> Vec<CapabilityFeature> {
221    use Lifecycle::{Experimental, Stable};
222    // Base grantable capabilities (the KNOWN_IMPORTS tokens) — the established interface grants.
223    let mut f: Vec<CapabilityFeature> = boatramp_core::config::known_imports()
224        .iter()
225        .map(|&name| CapabilityFeature {
226            name,
227            lifecycle: Stable,
228        })
229        .collect();
230    // Surface features beyond the base grants.
231    f.push(CapabilityFeature {
232        name: "sql-json",
233        lifecycle: Stable,
234    });
235    // pgvector `distance` is new and Postgres-only (fail-closed elsewhere); the vector surface is
236    // the most likely to grow more operators/index hints — flagged experimental until it settles.
237    f.push(CapabilityFeature {
238        name: "orm-vector",
239        lifecycle: Experimental,
240    });
241    // The `is_own()` / `own_first()` own-vs-base ranking (base-vs-override reads). Always compiled
242    // (a pure AST lowering into CASE, no deps); experimental until the shape settles.
243    f.push(CapabilityFeature {
244        name: "orm-own-pref",
245        lifecycle: Experimental,
246    });
247    f.push(CapabilityFeature {
248        name: "streaming",
249        lifecycle: Stable,
250    });
251    // The duplex/resumable session capability (`boatramp:handlers/session`, PLAN-session-primitive).
252    // Cargo-gated on `session`; experimental until the shape settles. A guest declares
253    // `requires = ["session"]` and a deploy against a host without it is refused cleanly.
254    if cfg!(feature = "session") {
255        f.push(CapabilityFeature {
256            name: "session",
257            lifecycle: Experimental,
258        });
259    }
260    // The `tenancy` (`present-token`) capability (Gap 3): an in-guest-verified emitter host-seals a
261    // tenant onto the async lane. Available whenever handlers are compiled (which pulls the
262    // messaging binding it rides); experimental until the shape settles. A guest declares
263    // `requires = ["tenancy"]`; a deploy against a host without it is refused cleanly.
264    if cfg!(feature = "handlers") {
265        f.push(CapabilityFeature {
266            name: "tenancy",
267            lifecycle: Experimental,
268        });
269    }
270    if cfg!(feature = "orm-subquery") {
271        // Correlated roll-ups ship off-by-default (the riskiest query surface) — experimental
272        // until the shape settles.
273        f.push(CapabilityFeature {
274            name: "orm-subquery",
275            lifecycle: Experimental,
276        });
277    }
278    f
279}
280
281/// The capability feature **names** this host implements — what a guest manifest `requires` is
282/// checked against. Names only; [`host_capability_features_detailed`] pairs them with lifecycle.
283#[cfg(feature = "handlers")]
284pub fn host_capability_features() -> Vec<&'static str> {
285    host_capability_features_detailed()
286        .into_iter()
287        .map(|c| c.name)
288        .collect()
289}
290
291/// The capability features a component's `boatramp:function-manifest` **requires** (any NDJSON
292/// line's `"requires": [...]`), deduped. A guest that uses a capability whose availability varies
293/// by host build declares it here so the deploy can fail loud on a host that lacks it.
294#[cfg(feature = "handlers")]
295pub fn component_requires(component: &[u8]) -> Vec<String> {
296    let mut reqs = Vec::new();
297    scan_manifest_sections(component, &mut |data| {
298        for line in data.split(|&b| b == b'\n') {
299            if let Ok(v) = serde_json::from_slice::<serde_json::Value>(line) {
300                if let Some(arr) = v.get("requires").and_then(serde_json::Value::as_array) {
301                    reqs.extend(arr.iter().filter_map(|r| r.as_str().map(str::to_string)));
302                }
303            }
304        }
305    });
306    reqs.sort();
307    reqs.dedup();
308    reqs
309}
310
311/// The capability features a component `requires` that this host does **not** implement — empty
312/// when the host can satisfy the component. A non-empty result is a fail-loud deploy rejection.
313#[cfg(feature = "handlers")]
314pub fn unmet_requires(component: &[u8]) -> Vec<String> {
315    let host = host_capability_features();
316    component_requires(component)
317        .into_iter()
318        .filter(|r| !host.contains(&r.as_str()))
319        .collect()
320}
321
322/// Keep the project's GraphQL supergraph correct across a function deploy. The function is a
323/// subgraph to (re)register when it is **already registered** *or* its component
324/// **self-declares** one (the shim marker); in either case introspect the **pending** version's
325/// `_service { sdl }` before its activation flips and **block** the deploy (an error response) if
326/// the new schema does not compose. So a subgraph auto-registers on first deploy, refreshes on
327/// each later deploy, and can never leave the supergraph stale or broken. An ordinary function
328/// (no registry entry, no marker) is untouched, `?register_subgraph=false` opts out, and a node
329/// with no engine degrades to a skip. `Ok(())` ⇒ proceed with the deploy.
330#[cfg(feature = "handlers")]
331async fn maybe_register_subgraph(
332    deploy: &DeployStore,
333    handlers: &HandlerRuntime,
334    project: boatramp_core::project::ProjectRef<'_>,
335    name: &str,
336    function: &boatramp_core::function::Function,
337    component: &str,
338    register: Option<bool>,
339) -> Result<(), Response> {
340    if register == Some(false) {
341        return Ok(()); // explicit opt-out (the coordinated-migration escape hatch)
342    }
343    let kv = deploy.kv().as_ref();
344    if !crate::graphql_registry::is_registered_subgraph(kv, project.as_str(), name).await {
345        // First deploy: only auto-register a component that declares itself a subgraph; any
346        // ordinary function (or an unreadable blob) deploys untouched.
347        match crate::handler_dispatch::read_blob_fully(deploy, component).await {
348            Ok(blob) if component_declares_subgraph(&blob) => {}
349            _ => return Ok(()),
350        }
351    }
352    let sdl = match handlers
353        .introspect_subgraph_sdl(deploy, project, function, component)
354        .await
355    {
356        Ok(sdl) => sdl,
357        // No engine on this node — skip rather than block; the SDL simply isn't (re)published.
358        Err(crate::function_runtime::SubgraphSdlError::Unavailable) => return Ok(()),
359        Err(crate::function_runtime::SubgraphSdlError::NotASubgraph) => {
360            return Err((
361                StatusCode::UNPROCESSABLE_ENTITY,
362                format!(
363                    "subgraph `{name}` does not answer `{{ _service {{ sdl }} }}`; deploy with \
364                     `?register_subgraph=false` to skip subgraph registration\n"
365                ),
366            )
367                .into_response())
368        }
369        Err(crate::function_runtime::SubgraphSdlError::InvokeFailed(msg)) => {
370            return Err((
371                StatusCode::BAD_GATEWAY,
372                format!("could not introspect subgraph `{name}`: {msg}\n"),
373            )
374                .into_response())
375        }
376    };
377    match crate::graphql_registry::publish(kv, project.as_str(), name, &sdl).await {
378        Ok(_) => Ok(()),
379        Err(crate::graphql_registry::PublishError::Composition(e)) => Err((
380            StatusCode::BAD_REQUEST,
381            format!(
382                "subgraph `{name}` does not compose: {e}\n(deploy with `?register_subgraph=false` \
383                 to skip, or unregister a conflicting subgraph first)\n"
384            ),
385        )
386            .into_response()),
387        Err(crate::graphql_registry::PublishError::Store(e)) => Err((
388            StatusCode::INTERNAL_SERVER_ERROR,
389            format!("registry store error: {e}\n"),
390        )
391            .into_response()),
392    }
393}
394
395/// No wasm engine in this build → no subgraph registry to maintain; the deploy proceeds unchanged.
396#[cfg(not(feature = "handlers"))]
397async fn maybe_register_subgraph(
398    _deploy: &DeployStore,
399    _handlers: &HandlerRuntime,
400    _project: boatramp_core::project::ProjectRef<'_>,
401    _name: &str,
402    _function: &boatramp_core::function::Function,
403    _component: &str,
404    _register: Option<bool>,
405) -> Result<(), Response> {
406    Ok(())
407}
408
409/// `PUT /api/functions/:name` (FA-2) — deploy a version of a top-level function.
410/// The component blob must already be uploaded. Creates the function if new;
411/// otherwise appends + activates the version (idempotent per component hash).
412/// `system·admin`.
413pub(super) async fn deploy_function(
414    State(deploy): State<DeployStore>,
415    Extension(project): axum::extract::Extension<ProjectContext>,
416    Extension(handlers): Extension<Arc<HandlerRuntime>>,
417    axum::extract::Query(q): axum::extract::Query<DeployFunctionQuery>,
418    Path(name): Path<String>,
419    Json(body): Json<FunctionUpsert>,
420) -> Response {
421    use boatramp_core::function::{Function, Owner};
422    if let Some(resp) = reject_invalid_name("function", &name) {
423        return resp;
424    }
425    // Fail loud at deploy on a `secrets` map the posture forbids: under the
426    // multi-tenant posture a bare / `env:` ref reads the operator's environment
427    // (cross-tenant host-env exfiltration). Refuse here with the same message the
428    // resolution-time backstop raises, so the tenant sees it now, not at first
429    // invocation. (Falls back to the fail-closed `false` on a no-op runtime.)
430    // Handlers-gated: without a wasm engine a function can't run, so there is no
431    // resolution-time path to guard at deploy.
432    #[cfg(feature = "handlers")]
433    if let Err(err) = crate::handler_dispatch::admit_secret_refs(
434        &body.config.secrets,
435        handlers.allow_env_secret_refs(),
436    ) {
437        return (
438            StatusCode::BAD_REQUEST,
439            format!("function secrets: {err}\n"),
440        )
441            .into_response();
442    }
443    match deploy.has_blob(&body.component).await {
444        Ok(true) => {}
445        Ok(false) => {
446            return (
447                StatusCode::BAD_REQUEST,
448                format!("component blob {} not uploaded\n", body.component),
449            )
450                .into_response()
451        }
452        Err(err) => return deploy_error_response(err),
453    }
454    let now = now_unix();
455    let f = match deploy.get_function(project.as_ref(), &name).await {
456        Ok(Some(mut existing)) => {
457            existing.config = body.config;
458            existing.upsert_version(&body.component, body.lifecycle, now);
459            existing
460        }
461        // A brand-new top-level function is owned by the (single, for now) default
462        // project; per-tenant ownership arrives with FA-4.
463        Ok(None) => Function::new(
464            name.clone(),
465            Owner::Project("default".to_string()),
466            &body.component,
467            body.config,
468            body.lifecycle,
469            now,
470        ),
471        Err(err) => return deploy_error_response(err),
472    };
473    // Before the new version becomes active: if this function is (or self-declares) a federation
474    // subgraph, (re)register its SDL and refuse the deploy if the new schema does not compose with
475    // the project's supergraph (a deploy must never leave the supergraph invalid or stale).
476    if let Err(resp) = maybe_register_subgraph(
477        &deploy,
478        &handlers,
479        project.as_ref(),
480        &name,
481        &f,
482        &body.component,
483        q.register_subgraph,
484    )
485    .await
486    {
487        return resp;
488    }
489    if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
490        return deploy_error_response(err);
491    }
492    Json(f).into_response()
493}
494
495/// Body of `POST /api/functions/:name/rollback`.
496#[derive(serde::Deserialize)]
497pub(super) struct RollbackBody {
498    pub(super) to: String,
499}
500
501/// `POST /api/functions/:name/rollback` (FA-2) — point active at a prior version.
502pub(super) async fn rollback_function(
503    State(deploy): State<DeployStore>,
504    Extension(project): axum::extract::Extension<ProjectContext>,
505    Path(name): Path<String>,
506    Json(body): Json<RollbackBody>,
507) -> Response {
508    match deploy.get_function(project.as_ref(), &name).await {
509        Ok(Some(mut f)) => match f.rollback(&body.to) {
510            Ok(()) => {
511                if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
512                    return deploy_error_response(err);
513                }
514                Json(f).into_response()
515            }
516            Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
517        },
518        Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
519        Err(err) => deploy_error_response(err),
520    }
521}
522
523/// Body of `PUT /api/functions/:name/aliases/:label`.
524#[derive(serde::Deserialize)]
525pub(super) struct AliasBody {
526    pub(super) version: String,
527}
528
529/// `PUT /api/functions/:name/aliases/:label` (FA-2) — point a label at a version.
530pub(super) async fn alias_function(
531    State(deploy): State<DeployStore>,
532    Extension(project): axum::extract::Extension<ProjectContext>,
533    Path((name, label)): Path<(String, String)>,
534    Json(body): Json<AliasBody>,
535) -> Response {
536    match deploy.get_function(project.as_ref(), &name).await {
537        Ok(Some(mut f)) => match f.set_alias(&label, &body.version) {
538            Ok(()) => {
539                if let Err(err) = deploy.put_function(project.as_ref(), &f).await {
540                    return deploy_error_response(err);
541                }
542                Json(f).into_response()
543            }
544            Err(msg) => (StatusCode::BAD_REQUEST, format!("{msg}\n")).into_response(),
545        },
546        Ok(None) => (StatusCode::NOT_FOUND, format!("no function {name:?}\n")).into_response(),
547        Err(err) => deploy_error_response(err),
548    }
549}
550
551/// `DELETE /api/functions/:name` (FA-2) — remove a top-level function (idempotent).
552/// Content-addressed component blobs are shared and left to `prune`.
553pub(super) async fn remove_function(
554    State(deploy): State<DeployStore>,
555    Extension(project): axum::extract::Extension<ProjectContext>,
556    Path(name): Path<String>,
557) -> Response {
558    match deploy.delete_function(project.as_ref(), &name).await {
559        Ok(_) => StatusCode::NO_CONTENT.into_response(),
560        Err(err) => deploy_error_response(err),
561    }
562}
563
564#[cfg(all(test, feature = "handlers"))]
565mod tests {
566    use super::*;
567    use boatramp_core::function::{Function, FunctionConfig, Lifecycle, Owner};
568    use boatramp_core::kv::MemoryKv;
569    use std::sync::Arc;
570
571    #[test]
572    fn host_capability_features_registry_and_requires_filter() {
573        let host = host_capability_features();
574        // Base grantable capabilities (KNOWN_IMPORTS) + always-on surface features are advertised.
575        assert!(host.contains(&"sql"), "base capability token present");
576        assert!(
577            host.contains(&"streaming"),
578            "always-on surface feature present"
579        );
580        assert!(host.contains(&"orm-vector"));
581        assert!(host.contains(&"orm-own-pref"));
582        // The correlated roll-up is cargo-gated: advertised iff this build enabled it.
583        assert_eq!(
584            host.contains(&"orm-subquery"),
585            cfg!(feature = "orm-subquery")
586        );
587        // The session capability is cargo-gated too: advertised iff this build enabled it.
588        assert_eq!(host.contains(&"session"), cfg!(feature = "session"));
589        // The detailed registry pairs each name with a lifecycle, and its names are exactly the
590        // flat list (one source of truth — the flat list derives from the detailed one).
591        let detailed = host_capability_features_detailed();
592        let detailed_names: Vec<&str> = detailed.iter().map(|c| c.name).collect();
593        assert_eq!(
594            detailed_names, host,
595            "detailed names match the flat registry"
596        );
597        let lifecycle = |name: &str| {
598            detailed
599                .iter()
600                .find(|c| c.name == name)
601                .map(|c| c.lifecycle)
602        };
603        assert_eq!(lifecycle("sql"), Some(super::Lifecycle::Stable));
604        assert_eq!(lifecycle("sql-json"), Some(super::Lifecycle::Stable));
605        assert_eq!(
606            lifecycle("orm-vector"),
607            Some(super::Lifecycle::Experimental)
608        );
609        assert_eq!(
610            lifecycle("orm-own-pref"),
611            Some(super::Lifecycle::Experimental)
612        );
613        // The admission filter keeps only requirements the host cannot satisfy.
614        let unmet: Vec<&str> = ["sql", "streaming", "quantum-teleport", "warp-drive"]
615            .into_iter()
616            .filter(|r| !host.contains(r))
617            .collect();
618        assert_eq!(unmet, vec!["quantum-teleport", "warp-drive"]);
619    }
620
621    /// A blob store the subgraph-refresh guard never reaches (it returns before touching blobs).
622    struct NullStorage;
623    #[async_trait::async_trait]
624    impl boatramp_core::Storage for NullStorage {
625        async fn get(
626            &self,
627            _: &str,
628        ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
629            Err(boatramp_core::StorageError::NotFound(String::new()))
630        }
631        async fn get_range(
632            &self,
633            _: &str,
634            _: u64,
635            _: Option<u64>,
636        ) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
637            Err(boatramp_core::StorageError::NotFound(String::new()))
638        }
639        async fn put(
640            &self,
641            _: &str,
642            _: boatramp_core::ByteStream,
643            _: boatramp_core::PutMeta,
644        ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
645            Err(boatramp_core::StorageError::unsupported("null"))
646        }
647        async fn head(
648            &self,
649            _: &str,
650        ) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
651            Err(boatramp_core::StorageError::NotFound(String::new()))
652        }
653        async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
654            Ok(())
655        }
656        async fn list(
657            &self,
658            _: &str,
659        ) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
660            Ok(Vec::new())
661        }
662    }
663
664    fn a_function() -> Function {
665        Function::new(
666            "accounts",
667            Owner::Project("default".to_string()),
668            "component-hash",
669            FunctionConfig::default(),
670            Lifecycle::default(),
671            0,
672        )
673    }
674
675    /// The subgraph-refresh hook must never block or touch the registry for a function that is
676    /// not a registered subgraph, on an explicit opt-out, or on a node with no wasm engine — so
677    /// an ordinary function deploy (and the coordinated-migration escape hatch) is unaffected.
678    #[tokio::test]
679    async fn refresh_is_a_noop_unless_the_function_is_a_registered_subgraph() {
680        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
681        let handlers = HandlerRuntime::disabled();
682        let project = boatramp_core::project::ProjectRef::new("default");
683        let f = a_function();
684
685        // Not a registered subgraph → no-op; nothing is published.
686        maybe_register_subgraph(
687            &deploy,
688            &handlers,
689            project,
690            "accounts",
691            &f,
692            "component-hash",
693            None,
694        )
695        .await
696        .expect("an unregistered function deploys freely");
697        assert!(
698            !crate::graphql_registry::is_registered_subgraph(
699                deploy.kv().as_ref(),
700                "default",
701                "accounts"
702            )
703            .await
704        );
705
706        // Now registered: `?register_subgraph=false` opts out (the migration escape hatch).
707        crate::graphql_registry::publish(
708            deploy.kv().as_ref(),
709            "default",
710            "accounts",
711            "type Query { x: Int }",
712        )
713        .await
714        .unwrap();
715        maybe_register_subgraph(
716            &deploy,
717            &handlers,
718            project,
719            "accounts",
720            &f,
721            "component-hash",
722            Some(false),
723        )
724        .await
725        .expect("opt-out never blocks");
726
727        // Registered, no opt-out, but this node has no engine → Unavailable degrades to a skip
728        // rather than blocking the deploy.
729        maybe_register_subgraph(
730            &deploy,
731            &handlers,
732            project,
733            "accounts",
734            &f,
735            "component-hash",
736            None,
737        )
738        .await
739        .expect("a node with no engine skips the refresh, it does not block");
740    }
741
742    /// A minimal core wasm module carrying one `boatramp:function-manifest` custom section — the
743    /// shape the guest's `#[link_section]` manifest takes (here at the top level; a real
744    /// component nests it in an embedded core module, exercised end-to-end elsewhere).
745    fn leb128(mut n: usize, out: &mut Vec<u8>) {
746        loop {
747            let mut byte = (n & 0x7f) as u8;
748            n >>= 7;
749            if n != 0 {
750                byte |= 0x80;
751            }
752            out.push(byte);
753            if n == 0 {
754                break;
755            }
756        }
757    }
758
759    fn module_with_manifest(manifest: &[u8]) -> Vec<u8> {
760        let name = b"boatramp:function-manifest";
761        let mut payload = Vec::new();
762        leb128(name.len(), &mut payload);
763        payload.extend_from_slice(name);
764        payload.extend_from_slice(manifest);
765        let mut module = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]; // \0asm + version 1
766        module.push(0x00); // custom section id
767        leb128(payload.len(), &mut module);
768        module.extend_from_slice(&payload);
769        module
770    }
771
772    #[test]
773    fn a_subgraph_marker_in_the_manifest_is_detected() {
774        assert!(component_declares_subgraph(&module_with_manifest(
775            br#"{"name":"schema","triggers":[{"on":"http","route":"POST /graphql"}],"authorize":"public","subgraph":true}"#
776        )));
777        // An ordinary managed handler manifest is not a subgraph.
778        assert!(!component_declares_subgraph(&module_with_manifest(
779            br#"{"name":"orders","authorize":"tenant"}"#
780        )));
781        // No manifest section at all → not a subgraph.
782        assert!(!component_declares_subgraph(&[
783            0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00
784        ]));
785        // Garbage bytes are handled gracefully (best-effort parse).
786        assert!(!component_declares_subgraph(b"not a wasm module"));
787    }
788}