feat(routes): author group route templates in manifest + reconcile (§11 tail R4)

Open the declarative path so a [group] can declare route TEMPLATES.

- manifest.rs: drop the blanket "[group] cannot declare [[routes]]"
  rejection (routes join the event trigger templates a group may own).
- apply_service:
  - validate_bundle_for: remove the group routes rejection. A group
    route's `script` is validated as binding a group-owned ENDPOINT via
    `resolve_inherited_targets_for(Group)`, now extended to scan
    bundle.routes as well as bundle.triggers — so a template binding the
    group's own (declared or pre-existing) endpoint resolves, and one
    pointing at a module or a missing name is a 422.
  - insert_bundle_route takes a ScriptOwner (was app-only): a group node
    writes a group_id route TEMPLATE, an app node an app-owned route.
  - load_current(Group) loads the group's own routes via list_for_group
    so re-apply is a NoOp and the template is prune-able.

Host-claim validation stays App-only (already guarded) — a template has
no single app; a descendant serves it on whatever host it claims (so
templates use host_kind = any in practice).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 21:54:55 +02:00
parent 96277e1e7c
commit 8f1ba39a8a
2 changed files with 29 additions and 32 deletions

View File

@@ -580,9 +580,9 @@ impl ApplyService {
) -> Result<HashMap<String, ScriptId>, 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<String, ScriptId> = self
.scripts
@@ -629,16 +630,13 @@ impl ApplyService {
inherited_endpoints: &HashSet<String>,
) -> 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<CurrentState, ApplyError> {
// 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(),

View File

@@ -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!(