feat(modules): author group trigger templates (event kinds) (§11 tail T2)

A [group] node may now declare EVENT trigger templates (kv/docs/files/
pubsub) that bind a group-owned handler. cron/queue/email stay app-only
(rejected on a group at both the manifest and the reconcile layer).

- Trigger is now owner-polymorphic (app_id: Option, group_id: Option),
  mirroring Script's Phase-4 reshape; TriggerRow carries group_id
  (#[sqlx(default)] so interactive RETURNING clauses still hydrate).
- insert_trigger_tx takes a ScriptOwner (App XOR Group) and writes the
  group_id column; the queue advisory-lock branch is app-only.
- TriggerRepo::list_for_group loads a group's templates; load_current's
  Group arm uses it so the diff is NoOp on re-apply and prune-able.
- apply_service reconcile drops the "triggers are app-only" assumption;
  validate_bundle_for rejects non-event kinds on a group. The semantic-
  identity diff is already per-owner.
- CLI manifest allows event-kind [[triggers]] on [group], rejects
  cron/queue/email there.

Templates don't fire yet — the live dispatch union lands in T3. All
trigger/apply/manifest unit tests green; clippy -D clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 20:38:28 +02:00
parent 7f51087d5d
commit 547004e32d
4 changed files with 145 additions and 44 deletions

View File

@@ -698,7 +698,9 @@ async fn delete_trigger(
.get(trigger_id)
.await?
.ok_or(TriggersApiError::NotFound(trigger_id))?;
if trigger.app_id != app_id {
// Interactive trigger management is app-scoped; a group TEMPLATE
// (app_id NULL) is never addressable here, so a mismatch (incl. None) 404s.
if trigger.app_id != Some(app_id) {
return Err(TriggersApiError::NotFound(trigger_id));
}
if !s.triggers.delete(trigger_id).await? {
@@ -900,7 +902,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Kv,
@@ -929,7 +932,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Docs,
@@ -958,7 +962,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::DeadLetter,
@@ -988,7 +993,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Email,
@@ -1015,7 +1021,7 @@ mod tests {
Ok(g.get(&trigger_id)
.filter(|t| t.kind == TriggerKind::Email)
.map(|t| EmailInboundTarget {
app_id: t.app_id,
app_id: t.app_id.expect("email trigger is app-owned"),
script_id: t.script_id,
enabled: t.enabled,
dispatch_mode: t.dispatch_mode,
@@ -1033,7 +1039,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Cron,
@@ -1063,7 +1070,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Files,
@@ -1092,7 +1100,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: crate::trigger_repo::TriggerKind::Pubsub,
@@ -1117,7 +1126,7 @@ mod tests {
.lock()
.await
.values()
.filter(|t| t.app_id == app_id)
.filter(|t| t.app_id == Some(app_id))
.cloned()
.collect())
}
@@ -1169,7 +1178,8 @@ mod tests {
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
app_id: Some(app_id),
group_id: None,
script_id: req.script_id,
name: "mock".into(),
kind: TriggerKind::Queue,