diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index ac852df..fcc825b 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -580,9 +580,9 @@ impl ApplyService { ) -> Result, ApplyError> { match owner { ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await, - // §11 tail: a group trigger TEMPLATE binds the group's OWN endpoint - // scripts (declared here or pre-existing). Surface the ones a - // template references but the bundle doesn't declare, so + // §11 tail: a group trigger or ROUTE TEMPLATE binds the group's OWN + // endpoint scripts (declared here or pre-existing). Surface the ones + // a template references but the bundle doesn't declare, so // `validate_bundle` accepts them (the reconcile's name_to_id already // includes the group's current scripts). ApplyOwner::Group(group_id) => { @@ -595,6 +595,7 @@ impl ApplyService { .triggers .iter() .map(|t| t.script().to_lowercase()) + .chain(bundle.routes.iter().map(|r| r.script.to_lowercase())) .collect(); let by_name: HashMap = self .scripts @@ -629,16 +630,13 @@ impl ApplyService { inherited_endpoints: &HashSet, ) -> Result<(), ApplyError> { if matches!(owner, ApplyOwner::Group(_)) { - if !bundle.routes.is_empty() { - return Err(ApplyError::Invalid( - "a group manifest cannot declare routes — routes bind to an app".into(), - )); - } - // §11 tail: a group MAY declare trigger TEMPLATES, but only for the - // stateless EVENT kinds (kv/docs/files/pubsub) — those resolve live - // via the chain union at dispatch. cron/queue/email carry per-app - // state (last_fired_at, the one-consumer lock, a sealed secret) and - // would need materialization; reject them on a group, deferred. + // §11 tail: a group MAY declare ROUTE TEMPLATES (validated as binding + // a group-owned endpoint by `validate_bundle` via the inherited + // targets) and TRIGGER TEMPLATES — but trigger templates are limited + // to the stateless EVENT kinds (kv/docs/files/pubsub), which resolve + // live via the chain union at dispatch. cron/queue/email carry + // per-app state (last_fired_at, the one-consumer lock, a sealed + // secret) and would need materialization; reject them, deferred. for t in &bundle.triggers { if !matches!( t, @@ -834,10 +832,10 @@ impl ApplyService { }; let br = bundle_routes[&ch.key]; let sid = resolve_script(&name_to_id, &br.script)?; - // Routes only exist on app nodes (a group bundle has none, so this - // loop is empty for a group — validated above). - let app_id = owner.app_id().expect("routes only exist on app nodes"); - insert_bundle_route(&mut *tx, app_id, sid, br).await?; + // §11 tail: an app node writes an app-owned route; a group node + // writes a route TEMPLATE (group_id owner) that expands into every + // descendant app's slice at rebuild. + insert_bundle_route(&mut *tx, owner.as_script_owner(), sid, br).await?; if is_update { report.routes_updated += 1; } else { @@ -2175,8 +2173,8 @@ impl ApplyService { } async fn load_current(&self, owner: ApplyOwner) -> Result { - // A group node has only scripts + vars; routes / triggers / secrets are - // app-only and stay empty (the bundle for a group declares none). + // A group node owns scripts + vars + route/trigger TEMPLATES (§11 tail); + // secrets stay empty (app-only — the bundle for a group declares none). let (scripts, routes, triggers, secret_names, var_owner) = match owner { ApplyOwner::App(app_id) => ( self.scripts @@ -2199,9 +2197,12 @@ impl ApplyService { .list_for_group(group_id) .await .map_err(|e| ApplyError::Backend(e.to_string()))?, - Vec::new(), - // §11 tail: a group owns trigger TEMPLATES; load them so the - // diff sees existing markers (NoOp on re-apply, prune-able). + // §11 tail: a group owns route + trigger TEMPLATES; load them so + // the diff sees existing markers (NoOp on re-apply, prune-able). + self.routes + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?, self.triggers .list_for_group(group_id) .await @@ -3208,12 +3209,12 @@ fn resolve_script( async fn insert_bundle_route( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - app_id: AppId, + owner: ScriptOwner, script_id: ScriptId, br: &BundleRoute, ) -> Result<(), ApplyError> { let new = NewRoute { - owner: ScriptOwner::App(app_id), + owner, script_id, host_kind: br.host_kind, host: br.host.clone(), diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index c9a9a92..e46ac0e 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -68,15 +68,11 @@ impl Manifest { } } // A group node owns scripts + vars (+ secret names) and, as of §11 tail, - // EVENT trigger TEMPLATES (kv/docs/files/pubsub) that fan out live to - // descendant apps. Routes stay app-only; so do the stateful trigger - // kinds (cron/queue/email need per-app state/secrets → materialization). + // ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that + // fan out live to descendant apps. The stateful trigger kinds + // (cron/queue/email) stay app-only — they need per-app state/secrets → + // materialization. if m.group.is_some() { - if !m.routes.is_empty() { - anyhow::bail!( - "a [group] manifest cannot declare [[routes]] — routes bind to an app" - ); - } let t = &m.triggers; if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() { anyhow::bail!(