feat(hierarchies): trigger templates — per-app fan-out (M4b)

Completes §4.5 templates (M4a shipped routes). A group declares a trigger once;
the tree apply fans it out into one concrete per-app_id trigger on each
descendant app, reusing the M4a expansion engine over the trigger insert path.

- Migration 0054: `trigger_templates` table (group-owned; the whole
  BundleTrigger wire object stored as `spec` JSONB, placeholders unresolved) +
  `triggers.from_template` provenance column + index.
- `template_repo.rs`: trigger-template CRUD + chain-load.
- Manifest: `[group]` `[[trigger_templates]]` (flat: name, kind, script, + kind
  params); build_bundle emits them; rejected on an app node.
- Apply: group nodes reconcile trigger-template rows; tree Phase B expands each
  chain trigger template into a concrete trigger per descendant app —
  placeholders resolved in every spec string leaf, then the typed trigger is
  rebuilt and inserted through the shared `insert_bundle_trigger_tx` (email
  secrets resolved + re-sealed per recipient app). Idempotent via from_template
  (one trigger per template per app; semantic-identity compare → no-op /
  delete+recreate / reap). Collision with a hand-declared trigger or between
  templates is a hard error. Each resolved trigger is re-validated with the
  per-kind shape check (cron/queue/etc.) so a {var:}-injected bad
  schedule/timeout can't slip in.
- Authz: declaring → GroupScriptsWrite; receiving → AppManageTriggers, plus
  AppSecretsRead for email-trigger recipients (parity with hand-declared email).
- Plan/token/report extended for trigger templates; blast radius covers both.
- Tests: tests/templates.rs trigger fan-out (kv + {app_slug}, stable-ids,
  prune-reap); resolve_placeholders_in_json unit test; schema golden reblessed.

Reviewed (no CRITICAL; isolation + per-app email-secret sealing verified
sound). Closed a HIGH validation-parity gap (re-validate resolved triggers) and
a MEDIUM authz gap (AppSecretsRead for email expansions). v1.2 Hierarchies
template work (M4) complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 17:38:04 +02:00
parent 0396866698
commit 4d2eed4e81
14 changed files with 953 additions and 98 deletions

View File

@@ -58,6 +58,12 @@ pub struct Manifest {
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_templates: Vec<ManifestRouteTemplate>,
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
/// `script = …`, plus the kind's params (string fields may carry
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trigger_templates: Vec<ManifestTriggerTemplate>,
}
impl Manifest {
@@ -306,6 +312,17 @@ pub struct ManifestRouteTemplate {
pub enabled: bool,
}
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggerTemplate {
pub name: String,
#[serde(flatten)]
pub spec: toml::Value,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
@@ -543,6 +560,7 @@ mod tests {
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}
@@ -648,6 +666,7 @@ mod tests {
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");