feat(apply): declaratively bind routes/triggers to inherited group scripts

Phase 4-lite C4 — the headline declarative path. An app's manifest can now bind
a `[[routes]]` / `[[triggers]]` to a script it does NOT declare, resolving the
name to an inherited group-owned endpoint on the app's chain (nearest-owner
wins; an app's own script of the same name still shadows it — CoW).

  * `resolve_inherited_targets(app_id, bundle)` resolves every route/trigger
    target name that isn't an app-own manifest script to a group endpoint via
    `get_by_name_inherited`, returning `name → script_id`. Run once before the
    apply transaction (group scripts pre-exist, committed).
  * `validate_bundle` takes the inherited endpoint names so "binds to unknown
    script" / "is a module" checks pass for inherited targets.
  * The apply transaction merges the inherited targets into `name_to_id`
    (app-own keeps precedence), so route/trigger inserts bind to the group
    script's id.
  * `compute_diff_with_inherited` adds the inherited ids → names to the diff's
    name map, so a route bound to a group script resolves its name and
    re-apply is idempotent (without this it read as a perpetual rebind).

Live-validated: an app under group G with a manifest binding `GET /greet` to
the group's inherited `greet` plans as `route create → greet` (no script
create), applies (+1 route, +0 scripts), and re-plans as `noop` (idempotent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:32:24 +02:00
parent 719a17432f
commit 701ec90b56

View File

@@ -419,12 +419,13 @@ impl ApplyService {
/// # Errors
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
self.validate_bundle(bundle)?;
let inherited = self.resolve_inherited_targets(app_id, bundle).await?;
self.validate_bundle(bundle, &keys_set(&inherited))?;
self.validate_route_hosts(app_id, bundle).await?;
let current = self.load_current(app_id).await?;
validate_email_secrets_present(bundle, &current.secret_names)?;
Ok(PlanResult {
plan: compute_diff(&current, bundle),
plan: compute_diff_with_inherited(&current, bundle, &inherited),
// Fingerprint of the live state this plan was computed against, so
// `apply` can refuse if the app changed underneath it (§4.2).
state_token: state_token(&current),
@@ -446,7 +447,8 @@ impl ApplyService {
actor: AdminUserId,
expected_token: Option<&str>,
) -> Result<ApplyReport, ApplyError> {
self.validate_bundle(bundle)?;
let inherited = self.resolve_inherited_targets(app_id, bundle).await?;
self.validate_bundle(bundle, &keys_set(&inherited))?;
self.validate_route_hosts(app_id, bundle).await?;
let mut tx = self
@@ -485,7 +487,7 @@ impl ApplyService {
// Surface a missing email-secret reference here so `plan` and `apply`
// agree, rather than only failing deep in `resolve_and_seal` below.
validate_email_secrets_present(bundle, &current.secret_names)?;
let plan = compute_diff(&current, bundle);
let plan = compute_diff_with_inherited(&current, bundle, &inherited);
let mut report = ApplyReport::default();
// §4.7 warning: an enabled binding pointing at a disabled script is
// deployed-but-unreachable. Not an error (it's valid desired state),
@@ -562,6 +564,14 @@ impl ApplyService {
}
}
// Phase 4: route/trigger targets the manifest doesn't define as app-own
// scripts may bind to inherited group endpoints. `or_insert` keeps
// app-own precedence (those names aren't in `inherited` by
// construction, but be defensive about CoW).
for (name, id) in &inherited {
name_to_id.entry(name.clone()).or_insert(*id);
}
// 2. Routes — identity is the (method, host, path) tuple; an
// "update" (rebind / dispatch change) is delete + insert.
let current_routes: HashMap<String, &Route> = current
@@ -924,7 +934,62 @@ impl ApplyService {
/// is NOT checked — the DB unique index catches only identical tuples,
/// so overlapping patterns are accepted.
#[allow(clippy::too_many_lines)]
fn validate_bundle(&self, bundle: &Bundle) -> Result<(), ApplyError> {
/// Phase 4: resolve route/trigger target names that the manifest does NOT
/// declare as app-own scripts to inherited group-owned endpoint scripts on
/// the app's chain. Returns `name(lowercased) → script_id` for the ones
/// that resolve to a group endpoint. App-own names (in `bundle.scripts`)
/// are skipped — they bind through the normal `name_to_id` path and take
/// precedence (CoW). Resolved before the apply transaction; the group
/// scripts pre-exist (committed), so a pool read is correct and stable.
async fn resolve_inherited_targets(
&self,
app_id: AppId,
bundle: &Bundle,
) -> Result<HashMap<String, ScriptId>, ApplyError> {
let own: HashSet<String> = bundle
.scripts
.iter()
.map(|s| s.name.to_lowercase())
.collect();
let mut referenced: HashSet<String> = HashSet::new();
for r in &bundle.routes {
referenced.insert(r.script.to_lowercase());
}
for t in &bundle.triggers {
referenced.insert(t.script().to_lowercase());
}
let mut out = HashMap::new();
for name in referenced {
if own.contains(&name) {
continue;
}
if let Some(s) = self
.scripts
.get_by_name_inherited(app_id, &name)
.await
.map_err(map_repo)?
{
// Only genuinely-inherited group endpoints qualify. A depth-0
// app-own match is already covered by `name_to_id`; skip it so
// the manifest keeps precedence. Group modules don't exist in
// Phase 4-lite, but guard kind anyway.
if s.group_id.is_some() && s.kind == ScriptKind::Endpoint {
out.insert(name, s.id);
}
}
}
Ok(out)
}
/// `inherited_endpoints` (lowercased names) are group-owned endpoint
/// scripts reachable from the app's chain that a route/trigger may bind to
/// even though the manifest doesn't declare them (Phase 4). They count as
/// known endpoints for the binding checks below.
fn validate_bundle(
&self,
bundle: &Bundle,
inherited_endpoints: &HashSet<String>,
) -> Result<(), ApplyError> {
// Scripts: unique names; each source compiles for its kind.
let mut names: HashSet<String> = HashSet::new();
for s in &bundle.scripts {
@@ -974,7 +1039,8 @@ impl ApplyService {
.map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?;
pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())
.map_err(|e| ApplyError::Invalid(format!("route host `{}`: {e}", r.host)))?;
if !all_names.contains(r.script.as_str()) {
let inherited = inherited_endpoints.contains(&r.script.to_lowercase());
if !all_names.contains(r.script.as_str()) && !inherited {
return Err(ApplyError::Invalid(format!(
"route {} binds to unknown script `{}`",
route_key(
@@ -987,7 +1053,9 @@ impl ApplyService {
r.script
)));
}
if !endpoints.contains(r.script.as_str()) {
// Inherited targets are endpoints by construction (group modules
// don't exist in Phase 4-lite); only check kind for app-own names.
if !inherited && !endpoints.contains(r.script.as_str()) {
return Err(ApplyError::Invalid(format!(
"route binds to `{}` which is a module, not an endpoint",
r.script
@@ -1008,14 +1076,15 @@ impl ApplyService {
// Triggers: bind to an endpoint script; unique semantic identity.
let mut trig_keys: HashSet<String> = HashSet::new();
for t in &bundle.triggers {
if !all_names.contains(t.script()) {
let inherited = inherited_endpoints.contains(&t.script().to_lowercase());
if !all_names.contains(t.script()) && !inherited {
return Err(ApplyError::Invalid(format!(
"{} trigger binds to unknown script `{}`",
t.kind_str(),
t.script()
)));
}
if !endpoints.contains(t.script()) {
if !inherited && !endpoints.contains(t.script()) {
return Err(ApplyError::Invalid(format!(
"{} trigger binds to `{}` which is a module, not an endpoint",
t.kind_str(),
@@ -1106,10 +1175,26 @@ impl ApplyService {
/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
#[must_use]
pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan {
compute_diff_with_inherited(current, bundle, &HashMap::new())
}
/// `compute_diff` with the set of inherited group-endpoint targets (Phase 4),
/// `name(lowercased) → script_id`. Those ids are added to the script-name map
/// so a route/trigger bound to an inherited group script resolves to its name
/// and re-apply is idempotent (otherwise the unknown id reads as a rebind
/// every plan). The public `compute_diff` passes an empty map (no inheritance).
fn compute_diff_with_inherited(
current: &CurrentState,
bundle: &Bundle,
inherited: &HashMap<String, ScriptId>,
) -> Plan {
let script_name_by_id: HashMap<ScriptId, String> = current
.scripts
.iter()
.map(|s| (s.id, s.name.clone()))
// Inherited group targets, inverted to id → name. Disjoint from
// app-own ids by construction, so order doesn't matter.
.chain(inherited.iter().map(|(name, id)| (*id, name.clone())))
.collect();
Plan {
@@ -1785,6 +1870,12 @@ pub struct ApplyReport {
pub warnings: Vec<String>,
}
/// The key set of a name→id map, for passing the inherited target *names*
/// into `validate_bundle` without leaking the ids.
fn keys_set(m: &HashMap<String, ScriptId>) -> HashSet<String> {
m.keys().cloned().collect()
}
fn resolve_script(
name_to_id: &HashMap<String, ScriptId>,
name: &str,