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:
@@ -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, ¤t.secret_names)?;
|
||||
Ok(PlanResult {
|
||||
plan: compute_diff(¤t, bundle),
|
||||
plan: compute_diff_with_inherited(¤t, 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(¤t),
|
||||
@@ -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, ¤t.secret_names)?;
|
||||
let plan = compute_diff(¤t, bundle);
|
||||
let plan = compute_diff_with_inherited(¤t, 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,
|
||||
|
||||
Reference in New Issue
Block a user