diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 7028f4b..a38cefa 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -419,12 +419,13 @@ impl ApplyService { /// # Errors /// `Invalid` for a malformed bundle; `Backend` for repo failures. pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result { - self.validate_bundle(bundle)?; + let inherited = self.resolve_inherited_targets(app_id, bundle).await?; + self.validate_bundle(bundle, &keys_set(&inherited))?; self.validate_route_hosts(app_id, bundle).await?; let current = self.load_current(app_id).await?; validate_email_secrets_present(bundle, ¤t.secret_names)?; Ok(PlanResult { - plan: compute_diff(¤t, bundle), + plan: compute_diff_with_inherited(¤t, bundle, &inherited), // Fingerprint of the live state this plan was computed against, so // `apply` can refuse if the app changed underneath it (§4.2). state_token: state_token(¤t), @@ -446,7 +447,8 @@ impl ApplyService { actor: AdminUserId, expected_token: Option<&str>, ) -> Result { - self.validate_bundle(bundle)?; + let inherited = self.resolve_inherited_targets(app_id, bundle).await?; + self.validate_bundle(bundle, &keys_set(&inherited))?; self.validate_route_hosts(app_id, bundle).await?; let mut tx = self @@ -485,7 +487,7 @@ impl ApplyService { // Surface a missing email-secret reference here so `plan` and `apply` // agree, rather than only failing deep in `resolve_and_seal` below. validate_email_secrets_present(bundle, ¤t.secret_names)?; - let plan = compute_diff(¤t, bundle); + let plan = compute_diff_with_inherited(¤t, bundle, &inherited); let mut report = ApplyReport::default(); // §4.7 warning: an enabled binding pointing at a disabled script is // deployed-but-unreachable. Not an error (it's valid desired state), @@ -562,6 +564,14 @@ impl ApplyService { } } + // Phase 4: route/trigger targets the manifest doesn't define as app-own + // scripts may bind to inherited group endpoints. `or_insert` keeps + // app-own precedence (those names aren't in `inherited` by + // construction, but be defensive about CoW). + for (name, id) in &inherited { + name_to_id.entry(name.clone()).or_insert(*id); + } + // 2. Routes — identity is the (method, host, path) tuple; an // "update" (rebind / dispatch change) is delete + insert. let current_routes: HashMap = current @@ -924,7 +934,62 @@ impl ApplyService { /// is NOT checked — the DB unique index catches only identical tuples, /// so overlapping patterns are accepted. #[allow(clippy::too_many_lines)] - fn validate_bundle(&self, bundle: &Bundle) -> Result<(), ApplyError> { + /// Phase 4: resolve route/trigger target names that the manifest does NOT + /// declare as app-own scripts to inherited group-owned endpoint scripts on + /// the app's chain. Returns `name(lowercased) → script_id` for the ones + /// that resolve to a group endpoint. App-own names (in `bundle.scripts`) + /// are skipped — they bind through the normal `name_to_id` path and take + /// precedence (CoW). Resolved before the apply transaction; the group + /// scripts pre-exist (committed), so a pool read is correct and stable. + async fn resolve_inherited_targets( + &self, + app_id: AppId, + bundle: &Bundle, + ) -> Result, ApplyError> { + let own: HashSet = bundle + .scripts + .iter() + .map(|s| s.name.to_lowercase()) + .collect(); + let mut referenced: HashSet = HashSet::new(); + for r in &bundle.routes { + referenced.insert(r.script.to_lowercase()); + } + for t in &bundle.triggers { + referenced.insert(t.script().to_lowercase()); + } + let mut out = HashMap::new(); + for name in referenced { + if own.contains(&name) { + continue; + } + if let Some(s) = self + .scripts + .get_by_name_inherited(app_id, &name) + .await + .map_err(map_repo)? + { + // Only genuinely-inherited group endpoints qualify. A depth-0 + // app-own match is already covered by `name_to_id`; skip it so + // the manifest keeps precedence. Group modules don't exist in + // Phase 4-lite, but guard kind anyway. + if s.group_id.is_some() && s.kind == ScriptKind::Endpoint { + out.insert(name, s.id); + } + } + } + Ok(out) + } + + /// `inherited_endpoints` (lowercased names) are group-owned endpoint + /// scripts reachable from the app's chain that a route/trigger may bind to + /// even though the manifest doesn't declare them (Phase 4). They count as + /// known endpoints for the binding checks below. + fn validate_bundle( + &self, + bundle: &Bundle, + inherited_endpoints: &HashSet, + ) -> Result<(), ApplyError> { // Scripts: unique names; each source compiles for its kind. let mut names: HashSet = HashSet::new(); for s in &bundle.scripts { @@ -974,7 +1039,8 @@ impl ApplyService { .map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?; pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref()) .map_err(|e| ApplyError::Invalid(format!("route host `{}`: {e}", r.host)))?; - if !all_names.contains(r.script.as_str()) { + let inherited = inherited_endpoints.contains(&r.script.to_lowercase()); + if !all_names.contains(r.script.as_str()) && !inherited { return Err(ApplyError::Invalid(format!( "route {} binds to unknown script `{}`", route_key( @@ -987,7 +1053,9 @@ impl ApplyService { r.script ))); } - if !endpoints.contains(r.script.as_str()) { + // Inherited targets are endpoints by construction (group modules + // don't exist in Phase 4-lite); only check kind for app-own names. + if !inherited && !endpoints.contains(r.script.as_str()) { return Err(ApplyError::Invalid(format!( "route binds to `{}` which is a module, not an endpoint", r.script @@ -1008,14 +1076,15 @@ impl ApplyService { // Triggers: bind to an endpoint script; unique semantic identity. let mut trig_keys: HashSet = HashSet::new(); for t in &bundle.triggers { - if !all_names.contains(t.script()) { + let inherited = inherited_endpoints.contains(&t.script().to_lowercase()); + if !all_names.contains(t.script()) && !inherited { return Err(ApplyError::Invalid(format!( "{} trigger binds to unknown script `{}`", t.kind_str(), t.script() ))); } - if !endpoints.contains(t.script()) { + if !inherited && !endpoints.contains(t.script()) { return Err(ApplyError::Invalid(format!( "{} trigger binds to `{}` which is a module, not an endpoint", t.kind_str(), @@ -1106,10 +1175,26 @@ impl ApplyService { /// Diff desired `bundle` against `current`. Pure — unit-tested directly. #[must_use] pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan { + compute_diff_with_inherited(current, bundle, &HashMap::new()) +} + +/// `compute_diff` with the set of inherited group-endpoint targets (Phase 4), +/// `name(lowercased) → script_id`. Those ids are added to the script-name map +/// so a route/trigger bound to an inherited group script resolves to its name +/// and re-apply is idempotent (otherwise the unknown id reads as a rebind +/// every plan). The public `compute_diff` passes an empty map (no inheritance). +fn compute_diff_with_inherited( + current: &CurrentState, + bundle: &Bundle, + inherited: &HashMap, +) -> Plan { let script_name_by_id: HashMap = current .scripts .iter() .map(|s| (s.id, s.name.clone())) + // Inherited group targets, inverted to id → name. Disjoint from + // app-own ids by construction, so order doesn't matter. + .chain(inherited.iter().map(|(name, id)| (*id, name.clone()))) .collect(); Plan { @@ -1785,6 +1870,12 @@ pub struct ApplyReport { pub warnings: Vec, } +/// The key set of a name→id map, for passing the inherited target *names* +/// into `validate_bundle` without leaking the ids. +fn keys_set(m: &HashMap) -> HashSet { + m.keys().cloned().collect() +} + fn resolve_script( name_to_id: &HashMap, name: &str,