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:
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user