-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5). -- -- Triggers and routes are app-scoped (never group-inherited — §5.1). At high -- tenant cardinality that means 100 apps × N routes = 100N hand declarations. -- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine -- fans it out into one concrete per-`app_id` row for every descendant app. This -- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the -- isolation boundary is unchanged); the template is just a stamp. -- -- Placeholders in template fields are a small, inert, documented set resolved -- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's -- effective var). Provenance: each expanded `routes` row carries `from_template` -- (the template id it was stamped from), so re-apply diffs idempotently and -- `--prune` removes expansions WITHOUT touching a hand-declared route of the -- same identity. -- -- (Trigger templates reuse this engine in a follow-up: `trigger_templates` + -- `triggers.from_template`.) -- Route templates — one per (group, name). Mirrors the `routes` column shape -- minus `app_id` (filled per descendant) plus `script_name` (resolved to the -- nearest inherited endpoint at expansion, like declarative route bindings). CREATE TABLE route_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, -- Explicit identity/upsert key, unique per group (case-insensitive). name TEXT NOT NULL, script_name TEXT NOT NULL, method TEXT, host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')), host TEXT NOT NULL DEFAULT '', host_param_name TEXT, path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')), path TEXT NOT NULL, dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')), enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX route_templates_group_name_idx ON route_templates (group_id, LOWER(name)); -- Provenance on the expanded rows. NULL = hand-declared (the app's own -- manifest); non-NULL = stamped from this template, managed by template -- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it -- deletes orphaned expansions explicitly), and a dangling id after a raw -- template delete is harmless (treated as "template gone → prune the row"). ALTER TABLE routes ADD COLUMN from_template UUID; CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);