diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs
index e6975a1..ab3a2cb 100644
--- a/crates/manager-core/src/dispatcher.rs
+++ b/crates/manager-core/src/dispatcher.rs
@@ -29,9 +29,9 @@ use picloud_executor_core::{
};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
use picloud_shared::{
- DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
+ AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
- RequestId, ScriptId, ScriptSandbox, TriggerEvent,
+ RequestId, Script, ScriptId, ScriptSandbox, TriggerEvent,
};
use rand::Rng;
use uuid::Uuid;
@@ -191,6 +191,32 @@ impl ExecLogContext {
}
impl Dispatcher {
+ /// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
+ /// True when the script is owned by `app_id` directly, or by a group on
+ /// its ancestor chain (an inherited group script). App-owned is the
+ /// in-memory fast path — only a group-owned script pays the chain query,
+ /// and a query error fails closed (not invocable). This generalizes the
+ /// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
+ /// hierarchy without changing behavior for app-owned scripts.
+ async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
+ if script.is_owned_by_app(app_id) {
+ return true;
+ }
+ if script.group_id.is_none() {
+ return false;
+ }
+ match self.scripts.is_invocable_by_app(script.id, app_id).await {
+ Ok(ok) => ok,
+ Err(e) => {
+ tracing::error!(
+ error = %e, script_id = %script.id, %app_id,
+ "chain-membership check failed; treating script as not invocable"
+ );
+ false
+ }
+ }
+ }
+
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
/// run for the process lifetime; returned `JoinHandle`s are
@@ -359,12 +385,12 @@ impl Dispatcher {
// existing consumers. Dead-letter the message so the misfire is
// observable rather than executing one app's script under
// another app's SdkCallCx.
- // Phase 4: a group-owned script (`app_id: None`) is not yet
- // dispatchable here — the chain-membership check that authorizes
- // inherited scripts lands with group-script binding. Until then
- // `is_owned_by_app` is the exact app-owned guard as before and a
- // group script fails closed (dead-lettered, not silently run).
- if !script.is_owned_by_app(claimed.app_id) {
+ // Phase 4: the target must be invocable in the claimed message's app
+ // context — owned by that app or an inherited group script on its
+ // chain. App-owned scripts take the in-memory fast path; only a group
+ // script pays the chain query, and a non-member fails closed
+ // (dead-lettered, not silently run under another app's SdkCallCx).
+ if !self.script_invocable(&script, claimed.app_id).await {
tracing::error!(
script_app = ?script.app_id,
claim_app = %claimed.app_id,
@@ -791,9 +817,9 @@ impl Dispatcher {
// boundary. Not reachable via the trigger-create or `apply` paths
// (both resolve the script within the app's own scope), so this is
// the runtime backstop the other arms already carry.
- // Phase 4: group scripts (`app_id: None`) fail closed here until the
- // chain-membership check lands with group-script binding.
- if !script.is_owned_by_app(row.app_id) {
+ // Phase 4: invocable in the outbox row's app context (app-owned or an
+ // inherited group script on the chain); a non-member fails closed.
+ if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"trigger outbox target belongs to a different app".into(),
));
@@ -883,8 +909,8 @@ impl Dispatcher {
// build_invoke_request and dispatch_one_queue. A hand-edited
// outbox row could otherwise execute one app's script under
// another app's SdkCallCx.
- // Phase 4: group scripts fail closed here (see queue/trigger arms).
- if !script.is_owned_by_app(row.app_id) {
+ // Phase 4: invocable in this app's context (see queue/trigger arms).
+ if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"http outbox target belongs to a different app".into(),
));
@@ -977,8 +1003,8 @@ impl Dispatcher {
})?;
// Same-app guard — the script could have been re-assigned or
// the row could have been hand-edited. Reject mismatches.
- // Phase 4: group scripts fail closed here (see queue/trigger arms).
- if !script.is_owned_by_app(row.app_id) {
+ // Phase 4: invocable in this app's context (app-owned or inherited).
+ if !self.script_invocable(&script, row.app_id).await {
return Err(DispatcherError::ResolveTrigger(
"invoke target belongs to a different app".into(),
));
@@ -1649,6 +1675,20 @@ mod tests {
) -> Result