From 96277e1e7c65effaa7fa8e88645d5c4faf279e74 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 21:49:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(routes):=20live=20group-route=20expansion?= =?UTF-8?q?=20+=20full-live=20invalidation=20(=C2=A711=20tail=20R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime half. Group route TEMPLATES now expand into every descendant app's in-memory match slice, live, with no per-app materialization. - `compile_effective_routes` consumes the `list_effective` expansion and applies NEAREST-OWNER-WINS shadowing: for one app, identical binding tuples (method+host+path) owned at multiple chain levels collapse to the nearest (an app's own route shadows an ancestor-group template); a nearer group beats a farther one. Non-identical bindings coexist and the existing matcher precedence resolves each request. `compile_route` now takes the effective app_id, so the matcher + dispatch are unchanged. - `rebuild_route_table` is the single refresh chokepoint (list_effective → compile_effective_routes → replace_all). Route CRUD, the apply reconcile, and startup all route through it; the apply guards drop the "a group node touches no routes" assumption (a group apply may now create templates). - FULL-LIVE INVALIDATION: app create/delete (apps_api) and group reparent (groups_api) rebuild the snapshot, so inherited routes take effect the instant the tree changes — matching the live model of triggers/vars. Best-effort, mirroring apply: a failed rebuild self-heals on the next write or restart, never failing the committed mutation. The isolation boundary is the chain expansion: a template lands only in the slices of apps whose ancestor chain contains the owning group. Pinned by a deterministic live-DB integration test (descendant inherits, sibling subtree does not, app shadows by identical binding, non-identical coexists). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/manager-core/src/apply_service.rs | 37 ++-- crates/manager-core/src/apps_api.rs | 24 +- crates/manager-core/src/groups_api.rs | 15 ++ crates/manager-core/src/lib.rs | 2 +- crates/manager-core/src/route_admin.rs | 78 ++++++- .../tests/group_route_templates.rs | 208 ++++++++++++++++++ crates/picloud/src/lib.rs | 29 +-- 7 files changed, 354 insertions(+), 39 deletions(-) create mode 100644 crates/manager-core/tests/group_route_templates.rs diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index 91c608f..ac852df 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -1173,9 +1173,10 @@ impl ApplyService { // Post-commit: rebuild the orchestrator's route snapshot once. A // failure here doesn't undo the committed DB write — the table - // self-heals on the next route write or restart. A group node touches - // no routes, so there's nothing to refresh. - if matches!(owner, ApplyOwner::App(_)) { + // self-heals on the next route write or restart. §11 tail: a GROUP node + // may now declare route TEMPLATES, so refresh on either owner (the + // rebuild expands templates into every descendant app's slice). + { if let Err(e) = self.refresh_route_table().await { tracing::warn!(error = %e, "apply: route table refresh failed"); report @@ -1254,7 +1255,6 @@ impl ApplyService { let mut report = ApplyReport::default(); let mut group_script_index: HashMap> = HashMap::new(); - let mut any_app = false; // Phase A — groups first: reconcile each and record its post-create // `name -> id` so descendant app nodes can resolve inherited bindings. @@ -1285,7 +1285,6 @@ impl ApplyService { // ids just created) and pre-existing ancestors, then reconcile. for p in &prepared { if let ApplyOwner::App(_) = p.owner { - any_app = true; report .warnings .extend(disabled_target_warnings(&p.node.bundle)); @@ -1314,14 +1313,15 @@ impl ApplyService { tx.commit() .await .map_err(|e| ApplyError::Backend(e.to_string()))?; - // One post-commit route refresh covers every app node touched. - if any_app { - if let Err(e) = self.refresh_route_table().await { - tracing::warn!(error = %e, "apply_tree: route table refresh failed"); - report - .warnings - .push("route table refresh failed; it will self-heal".into()); - } + // One post-commit route refresh covers the whole tree. §11 tail: a + // group node may declare route TEMPLATES affecting descendant apps even + // when no app node is in this apply, so refresh unconditionally (a full + // rebuild; cheap relative to a tree apply). + if let Err(e) = self.refresh_route_table().await { + tracing::warn!(error = %e, "apply_tree: route table refresh failed"); + report + .warnings + .push("route table refresh failed; it will self-heal".into()); } Ok(report) } @@ -1608,14 +1608,11 @@ impl ApplyService { } async fn refresh_route_table(&self) -> Result<(), ApplyError> { - let rows = self - .routes - .list_all() + // §11 tail: rebuild via the live expansion so a group route TEMPLATE + // created in this apply lands in every descendant app's slice. + crate::route_admin::rebuild_route_table(self.routes.as_ref(), &self.route_table) .await - .map_err(|e| ApplyError::Backend(e.to_string()))?; - let compiled = crate::route_admin::compile_routes(&rows); - self.route_table.replace_all(compiled); - Ok(()) + .map_err(|e| ApplyError::Backend(e.to_string())) } /// Resolve a referenced secret to plaintext, then re-seal it for an diff --git a/crates/manager-core/src/apps_api.rs b/crates/manager-core/src/apps_api.rs index 4326077..9f4d188 100644 --- a/crates/manager-core/src/apps_api.rs +++ b/crates/manager-core/src/apps_api.rs @@ -16,7 +16,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Json, Response}; use axum::routing::{delete, get, post}; use axum::{Extension, Router}; -use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain}; +use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain, RouteTable}; use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -43,6 +43,10 @@ pub struct AppsState { /// Cached host → app_id lookup; replaced after every domain CRUD /// operation so the orchestrator sees changes immediately. pub domain_table: Arc, + /// §11 tail: the route match snapshot. Creating/deleting an app changes + /// which group route TEMPLATES are inherited, so it is rebuilt here + /// (full-live invalidation) — mirroring the domain_table refresh. + pub route_table: Arc, /// Capability gate — Phase 3.5. pub authz: Arc, /// Group tree — resolves an app's parent group at create time @@ -226,6 +230,9 @@ async fn create_app( ) .await? }; + // The new app may sit under a group that owns route TEMPLATES — make them + // servable immediately (full-live invalidation). + refresh_route_cache(&s).await; Ok((StatusCode::CREATED, Json(created))) } @@ -368,6 +375,8 @@ async fn delete_app( s.apps.delete(app.id).await?; } refresh_domain_cache(&s).await?; + // Drop the deleted app's slice (and any routes it owned) from the snapshot. + refresh_route_cache(&s).await; Ok(StatusCode::NO_CONTENT) } @@ -545,6 +554,19 @@ fn validate_slug(slug: &str) -> Result<(), AppsApiError> { /// Rebuild the in-memory host → app_id cache used by the orchestrator. /// Called after every domain CRUD operation. +/// §11 tail: rebuild the route match snapshot after a tree mutation that +/// changes route inheritance (an app gained/lost its place in the group tree, +/// so the set of ancestor-group route TEMPLATES it serves changed). Best-effort +/// to mirror the apply path — a failure leaves a stale-but-self-healing table +/// rather than failing the committed mutation. +async fn refresh_route_cache(state: &AppsState) { + if let Err(e) = + crate::route_admin::rebuild_route_table(state.routes.as_ref(), &state.route_table).await + { + tracing::warn!(error = %e, "apps: route table refresh failed; it will self-heal"); + } +} + pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> { let all = state.domains.list_all().await?; let compiled = all diff --git a/crates/manager-core/src/groups_api.rs b/crates/manager-core/src/groups_api.rs index 2599ff7..22a17d3 100644 --- a/crates/manager-core/src/groups_api.rs +++ b/crates/manager-core/src/groups_api.rs @@ -22,6 +22,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use uuid::Uuid; +use picloud_orchestrator_core::routing::RouteTable; + use crate::admin_user_repo::{AdminUserRepository, AdminUserRow}; use crate::app_repo::AppRepository; use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability}; @@ -29,6 +31,7 @@ use crate::group_members_repo::{ GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow, }; use crate::group_repo::{GroupRepository, GroupRepositoryError}; +use crate::route_repo::RouteRepository; const SLUG_MAX: usize = 63; const RESERVED_SLUGS: &[&str] = &[ @@ -42,6 +45,11 @@ pub struct GroupsState { pub apps: Arc, pub users: Arc, pub authz: Arc, + /// §11 tail full-live invalidation: reparenting a group moves a whole + /// subtree, changing which ancestor-group route TEMPLATES its descendant + /// apps inherit — so the route snapshot is rebuilt after a reparent. + pub routes: Arc, + pub route_table: Arc, } pub fn groups_router(state: GroupsState) -> Router { @@ -280,6 +288,13 @@ async fn reparent_group( } }; let moved = s.groups.reparent(group.id, new_parent).await?; + // §11 tail: the moved subtree now inherits a different set of ancestor-group + // route TEMPLATES — rebuild the snapshot (best-effort; self-heals on the + // next route write or restart if it fails). + if let Err(e) = crate::route_admin::rebuild_route_table(s.routes.as_ref(), &s.route_table).await + { + tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal"); + } Ok(Json(moved)) } diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index fde4759..a787bcf 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -218,7 +218,7 @@ pub use repo::{ ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository, RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError, }; -pub use route_admin::{compile_routes, route_admin_router, RouteAdminState}; +pub use route_admin::{compile_routes, rebuild_route_table, route_admin_router, RouteAdminState}; pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository}; pub use sandbox::{CeilingError, SandboxCeiling}; pub use secrets_api::{secrets_router, SecretsApiError, SecretsState}; diff --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs index 1c84228..50ef8bd 100644 --- a/crates/manager-core/src/route_admin.rs +++ b/crates/manager-core/src/route_admin.rs @@ -20,7 +20,7 @@ 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::{NewRoute, RouteRepository}; +use crate::route_repo::{EffectiveRoute, NewRoute, RouteRepository}; pub struct RouteAdminState { pub routes: Arc, @@ -388,9 +388,26 @@ fn first_conflict( async fn refresh_table( state: &RouteAdminState, ) -> Result<(), RouteApiError> { - let rows = state.routes.list_all().await?; - let compiled = compile_routes(&rows); - state.table.replace_all(compiled); + 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?; + let compiled = compile_effective_routes(&effective); + table.replace_all(compiled); Ok(()) } @@ -423,6 +440,59 @@ pub fn compile_routes(rows: &[Route]) -> Vec { .collect() } +/// 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. +/// +/// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which +/// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest. +#[must_use] +pub fn compile_effective_routes(rows: &[EffectiveRoute]) -> Vec { + let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new(); + let mut out = Vec::new(); + for er in rows { + if !er.route.enabled { + 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: an un-compilable row is skipped-with-warning, never an diff --git a/crates/manager-core/tests/group_route_templates.rs b/crates/manager-core/tests/group_route_templates.rs new file mode 100644 index 0000000..168021d --- /dev/null +++ b/crates/manager-core/tests/group_route_templates.rs @@ -0,0 +1,208 @@ +//! §11 tail integration test: the live expansion of group ROUTE templates into +//! per-app match slices. A group-owned route TEMPLATE must land in the compiled +//! slice of a **descendant** app and must NOT land in an app in a **sibling** +//! subtree — the ancestor-chain expansion is the isolation boundary. An app's +//! own route with an identical binding must **shadow** the template (nearest +//! owner wins); a non-identical app route must **coexist**. +//! +//! Deterministic: drives `list_effective` + `compile_effective_routes` directly +//! (no async dispatcher), pinning the security-critical SQL + the shadow rule +//! without flakiness. Skips cleanly when `DATABASE_URL` is unset. + +#![allow( + clippy::needless_pass_by_value, + clippy::many_single_char_names, + clippy::too_many_lines +)] + +use picloud_manager_core::route_admin::compile_effective_routes; +use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository}; +use picloud_shared::AppId; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use uuid::Uuid; + +async fn pool_or_skip() -> Option { + let Ok(url) = std::env::var("DATABASE_URL") else { + eprintln!("group_route_templates: DATABASE_URL unset — skipping"); + return None; + }; + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&url) + .await + .expect("connect to DATABASE_URL"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("apply migrations"); + Some(pool) +} + +async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid { + let row: (Uuid,) = sqlx::query_as(sql) + .bind(bind) + .fetch_one(pool) + .await + .expect("insert returning id"); + row.0 +} + +async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid { + let row: (Uuid,) = + sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id") + .bind(slug) + .bind(group) + .fetch_one(pool) + .await + .expect("app insert"); + row.0 +} + +/// The compiled match slice for `app` — its own routes plus inherited +/// ancestor-group templates, after nearest-wins shadowing. +async fn slice_paths(repo: &PostgresRouteRepository, app: Uuid) -> Vec<(String, Uuid)> { + let effective = repo.list_effective().await.expect("list_effective"); + compile_effective_routes(&effective) + .into_iter() + .filter(|c| c.app_id == AppId::from(app)) + .map(|c| { + // PathPattern's Debug carries the raw path; pair it with the bound + // script so the shadow assertion can check which handler won. + (format!("{:?}", c.path), c.script_id.into_inner()) + }) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn group_route_template_expands_to_descendant_not_sibling_and_shadows() { + let Some(pool) = pool_or_skip().await else { + return; + }; + let sfx = Uuid::new_v4().simple().to_string(); + + // G owns the template; G2 is an unrelated sibling subtree. + let g = id1( + &pool, + "INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id", + &format!("grt-g-{sfx}"), + ) + .await; + let g2 = id1( + &pool, + "INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id", + &format!("grt-g2-{sfx}"), + ) + .await; + + // App A under G (descendant); app C under G2 (sibling subtree). + let a = app_under(&pool, &format!("grt-a-{sfx}"), g).await; + let c = app_under(&pool, &format!("grt-c-{sfx}"), g2).await; + + // Group-owned handler script + a group route TEMPLATE binding it. + let group_script: (Uuid,) = sqlx::query_as( + "INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id", + ) + .bind(format!("ghandler-{sfx}")) + .bind(g) + .fetch_one(&pool) + .await + .expect("group script"); + sqlx::query( + "INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \ + VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)", + ) + .bind(g) + .bind(group_script.0) + .execute(&pool) + .await + .expect("group route template"); + + let repo = PostgresRouteRepository::new(pool.clone()); + + // Descendant app A's slice CONTAINS the template, bound to the group script. + let a_slice = slice_paths(&repo, a).await; + assert!( + a_slice + .iter() + .any(|(p, sid)| p.contains("/hello") && *sid == group_script.0), + "a descendant app must inherit the group route template:\n{a_slice:?}" + ); + + // Sibling-subtree app C's slice does NOT — the chain expansion is the bound. + let c_slice = slice_paths(&repo, c).await; + assert!( + !c_slice.iter().any(|(p, _)| p.contains("/hello")), + "a sibling-subtree app must NOT inherit another subtree's template:\n{c_slice:?}" + ); + + // A's OWN /hello route (its own app-owned script) must SHADOW the template: + // exactly one compiled /hello for A, bound to the app script, not the group. + let app_script: (Uuid,) = sqlx::query_as( + "INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id", + ) + .bind(format!("ahandler-{sfx}")) + .bind(a) + .fetch_one(&pool) + .await + .expect("app script"); + sqlx::query( + "INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \ + VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)", + ) + .bind(a) + .bind(app_script.0) + .execute(&pool) + .await + .expect("app route shadow"); + + let a_after = slice_paths(&repo, a).await; + let hellos: Vec<_> = a_after + .iter() + .filter(|(p, _)| p.contains("/hello")) + .collect(); + assert_eq!( + hellos.len(), + 1, + "nearest-wins shadowing must leave exactly one /hello for A:\n{a_after:?}" + ); + assert_eq!( + hellos[0].1, app_script.0, + "the app's own route must win over the inherited group template" + ); + + // A non-identical app route (different path) must COEXIST with the template. + sqlx::query( + "INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \ + VALUES ($1, $2, 'any', '', 'exact', '/own', NULL)", + ) + .bind(a) + .bind(app_script.0) + .execute(&pool) + .await + .expect("app route coexist"); + let a_final = slice_paths(&repo, a).await; + assert!( + a_final.iter().any(|(p, _)| p.contains("/own")), + "a non-identical app route must coexist with the inherited template:\n{a_final:?}" + ); + + // Cleanup (FK order: routes → scripts → apps → groups). + let _ = sqlx::query("DELETE FROM routes WHERE app_id = ANY($1) OR group_id = $2") + .bind(vec![a, c]) + .bind(g) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)") + .bind(vec![group_script.0, app_script.0]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)") + .bind(vec![a, c]) + .execute(&pool) + .await; + let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)") + .bind(vec![g, g2]) + .execute(&pool) + .await; +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 4c2b5b1..d5bb473 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -11,9 +11,9 @@ use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api, - apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, - dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, - migrations, require_authenticated, route_admin_router, secrets_router, topics_router, + apps_router, attach_principal_if_present, auth_router, dead_letters_router, dev_emails_router, + email_inbound_router, files_admin_router, groups_router, kv_admin_router, migrations, + rebuild_route_table, require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, @@ -33,9 +33,9 @@ use picloud_manager_core::{ PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, - SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, - TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl, + SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, + SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, + UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -328,12 +328,12 @@ pub async fn build_app( // route_repo, then re-populated whenever the admin layer writes // routes. let route_table = Arc::new(RouteTable::new()); - let initial = route_repo.list_all().await?; - // Lenient: a single un-compilable stored route (e.g. one whose path - // became reserved under stricter validation) is skipped-with-warning - // inside compile_routes, never aborting boot. (H1) - let compiled = compile_routes(&initial); - route_table.replace_all(compiled); + // §11 tail: rebuild via the live expansion so group route TEMPLATES land in + // each descendant app's slice at boot. Lenient: a single un-compilable + // stored route (e.g. one whose path became reserved under stricter + // validation) is skipped-with-warning inside the compile, never aborting + // boot. (H1) + rebuild_route_table(route_repo.as_ref(), &route_table).await?; // v1.1.9 function composition. InvokeServiceImpl resolves targets // by Id, Name (via get_by_name), or Path (via the orchestrator's @@ -555,8 +555,9 @@ pub async fn build_app( let apps_state = AppsState { apps: apps_repo.clone(), domains: domains_repo, - routes: route_repo, + routes: route_repo.clone(), domain_table: app_domain_table.clone(), + route_table: route_table.clone(), authz: authz.clone(), groups: groups_repo.clone(), }; @@ -604,6 +605,8 @@ pub async fn build_app( apps: apps_state.apps.clone(), users: auth.users.clone(), authz: authz.clone(), + routes: route_repo.clone(), + route_table: route_table.clone(), }; let vars_state = VarsApiState { vars: Arc::new(PostgresVarsRepo::new(pool.clone())),