//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside //! the script CRUD endpoints; the picloud binary wires the //! `RouteTable` shared with the orchestrator dispatcher in here so //! writes invalidate the in-memory snapshot. use std::sync::Arc; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, post}, Extension, Json, Router, }; use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable}; use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId, ScriptOwner}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::app_domain_repo::AppDomainRepository; use crate::authz::{require, AuthzDenied, AuthzRepo, Capability}; use crate::repo::{ScriptRepository, ScriptRepositoryError}; use crate::route_repo::{EffectiveRoute, NewRoute, RouteRepository}; pub struct RouteAdminState { pub routes: Arc, /// Used to resolve `script_id → app_id` when creating routes (the /// route inherits the script's app) and to scope conflict checks. pub scripts: Arc, /// Used to validate the route's host against the parent app's /// declared domain claims. pub domains: Arc, pub table: Arc, /// Capability gate — Phase 3.5. pub authz: Arc, } impl Clone for RouteAdminState { fn clone(&self) -> Self { Self { routes: self.routes.clone(), scripts: self.scripts.clone(), domains: self.domains.clone(), table: self.table.clone(), authz: self.authz.clone(), } } } pub fn route_admin_router(state: RouteAdminState) -> Router where RR: RouteRepository + 'static, SR: ScriptRepository + 'static, { Router::new() .route( "/scripts/{id}/routes", get(list_routes::).post(create_route::), ) .route("/routes/{route_id}", delete(delete_route::)) .route("/routes:check", post(check_route::)) .route("/routes:match", post(match_route::)) .with_state(state) } // ---------------------------------------------------------------------------- // DTOs // ---------------------------------------------------------------------------- #[derive(Debug, Deserialize)] pub struct CreateRouteRequest { pub host_kind: HostKind, #[serde(default)] pub host: String, #[serde(default)] pub host_param_name: Option, pub path_kind: PathKind, pub path: String, pub method: Option, /// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when /// omitted so older clients aren't broken. `Async` routes return /// `202 Accepted` immediately and run the script in the /// background via the dispatcher. #[serde(default)] pub dispatch_mode: picloud_shared::DispatchMode, } #[derive(Debug, Deserialize)] pub struct CheckRouteRequest { /// Required: which app's route table this hypothetical route would /// join. Conflict checks are strictly intra-app (cross-app route /// errors would leak tenant info — see blueprint §11.5). pub app_id: AppId, pub host_kind: HostKind, #[serde(default)] pub host: String, pub path_kind: PathKind, pub path: String, pub method: Option, } #[derive(Debug, Serialize)] pub struct CheckRouteResponse { pub ok: bool, pub conflicting_route: Option, pub conflict_reason: Option, } #[derive(Debug, Deserialize)] pub struct MatchRouteRequest { /// Which app's route table to dispatch against. The dashboard's /// route-preview tester always knows the current app context. pub app_id: AppId, pub url: String, #[serde(default = "default_method")] pub method: String, } fn default_method() -> String { "GET".into() } #[derive(Debug, Serialize)] pub struct MatchRouteResponse { pub matched: Option, } #[derive(Debug, Serialize)] pub struct MatchedRoute { pub route_id: Uuid, pub script_id: ScriptId, pub params: std::collections::BTreeMap, pub rest: Option, pub host_param: Option<(String, String)>, } // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- async fn list_routes( State(state): State>, Extension(principal): Extension, Path(script_id): Path, ) -> Result>, RouteApiError> { let script = state .scripts .get(script_id) .await? .ok_or(RouteApiError::ScriptNotFound(script_id))?; // Phase 4: routes bind to app-owned scripts; a group-owned script // (`app_id: None`) is not addressable here (binding lands in C3), so it // reads as a missing script. let app_id = script .app_id .ok_or(RouteApiError::ScriptNotFound(script_id))?; require( state.authz.as_ref(), &principal, Capability::AppRead(app_id), ) .await?; Ok(Json(state.routes.list_for_script(script_id).await?)) } async fn create_route( State(state): State>, Extension(principal): Extension, Path(script_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), RouteApiError> { let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; pattern::parse_host( input.host_kind, &input.host, input.host_param_name.as_deref(), )?; // Look up the script's owning app — every route inherits it. let script = state .scripts .get(script_id) .await? .ok_or(RouteApiError::ScriptNotFound(script_id))?; // Phase 4: only app-owned scripts are route-bindable for now (group // binding is C3); a group script reads as missing here. let app_id = script .app_id .ok_or(RouteApiError::ScriptNotFound(script_id))?; require( state.authz.as_ref(), &principal, Capability::AppWriteRoute(app_id), ) .await?; // v1.1.3: module scripts have no executable entry point — they're // libraries imported by other scripts. Reject route bindings here // before we touch the routes table. if script.kind == picloud_shared::ScriptKind::Module { return Err(RouteApiError::BadRequest(format!( "script {script_id} has kind=module; modules are imported, \ not bound to routes — switch the script to kind=endpoint \ or attach this route to a different script" ))); } // Validate the route's host is consistent with one of the app's // domain claims. `HostKind::Any` is always permitted (catches every // host the app already owns). Specific hosts must match a claim. validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host) .await?; // Within-app conflict check (cross-app is impossible by construction). let existing = state.routes.list_for_app(app_id).await?; if let Some((conflicting, reason)) = first_conflict( &existing, input.host_kind, &input.host, input.path_kind, &normalized_path, input.method.as_deref(), )? { return Err(RouteApiError::Conflict { conflicting_route: Box::new(conflicting), reason, }); } let created = state .routes .create(NewRoute { owner: ScriptOwner::App(app_id), script_id, host_kind: input.host_kind, host: input.host, host_param_name: input.host_param_name, path_kind: input.path_kind, path: normalized_path, method: input.method, dispatch_mode: input.dispatch_mode, // Routes are created active; toggling is a dedicated path. enabled: true, // Sealing is a group-template property (§11 tail); an interactively // created app route is never sealed. sealed: false, }) .await?; refresh_table(&state).await?; Ok((StatusCode::CREATED, Json(created))) } async fn delete_route( State(state): State>, Extension(principal): Extension, Path(route_id): Path, ) -> Result { // Resolve the route's app before we delete, so the capability // binds to the actual route's app_id (not a path param). let route = state .routes .get(route_id) .await? .ok_or(RouteApiError::RouteNotFound(route_id))?; // §11 tail: the interactive route API is app-scoped. A group-owned route // TEMPLATE (`app_id` None) is managed declaratively via apply, not // addressable here — treat it as not found. let route_app_id = route.app_id.ok_or(RouteApiError::RouteNotFound(route_id))?; require( state.authz.as_ref(), &principal, Capability::AppWriteRoute(route_app_id), ) .await?; state.routes.delete(route_id).await?; refresh_table(&state).await?; Ok(StatusCode::NO_CONTENT) } async fn check_route( State(state): State>, Extension(principal): Extension, Json(input): Json, ) -> Result, RouteApiError> { // routes:check is read-only — peeking at a hypothetical conflict // is bounded by AppRead on the target app (otherwise members // could probe other apps). require( state.authz.as_ref(), &principal, Capability::AppRead(input.app_id), ) .await?; let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?; pattern::parse_host(input.host_kind, &input.host, None)?; let existing = state.routes.list_for_app(input.app_id).await?; let conflict = first_conflict( &existing, input.host_kind, &input.host, input.path_kind, &normalized_path, input.method.as_deref(), )?; Ok(Json(match conflict { None => CheckRouteResponse { ok: true, conflicting_route: None, conflict_reason: None, }, Some((route, reason)) => CheckRouteResponse { ok: false, conflicting_route: Some(route), conflict_reason: Some(reason), }, })) } async fn match_route( State(state): State>, Extension(principal): Extension, Json(input): Json, ) -> Result, RouteApiError> { require( state.authz.as_ref(), &principal, Capability::AppRead(input.app_id), ) .await?; let parsed = url::Url::parse(&input.url) .map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?; let host = parsed.host_str().unwrap_or("").to_string(); let path = parsed.path().to_string(); let result = state .table .match_request_for_app(input.app_id, &host, &input.method, &path); Ok(Json(MatchRouteResponse { matched: result.map(|r| MatchedRoute { route_id: r.matched.route_id, script_id: r.matched.script_id, params: r.params, rest: r.rest, host_param: r.host_param, }), })) } // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- /// Validate the raw user-typed path string and return it verbatim if /// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`) /// happens only in memory at compile time; persisted strings stay in /// the form the user submitted so re-parses are idempotent. fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result { pattern::parse_path(kind, raw)?; Ok(raw.to_string()) } #[allow(clippy::type_complexity)] fn first_conflict( existing: &[Route], host_kind: HostKind, host: &str, path_kind: PathKind, path: &str, method: Option<&str>, ) -> Result, RouteApiError> { let new_host = pattern::parse_host(host_kind, host, None)?; let new_path = pattern::parse_path(path_kind, path)?; for r in existing { let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?; if !conflict::hosts_overlap(&new_host, &r_host) { continue; } if !conflict::methods_overlap(method, r.method.as_deref()) { continue; } let r_path = pattern::parse_path(r.path_kind, &r.path)?; if let Some(reason) = conflict::conflicts(&new_path, &r_path) { return Ok(Some((r.clone(), format!("{reason:?}")))); } } Ok(None) } async fn refresh_table( state: &RouteAdminState, ) -> Result<(), RouteApiError> { rebuild_route_table(state.routes.as_ref(), &state.table).await?; Ok(()) } /// Rebuild the entire in-memory [`RouteTable`] from the database, expanding /// §11 tail group route TEMPLATES into every descendant app's slice. /// /// This is the single chokepoint for route-table refresh — called from route /// CRUD, the declarative apply reconcile, startup, and (full-live invalidation) /// every tree mutation that changes inheritance (app create/reparent/delete, /// group reparent). It reads the live expansion (`list_effective`) so a group /// template lands in the slice of each app whose ancestor chain contains the /// owning group, and nowhere else — the chain walk is the isolation boundary. pub async fn rebuild_route_table( routes: &dyn RouteRepository, table: &RouteTable, ) -> Result<(), ScriptRepositoryError> { let effective = routes.list_effective().await?; // §11 tail: `(app_id, path)` route suppressions — an inherited route at a // suppressed path is dropped from the app's slice (404). let suppressed: std::collections::HashSet<(AppId, String)> = routes .list_route_suppressions() .await? .into_iter() .collect(); let compiled = compile_effective_routes(&effective, &suppressed); table.replace_all(compiled); Ok(()) } /// Compile the §11 tail effective expansion into per-app match slices, applying /// **nearest-owner-wins shadowing**: for a given app, if the same binding tuple /// (method + host + path) is owned at more than one chain level — e.g. the app /// declares its own `/x` and an ancestor group also templates `/x` — the /// nearest owner (lowest `depth`) wins and the farther one is dropped. Unlike /// triggers (which fan out to every match), a route resolves to a single /// winner, so identical bindings MUST dedupe; **non-identical** bindings (the /// app's `/users/:id` + the group's `/users/admin`) coexist and the matcher's /// existing precedence picks per request. /// /// This is the single production entry point: app-only installs feed it routes /// at `depth 0` (one per app, no templates), so it subsumes the former app-only /// compile path. Disabled routes are dropped (§4.3) and un-compilable rows /// skipped-with-warning (see [`compile_one`]). /// /// **Disabled + inherited (§4.3 semantic):** the `enabled` filter runs *before* /// the shadow check, so a **disabled** own-route does NOT claim its binding — an /// enabled ancestor-group template at the same binding then falls through and /// serves (disabled = "indistinguishable from absent"). To actually 404 an /// inherited route, a descendant SUPPRESSES its path (§11 tail, the /// `suppressed_paths` set) — the deliberate per-app opt-out. /// /// `suppressed_paths` holds `(app_id, path)` route suppressions: an INHERITED /// row (`depth > 0`) whose `(effective_app_id, path)` is present is skipped /// entirely (the binding is absent → 404). Gated to `depth > 0` so an app can /// only decline what it inherits, never its own route. /// /// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which /// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest. #[must_use] #[allow(clippy::implicit_hasher)] // every caller uses the default hasher pub fn compile_effective_routes( rows: &[EffectiveRoute], suppressed_paths: &std::collections::HashSet<(AppId, String)>, ) -> Vec { let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new(); let mut out = Vec::new(); for er in rows { // A disabled route (§4.3) is dropped from the match table entirely, so a // request to it 404s indistinguishably from an absent route. if !er.route.enabled { continue; } // §11 tail: an inherited route whose path this app suppresses is // dropped — the binding 404s. Gated to inherited rows (`depth > 0`). // A `sealed` template is non-suppressible: the descendant's opt-out is // ignored, so it stays in the slice regardless of the suppression. if er.depth > 0 && !er.route.sealed && suppressed_paths.contains(&(er.effective_app_id, er.route.path.clone())) { continue; } // A nearer owner already claimed this binding for this app → shadow. if !seen.insert((er.effective_app_id, binding_key(&er.route))) { continue; } if let Some(compiled) = compile_one(&er.route, er.effective_app_id) { out.push(compiled); } } out } /// The shadow-identity of a route within one app's slice: the binding tuple a /// request resolves against (method + host + path), matching the per-owner DB /// unique index. Two routes with the same key are "the same endpoint" and only /// the nearest owner's survives. fn binding_key(r: &Route) -> String { let method = r .method .as_deref() .map_or("ANY".to_string(), str::to_uppercase); let host_kind = match r.host_kind { HostKind::Any => "any", HostKind::Strict => "strict", HostKind::Wildcard => "wildcard", }; let path_kind = match r.path_kind { PathKind::Exact => "exact", PathKind::Prefix => "prefix", PathKind::Param => "param", }; format!("{method} {host_kind} {} {path_kind} {}", r.host, r.path) } /// Parse one stored route into a `CompiledRoute` bound to `app_id` (the /// effective app — its own for app routes, the firing descendant for a group /// template). /// /// **Lenient by design (H1).** A row that fails to parse is *skipped with a /// warning*, not propagated as an error. The motivating case: a path that was /// valid when created but became reserved under a later, stricter validation /// (the case-insensitive reserved-prefix check) — but this also covers any /// other parse failure. A single un-compilable row must never take down the /// data plane: this runs at startup (where a hard error aborts boot) and on /// every table rebuild after an edit (where it would fail an unrelated CRUD /// op). A skipped route simply doesn't match; the warning tells the operator to /// delete or fix it (and migration 0044 sweeps the reserved-path offenders on /// upgrade). fn compile_one(r: &Route, app_id: AppId) -> Option { match compile_route(r, app_id) { Ok(compiled) => Some(compiled), Err(e) => { tracing::warn!( route_id = %r.id, app_id = %app_id, path = %r.path, error = %e, "skipping un-compilable stored route — it will not match; \ delete or fix it" ); None } } } fn compile_route(r: &Route, app_id: AppId) -> Result { Ok(CompiledRoute { route_id: r.id, app_id, script_id: r.script_id, host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?, path: pattern::parse_path(r.path_kind, &r.path)?, method: r.method.clone(), dispatch_mode: r.dispatch_mode, }) } /// Validate that a new route's (host_kind, host) is consistent with at /// least one of the parent app's domain claims. `HostKind::Any` is /// always permitted — it catches every host the app already owns. pub(crate) async fn validate_route_host_against_app( domains: &dyn AppDomainRepository, app_id: AppId, host_kind: HostKind, host: &str, ) -> Result<(), RouteApiError> { if matches!(host_kind, HostKind::Any) { return Ok(()); } let claims = domains.list_for_app(app_id).await?; if claims.is_empty() { return Err(RouteApiError::HostNotClaimed { host: host.to_string(), available_claims: vec![], }); } let host_lower = host.to_ascii_lowercase(); for claim in &claims { let claim_lower = claim.pattern.to_ascii_lowercase(); match (host_kind, claim.shape) { // Strict route under exact claim: must match exactly. (HostKind::Strict, picloud_shared::DomainShape::Exact) => { if host_lower == claim_lower { return Ok(()); } } // Strict route under wildcard/parameterized: must end with // "." where the claim's suffix is the part after // `*.` or `{...}.`. ( HostKind::Strict, picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized, ) => { let suffix = claim_lower .split_once('.') .map(|(_, s)| s.to_string()) .unwrap_or_default(); let needle = format!(".{suffix}"); if !suffix.is_empty() && host_lower.ends_with(&needle) { return Ok(()); } } // Wildcard route: must match a wildcard or parameterized // claim with identical suffix. ( HostKind::Wildcard, picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized, ) => { let claim_suffix = claim_lower .split_once('.') .map(|(_, s)| s.to_string()) .unwrap_or_default(); if claim_suffix == host_lower { return Ok(()); } } // Wildcard route under exact claim: not allowed (would // shadow other apps' subdomains the operator didn't claim). (HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {} (HostKind::Any, _) => unreachable!("handled above"), } } Err(RouteApiError::HostNotClaimed { host: host.to_string(), available_claims: claims.into_iter().map(|c| c.pattern).collect(), }) } // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- #[derive(Debug, thiserror::Error)] pub enum RouteApiError { #[error("route conflicts with existing route ({reason})")] Conflict { conflicting_route: Box, reason: String, }, #[error("invalid route: {0}")] Pattern(#[from] pattern::ParseError), #[error("bad request: {0}")] BadRequest(String), #[error("script not found: {0}")] ScriptNotFound(ScriptId), #[error("route not found: {0}")] RouteNotFound(Uuid), #[error("host {host:?} is not claimed by this app")] HostNotClaimed { host: String, available_claims: Vec, }, #[error("forbidden")] Forbidden, #[error("authorization repo error: {0}")] AuthzRepo(String), #[error("repository error: {0}")] Repo(#[from] ScriptRepositoryError), } impl From for RouteApiError { fn from(d: AuthzDenied) -> Self { match d { AuthzDenied::Denied => Self::Forbidden, AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), } } } impl IntoResponse for RouteApiError { fn into_response(self) -> Response { let (status, body) = match &self { Self::Conflict { conflicting_route, reason, } => ( StatusCode::CONFLICT, serde_json::json!({ "error": self.to_string(), "reason": reason, "conflicting_route": &**conflicting_route, }), ), Self::Pattern(_) | Self::BadRequest(_) => ( StatusCode::UNPROCESSABLE_ENTITY, serde_json::json!({ "error": self.to_string() }), ), Self::ScriptNotFound(_) | Self::RouteNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => ( StatusCode::NOT_FOUND, serde_json::json!({ "error": self.to_string() }), ), Self::Forbidden => ( StatusCode::FORBIDDEN, serde_json::json!({ "error": self.to_string() }), ), Self::AuthzRepo(e) => { tracing::error!(error = %e, "route authz repo error"); ( StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({ "error": "internal error" }), ) } Self::HostNotClaimed { host, available_claims, } => ( StatusCode::UNPROCESSABLE_ENTITY, serde_json::json!({ "error": self.to_string(), "host": host, "available_claims": available_claims, }), ), Self::Repo(ScriptRepositoryError::Conflict(_)) => ( StatusCode::CONFLICT, serde_json::json!({ "error": self.to_string() }), ), Self::Repo(ScriptRepositoryError::Db(e)) => { tracing::error!(error = %e, "route admin db error"); ( StatusCode::INTERNAL_SERVER_ERROR, serde_json::json!({ "error": "internal error" }), ) } }; (status, Json(body)).into_response() } } #[cfg(test)] mod tests { use super::*; use picloud_shared::DispatchMode; use uuid::Uuid; fn route_with_path(path: &str) -> Route { Route { id: Uuid::new_v4(), app_id: Some(AppId::from(Uuid::new_v4())), group_id: None, script_id: ScriptId::from(Uuid::new_v4()), host_kind: HostKind::Any, host: String::new(), host_param_name: None, path_kind: PathKind::Exact, path: path.to_string(), method: None, dispatch_mode: DispatchMode::default(), enabled: true, sealed: false, created_at: chrono::Utc::now(), } } /// Wrap an app-owned route as its own `depth 0` effective row — the shape /// `list_effective` produces for an app-only install (no group templates). fn eff(route: &Route) -> EffectiveRoute { EffectiveRoute { effective_app_id: route.app_id.expect("test route is app-owned"), depth: 0, route: route.clone(), } } /// No route suppressions — the common case for these compile tests. fn no_suppress() -> std::collections::HashSet<(AppId, String)> { std::collections::HashSet::new() } #[test] fn compile_effective_skips_uncompilable_rows_instead_of_failing() { // H1 regression guard: a stored route whose path is now reserved // (creatable before the case-insensitive reserved-prefix fix) must // be skipped, not abort the whole compile — otherwise one legacy // row bricks startup (the effective rebuild runs in `build_app`). let good_a = route_with_path("/ok"); let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive let good_b = route_with_path("/items"); let rows = [eff(&good_a), eff(&bad), eff(&good_b)]; let compiled = compile_effective_routes(&rows, &no_suppress()); let ids: Vec = compiled.iter().map(|c| c.route_id).collect(); assert_eq!(compiled.len(), 2, "the reserved row must be dropped"); assert!(ids.contains(&good_a.id)); assert!(ids.contains(&good_b.id)); assert!( !ids.contains(&bad.id), "a reserved-path route must be skipped, never abort the compile" ); } #[test] fn disabled_route_is_dropped_from_compiled_table() { // §4.3: a disabled route is excluded from the match table, so a // request to it 404s indistinguishably from an absent route. let active = route_with_path("/on"); let mut disabled = route_with_path("/off"); disabled.enabled = false; let compiled = compile_effective_routes(&[eff(&active), eff(&disabled)], &no_suppress()); let ids: Vec = compiled.iter().map(|c| c.route_id).collect(); assert_eq!(ids, vec![active.id], "only the enabled route compiles"); } #[test] fn nearest_owner_shadows_identical_binding() { // An app's own route (depth 0) and an ancestor-group template (depth 1) // with the SAME binding collapse to the nearer one; a different binding // coexists. Mirrors the live `group_route_templates` integration test // at the pure-compile layer. let app = AppId::from(Uuid::new_v4()); let own = route_with_path("/x"); // app's own /x let mut template = route_with_path("/x"); // group template, same binding template.app_id = None; template.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4())); let other_template = route_with_path("/y"); // group template, different path let rows = [ EffectiveRoute { effective_app_id: app, depth: 0, route: own.clone(), }, EffectiveRoute { effective_app_id: app, depth: 1, route: template.clone(), }, EffectiveRoute { effective_app_id: app, depth: 1, route: other_template.clone(), }, ]; let ids: Vec = compile_effective_routes(&rows, &no_suppress()) .iter() .map(|c| c.route_id) .collect(); assert!(ids.contains(&own.id), "the app's own /x must win"); assert!( !ids.contains(&template.id), "the inherited /x template must be shadowed by the app's own" ); assert!( ids.contains(&other_template.id), "a non-identical /y template must coexist" ); } }