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:
@@ -379,27 +379,35 @@ Two distinct constraints:
|
||||
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||
>
|
||||
> **Status: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a
|
||||
> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A
|
||||
> `[group]` manifest declares `[[route_templates]]`; `pic apply --dir` reconciles the template rows
|
||||
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
|
||||
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
|
||||
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
|
||||
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
|
||||
> all seven kinds); `pic apply --dir` reconciles the template rows
|
||||
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
||||
> concrete `routes` row per descendant app node** in the apply, resolving the script binding
|
||||
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
|
||||
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
||||
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
||||
> error). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so
|
||||
> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an
|
||||
> expansion colliding with a hand-declared route, or two templates colliding, is a hard error. `plan`
|
||||
> reports the **blast radius** (descendant app count) and the `state_token` folds each template, so an
|
||||
> edit between plan and apply trips `StateMoved`. Authz: declaring a template needs `GroupScriptsWrite`;
|
||||
> an app that *receives* expansions needs `AppWriteRoute` (gated even when the app declares no routes
|
||||
> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets —
|
||||
> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected
|
||||
> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES
|
||||
> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another
|
||||
> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied
|
||||
> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands
|
||||
> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert
|
||||
> path (the seven kinds + email-secret sealing).
|
||||
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
|
||||
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
|
||||
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
|
||||
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
|
||||
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
|
||||
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
|
||||
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
|
||||
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
|
||||
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
|
||||
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
|
||||
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
|
||||
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
|
||||
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
|
||||
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
|
||||
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets app NODES present in the tree apply
|
||||
> (the common `pic apply --dir` case), not yet every descendant in another repo — deleting a template
|
||||
> leaves expansions in out-of-apply descendants until they're re-applied (the apply warns);
|
||||
> `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands next apply). A
|
||||
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
|
||||
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
|
||||
|
||||
### 4.6 Secrets & `pull`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user