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
|
/// # Errors
|
||||||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
|
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?;
|
self.validate_route_hosts(app_id, bundle).await?;
|
||||||
let current = self.load_current(app_id).await?;
|
let current = self.load_current(app_id).await?;
|
||||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||||
Ok(PlanResult {
|
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
|
// Fingerprint of the live state this plan was computed against, so
|
||||||
// `apply` can refuse if the app changed underneath it (§4.2).
|
// `apply` can refuse if the app changed underneath it (§4.2).
|
||||||
state_token: state_token(¤t),
|
state_token: state_token(¤t),
|
||||||
@@ -446,7 +447,8 @@ impl ApplyService {
|
|||||||
actor: AdminUserId,
|
actor: AdminUserId,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> 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?;
|
self.validate_route_hosts(app_id, bundle).await?;
|
||||||
|
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
@@ -485,7 +487,7 @@ impl ApplyService {
|
|||||||
// Surface a missing email-secret reference here so `plan` and `apply`
|
// Surface a missing email-secret reference here so `plan` and `apply`
|
||||||
// agree, rather than only failing deep in `resolve_and_seal` below.
|
// agree, rather than only failing deep in `resolve_and_seal` below.
|
||||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
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();
|
let mut report = ApplyReport::default();
|
||||||
// §4.7 warning: an enabled binding pointing at a disabled script is
|
// §4.7 warning: an enabled binding pointing at a disabled script is
|
||||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
// 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
|
// 2. Routes — identity is the (method, host, path) tuple; an
|
||||||
// "update" (rebind / dispatch change) is delete + insert.
|
// "update" (rebind / dispatch change) is delete + insert.
|
||||||
let current_routes: HashMap<String, &Route> = current
|
let current_routes: HashMap<String, &Route> = current
|
||||||
@@ -924,7 +934,62 @@ impl ApplyService {
|
|||||||
/// is NOT checked — the DB unique index catches only identical tuples,
|
/// is NOT checked — the DB unique index catches only identical tuples,
|
||||||
/// so overlapping patterns are accepted.
|
/// so overlapping patterns are accepted.
|
||||||
#[allow(clippy::too_many_lines)]
|
#[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.
|
// Scripts: unique names; each source compiles for its kind.
|
||||||
let mut names: HashSet<String> = HashSet::new();
|
let mut names: HashSet<String> = HashSet::new();
|
||||||
for s in &bundle.scripts {
|
for s in &bundle.scripts {
|
||||||
@@ -974,7 +1039,8 @@ impl ApplyService {
|
|||||||
.map_err(|e| ApplyError::Invalid(format!("route path `{}`: {e}", r.path)))?;
|
.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())
|
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)))?;
|
.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!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"route {} binds to unknown script `{}`",
|
"route {} binds to unknown script `{}`",
|
||||||
route_key(
|
route_key(
|
||||||
@@ -987,7 +1053,9 @@ impl ApplyService {
|
|||||||
r.script
|
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!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"route binds to `{}` which is a module, not an endpoint",
|
"route binds to `{}` which is a module, not an endpoint",
|
||||||
r.script
|
r.script
|
||||||
@@ -1008,14 +1076,15 @@ impl ApplyService {
|
|||||||
// Triggers: bind to an endpoint script; unique semantic identity.
|
// Triggers: bind to an endpoint script; unique semantic identity.
|
||||||
let mut trig_keys: HashSet<String> = HashSet::new();
|
let mut trig_keys: HashSet<String> = HashSet::new();
|
||||||
for t in &bundle.triggers {
|
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!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"{} trigger binds to unknown script `{}`",
|
"{} trigger binds to unknown script `{}`",
|
||||||
t.kind_str(),
|
t.kind_str(),
|
||||||
t.script()
|
t.script()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if !endpoints.contains(t.script()) {
|
if !inherited && !endpoints.contains(t.script()) {
|
||||||
return Err(ApplyError::Invalid(format!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"{} trigger binds to `{}` which is a module, not an endpoint",
|
"{} trigger binds to `{}` which is a module, not an endpoint",
|
||||||
t.kind_str(),
|
t.kind_str(),
|
||||||
@@ -1106,10 +1175,26 @@ impl ApplyService {
|
|||||||
/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
|
/// Diff desired `bundle` against `current`. Pure — unit-tested directly.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn compute_diff(current: &CurrentState, bundle: &Bundle) -> Plan {
|
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
|
let script_name_by_id: HashMap<ScriptId, String> = current
|
||||||
.scripts
|
.scripts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (s.id, s.name.clone()))
|
.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();
|
.collect();
|
||||||
|
|
||||||
Plan {
|
Plan {
|
||||||
@@ -1785,6 +1870,12 @@ pub struct ApplyReport {
|
|||||||
pub warnings: Vec<String>,
|
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(
|
fn resolve_script(
|
||||||
name_to_id: &HashMap<String, ScriptId>,
|
name_to_id: &HashMap<String, ScriptId>,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|||||||
Reference in New Issue
Block a user