feat(scripts): inherited resolution for invoke() + chain-aware dispatch guards

Phase 4-lite C3. A descendant app can now resolve and run a group-owned script
inherited down its ownership chain — the core of group-script inheritance.

Resolver (repo, reusing config_resolver's CHAIN_LEVELS_CTE):
  * `get_by_name_inherited(app_id, name)` — walk app → ancestor groups
    nearest-first, return the nearest script of that name (an app's own script
    shadows an inherited group one: CoW).
  * `is_invocable_by_app(script_id, app_id)` — EXISTS over the same chain; the
    cross-app isolation predicate generalized to the hierarchy.

invoke(): `invoke("name", …)` resolves via `get_by_name_inherited`, so a script
can call a shared group endpoint by name. A group script always runs under the
*caller's* app context, never its owner.

Dispatch guards (the isolation backstops touched in C1) are now chain-aware via
a `Dispatcher::script_invocable` helper and the chain-aware
`validate_trigger_target`: app-owned scripts keep the in-memory fast path (no
query, behavior byte-identical to the pre-Phase-4 same-app guard); only a
group-owned candidate pays one chain query, and a non-member / query error
fails closed (dead-letter or reject — never run under another app's SdkCallCx).

The orchestrator HTTP route path needs no change: its per-app route trie
already scopes routes to one app, and it dispatches under the route's app
without reading script.app_id — a route bound to a group script just works.

Live-validated against the dev DB: an app under group G invoking the group's
`shared` returns its output; an app NOT under G cannot resolve it (isolation);
and after the app deploys its own `shared`, the same call returns the app's
version (CoW shadowing). Route/trigger binding of inherited scripts via the
declarative engine is C4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:23:07 +02:00
parent 43ed4e8e7f
commit 719a17432f
4 changed files with 194 additions and 22 deletions

View File

@@ -106,9 +106,13 @@ impl InvokeServiceImpl {
cx: &SdkCallCx,
name: &str,
) -> Result<ResolvedScript, InvokeError> {
// Phase 4: resolve down the app's chain — an inherited group script
// of this name is reachable, nearest-owner-wins (an app's own script
// shadows it). The chain only contains the app + its ancestors, so the
// resolved script is invocable by `cx.app_id` by construction.
let script = self
.scripts
.get_by_name(cx.app_id, name)
.get_by_name_inherited(cx.app_id, name)
.await
.map_err(|e| InvokeError::Backend(e.to_string()))?
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
@@ -117,8 +121,8 @@ impl InvokeServiceImpl {
}
Ok(ResolvedScript {
script_id: script.id,
// Resolved within `cx.app_id`'s own scope (get_by_name filters by
// it), so the execution-context app is the caller's app.
// The execution-context app is always the caller's app — a group
// script runs under the inheriting app, never its owner.
app_id: cx.app_id,
source: script.source,
updated_at: script.updated_at,
@@ -240,6 +244,21 @@ mod tests {
},
)
}
async fn get_by_name_inherited(
&self,
app_id: AppId,
name: &str,
) -> Result<Option<Script>, ScriptRepositoryError> {
// This one-script repo has no hierarchy; inherited == own-scope.
self.get_by_name(app_id, name).await
}
async fn is_invocable_by_app(
&self,
script_id: ScriptId,
_app_id: AppId,
) -> Result<bool, ScriptRepositoryError> {
Ok(script_id == self.script.id)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
Ok(vec![self.script.clone()])
}