feat(stateful-templates): schema + dispatch guards + allow cron/queue on group (M5.1)

0062 adds `materialized_from` on triggers (a managed app-owned copy links back
to its group template; CASCADE). The scheduler + queue-consumer queries gain
`AND t.app_id IS NOT NULL` so a group-owned stateful TEMPLATE is never
dispatched directly — only the per-descendant materialized app rows are.
validate_bundle_for + manifest parse now allow cron/queue on a group (email
stays rejected pending its per-app inbound-secret handling in M5.5). The
materialization reconcile that expands templates into app rows lands in M5.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 21:34:14 +02:00
parent 24d834a102
commit 456e972336
6 changed files with 60 additions and 28 deletions

View File

@@ -78,14 +78,15 @@ impl Manifest {
// ancestor, for its whole subtree. The stateful trigger kinds
// (cron/queue/email) stay app-only — they need per-app state/secrets →
// materialization.
if m.group.is_some() {
let t = &m.triggers;
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
anyhow::bail!(
"a [group] trigger template must be an event kind \
(kv, docs, files, pubsub); cron/queue/email are app-only"
);
}
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron and
// queue materialize a per-descendant-app row (email lands with its
// per-app inbound-secret handling). Only the app-specific `email` kind
// is still rejected on a group here.
if m.group.is_some() && !m.triggers.email.is_empty() {
anyhow::bail!(
"a [group] email trigger template is not yet supported \
(per-app inbound secret); cron/queue/event kinds are allowed"
);
}
Ok(m)
}
@@ -885,13 +886,20 @@ mod tests {
assert!(routed.is_group());
assert_eq!(routed.routes.len(), 1);
// ...but a STATEFUL trigger kind (cron/queue/email) on a group is rejected.
let err = Manifest::parse(
// §4.5 M5: a group cron template is now ALLOWED (materialized per app).
let cron = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
)
.expect_err("group with a cron trigger is rejected");
assert!(err.to_string().contains("event kind"), "got: {err}");
.expect("group cron template parses");
assert_eq!(cron.triggers.cron.len(), 1);
// ...but an email template on a group is still rejected (per-app secret).
let err = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
[[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n",
)
.expect_err("group email template is rejected");
assert!(err.to_string().contains("email"), "got: {err}");
// Neither / both is rejected.
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");