feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)

§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).

- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
  provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
  on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
  plan); tree Phase B expands each chain template into a concrete route per
  descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
  app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
  Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
  is no-churn, --prune reaps expansions). Collision with a hand-declared route,
  or between two templates, is a hard error. Each RESOLVED expansion is
  validated like a hand-declared route (reserved-path, path/host parse,
  host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
  count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
  AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
  prune-reap + collision-rejected); placeholder/validation unit tests; schema
  golden reblessed.

Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-28 15:58:41 +02:00
parent 193336a8a6
commit 0396866698
18 changed files with 1504 additions and 37 deletions

View File

@@ -375,11 +375,31 @@ Two distinct constraints:
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
> committing to the narrow-inheritance choice (§5.1).
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
> 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
> (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
> 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).
### 4.6 Secrets & `pull`