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:
@@ -34,6 +34,26 @@ pub trait ScriptRepository: Send + Sync {
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
|
||||
/// the app itself, then its ancestor groups nearest-first. Nearest owner
|
||||
/// wins (an app's own script shadows an inherited group one of the same
|
||||
/// name: CoW). Returns `None` if no script in the chain has that name.
|
||||
/// Backs inherited `invoke("name")` and declarative name→id binding.
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
|
||||
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
|
||||
/// ancestor chain. The cross-app isolation predicate generalized to the
|
||||
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
|
||||
/// trigger-bind validation. A missing script returns `false` (fail closed).
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
@@ -97,6 +117,20 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name(app_id, name).await
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name_inherited(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
(**self).is_invocable_by_app(script_id, app_id).await
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list().await
|
||||
}
|
||||
@@ -253,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
|
||||
// join scripts owned at each level, and take the nearest (lowest
|
||||
// depth) row named `name`. Case-insensitive to match the per-owner
|
||||
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
|
||||
let cols = SCRIPT_SELECT_COLS
|
||||
.split(", ")
|
||||
.map(|c| format!("s.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"{cte} SELECT {cols} FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC \
|
||||
LIMIT 1",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
// EXISTS over the same chain: the script's owner (app or group) must
|
||||
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
|
||||
// binds $1 = app_id; $2 = script_id.
|
||||
let exists: (bool,) = sqlx::query_as(&format!(
|
||||
"{cte} SELECT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.id = $2 \
|
||||
)",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists.0)
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||
|
||||
Reference in New Issue
Block a user