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

@@ -298,6 +298,22 @@ table: queue_trigger_details
visibility_timeout_secs: integer NOT NULL default=30
last_fired_at: timestamp with time zone NULL
table: route_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
script_name: text NOT NULL
method: text NULL
host_kind: text NOT NULL
host: text NOT NULL default=''::text
host_param_name: text NULL
path_kind: text NOT NULL
path: text NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: routes
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -311,6 +327,7 @@ table: routes
app_id: uuid NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
from_template: uuid NULL
table: script_imports
app_id: uuid NOT NULL
@@ -531,8 +548,13 @@ indexes on queue_trigger_details:
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
indexes on route_templates:
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
route_templates_pkey: public.route_templates USING btree (id)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_from_template_idx: public.routes USING btree (app_id, from_template)
routes_lookup_idx: public.routes USING btree (host_kind, host)
routes_pkey: public.routes USING btree (id)
routes_script_id_idx: public.routes USING btree (script_id)
@@ -739,6 +761,13 @@ constraints on queue_trigger_details:
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on route_templates:
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
@@ -841,3 +870,4 @@ constraints on vars:
0050: group scripts
0051: extension points
0052: owner project
0053: templates