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

@@ -0,0 +1,49 @@
-- 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);