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