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:
@@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
@@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
@@ -573,6 +574,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
@@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
|
||||
Reference in New Issue
Block a user