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:
@@ -289,10 +289,17 @@ async fn validate_trigger_target(
|
||||
.ok_or_else(|| {
|
||||
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
|
||||
})?;
|
||||
// Phase 4: trigger targets are app-owned scripts; a group-owned script
|
||||
// (`app_id: None`) fails closed here until group-script trigger binding
|
||||
// lands in C3.
|
||||
if !script.is_owned_by_app(app_id) {
|
||||
// Phase 4: a trigger target must be invocable in this app's context —
|
||||
// owned by the app, or by a group on the app's ancestor chain (inherited).
|
||||
// App-owned is the in-memory fast path (no query); only a group-owned
|
||||
// candidate pays the chain lookup.
|
||||
let invocable = script.is_owned_by_app(app_id)
|
||||
|| (script.group_id.is_some()
|
||||
&& scripts
|
||||
.is_invocable_by_app(script_id, app_id)
|
||||
.await
|
||||
.map_err(map_script_repo_err)?);
|
||||
if !invocable {
|
||||
return Err(TriggersApiError::Invalid(format!(
|
||||
"script {script_id} does not belong to this app"
|
||||
)));
|
||||
@@ -1400,6 +1407,26 @@ mod tests {
|
||||
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
||||
.cloned())
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
// No hierarchy in this in-memory repo; 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, crate::repo::ScriptRepositoryError> {
|
||||
Ok(self
|
||||
.existing
|
||||
.lock()
|
||||
.await
|
||||
.get(&script_id)
|
||||
.is_some_and(|s| s.is_owned_by_app(app_id)))
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
|
||||
Reference in New Issue
Block a user