feat(stateful-templates): materialize group email templates (M5.5)

Close the last M5 gap: a [group] may now declare an email trigger
template, materialized into a per-descendant-app row like cron/queue.

Uses the shared-group-secret model: the template resolves its
inbound_secret_ref against the GROUP's own secret store once at apply and
seals it (resolve_and_seal + insert_email_trigger_tx generalized to a
SecretOwner / ScriptOwner). Email secrets are v0/no-AAD, so the sealed
blob is portable across rows — materialize copies the bytes verbatim into
each descendant (no master key, no reseal, no new schema column, no
threading into the CRUD hooks). Each copy has its own trigger_id / inbound
webhook URL.

- apply_service/manifest: drop the two group-email rejections; a group
  email template with an unset group secret still fails apply hard.
- materialize: add "email" to MATERIALIZED_KINDS + a byte-copy detail arm.
- trigger_repo: email_inbound_target gains AND t.app_id IS NOT NULL so a
  group TEMPLATE row is never directly invocable via its own webhook URL
  (mirrors the cron/queue app_id guards).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-02 20:24:10 +02:00
parent 05373814b2
commit 9dc9191cb2
4 changed files with 80 additions and 57 deletions

View File

@@ -75,19 +75,12 @@ impl Manifest {
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
// fan out live to descendant apps. As of §11 tail M1 a group may also
// declare `[suppress]` to decline a template it inherits from a higher
// ancestor, for its whole subtree. The stateful trigger kinds
// (cron/queue/email) stay app-only — they need per-app state/secrets →
// materialization.
// §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"
);
}
// ancestor, for its whole subtree.
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron,
// queue, and email materialize a per-descendant-app row. An email
// template resolves its `inbound_secret_ref` against the GROUP's own
// secret store once at apply (shared-group-secret model); the server
// rejects it there if the secret is unset.
Ok(m)
}