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:
@@ -588,10 +588,25 @@ impl ApplyService {
|
||||
"a group manifest cannot declare routes — routes bind to an app".into(),
|
||||
));
|
||||
}
|
||||
if !bundle.triggers.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"a group manifest cannot declare triggers — triggers belong 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.
|
||||
for t in &bundle.triggers {
|
||||
if !matches!(
|
||||
t,
|
||||
BundleTrigger::Kv { .. }
|
||||
| BundleTrigger::Docs { .. }
|
||||
| BundleTrigger::Files { .. }
|
||||
| BundleTrigger::Pubsub { .. }
|
||||
) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"a group trigger template must be an event kind \
|
||||
(kv, docs, files, pubsub); cron/queue/email are app-only"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// §11.6: shared collections are owned by GROUPS. Reject them on an app
|
||||
@@ -793,17 +808,19 @@ impl ApplyService {
|
||||
if ch.op != Op::Create {
|
||||
continue;
|
||||
}
|
||||
// Triggers only exist on app nodes (a group bundle has none).
|
||||
let app_id = owner.app_id().expect("triggers only exist on app nodes");
|
||||
// §11 tail: an app node owns triggers; a group node owns trigger
|
||||
// TEMPLATES (event kinds only — validated in `validate_bundle_for`).
|
||||
let bt = bundle_triggers[&ch.key];
|
||||
let sid = resolve_script(&name_to_id, bt.script())?;
|
||||
if let BundleTrigger::Email {
|
||||
inbound_secret_ref, ..
|
||||
} = bt
|
||||
{
|
||||
// Resolve the referenced secret, decrypt it, and re-seal it
|
||||
// into the email trigger's detail row — the value never
|
||||
// travels in the manifest.
|
||||
// Email is app-only (a group template rejects it in
|
||||
// `validate_bundle_for`). Resolve the referenced secret,
|
||||
// decrypt it, and re-seal it into the email trigger's detail
|
||||
// row — the value never travels in the manifest.
|
||||
let app_id = owner.app_id().expect("email triggers are app-only");
|
||||
let (ct, nonce) = self.resolve_and_seal(app_id, inbound_secret_ref).await?;
|
||||
insert_email_trigger_tx(&mut *tx, app_id, sid, actor, &ct, &nonce)
|
||||
.await
|
||||
@@ -815,7 +832,15 @@ impl ApplyService {
|
||||
self.trigger_config.queue_default_visibility_timeout_secs,
|
||||
);
|
||||
insert_trigger_tx(
|
||||
&mut *tx, app_id, sid, actor, dispatch, retry_max, backoff, base, &details,
|
||||
&mut *tx,
|
||||
owner.as_script_owner(),
|
||||
sid,
|
||||
actor,
|
||||
dispatch,
|
||||
retry_max,
|
||||
backoff,
|
||||
base,
|
||||
&details,
|
||||
)
|
||||
.await
|
||||
.map_err(map_trig)?;
|
||||
@@ -2080,7 +2105,12 @@ impl ApplyService {
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
// §11 tail: a group owns trigger TEMPLATES; load them so the
|
||||
// diff sees existing markers (NoOp on re-apply, prune-able).
|
||||
self.triggers
|
||||
.list_for_group(group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
Vec::new(),
|
||||
VarOwner::Group(group_id),
|
||||
),
|
||||
@@ -3491,7 +3521,8 @@ mod tests {
|
||||
};
|
||||
Trigger {
|
||||
id: TriggerId::from(uuid::Uuid::new_v4()),
|
||||
app_id: AppId::from(uuid::Uuid::nil()),
|
||||
app_id: Some(AppId::from(uuid::Uuid::nil())),
|
||||
group_id: None,
|
||||
script_id,
|
||||
name: "t".into(),
|
||||
kind,
|
||||
|
||||
Reference in New Issue
Block a user