diff --git a/crates/manager-core/src/dispatcher.rs b/crates/manager-core/src/dispatcher.rs
index 053b3b5..96b0c52 100644
--- a/crates/manager-core/src/dispatcher.rs
+++ b/crates/manager-core/src/dispatcher.rs
@@ -384,6 +384,39 @@ impl Dispatcher {
return Ok(());
}
+ // Fire-time `enabled` re-check (§4.3). The queue arm does not flow
+ // through `dispatch_one`'s unified `!active` gate; its only other
+ // `enabled` guard is the per-tick `list_active_queue_consumers`
+ // snapshot, which is stale for messages already claimed in this
+ // tick. A script disabled after the list query but before this
+ // claimed message executes must NOT run (`script` here is a fresh
+ // read from line ~331, so `enabled` is current). Release the claim
+ // (nack) rather than ack/dead-letter: the message stays queued and
+ // is processed when the script is re-enabled, and the next tick
+ // won't re-claim it because the list filters `s.enabled`. Without
+ // this nack the message would sit claimed indefinitely —
+ // `reclaim_visibility_timeouts` only reclaims for enabled triggers.
+ if !script.enabled {
+ tracing::info!(
+ script_id = %consumer.script_id,
+ trigger_id = %consumer.trigger_id,
+ "queue consumer script disabled at fire time; releasing claim"
+ );
+ if let Err(e) = self
+ .queue
+ .nack(
+ claimed.id,
+ claimed.claim_token,
+ chrono::Duration::seconds(1),
+ )
+ .await
+ {
+ tracing::warn!(?e, "queue nack on disabled consumer failed");
+ }
+ drop(permit);
+ return Ok(());
+ }
+
let principal = self
.principals
.resolve(consumer.registered_by_principal)
@@ -743,6 +776,22 @@ impl Dispatcher {
DispatcherError::ResolveTrigger(format!("script {} not found", trigger.script_id))
})?;
+ // Audit 2026-06-11 H-F1 sibling — same-app guard mirroring
+ // build_http_request / build_invoke_request / dispatch_one_queue.
+ // `build_exec_request` stamps `ExecRequest.app_id = row.app_id`
+ // while sourcing the body from `trigger.script_id`; without this
+ // check a hand-edited outbox/trigger row (or a partial restore, or
+ // a script re-pointed across apps) could run one app's script under
+ // another app's `SdkCallCx.app_id` — the cross-app isolation
+ // 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.
+ if script.app_id != row.app_id {
+ return Err(DispatcherError::ResolveTrigger(
+ "trigger outbox target belongs to a different app".into(),
+ ));
+ }
+
Ok(ResolvedTrigger {
trigger_kind: trigger.kind,
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
@@ -1535,4 +1584,837 @@ mod tests {
);
assert_eq!(failure_kind_to_status(InboxFailureKind::Platform), 500);
}
+
+ // ----------------------------------------------------------------
+ // Queue-arm fire-time `enabled` gate (§4.3).
+ //
+ // `dispatch_one_queue` re-reads the script fresh after claiming a
+ // message and, if it has been disabled since the per-tick
+ // `list_active_queue_consumers` snapshot, releases the claim (nack)
+ // without resolving a principal or executing. This test proves that
+ // gate end-to-end with in-memory stubs.
+ //
+ // Regression property: the principal resolver returns a valid
+ // `Principal` and the executor records `executed = true` before
+ // erroring, so DELETING the `if !script.enabled` gate makes the flow
+ // fall through to resolve → execute, flipping `executed` and failing
+ // `assert!(!executed)`. (Verified by temporarily removing the gate.)
+ // ----------------------------------------------------------------
+ mod queue_enabled_gate {
+ use super::*;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::Arc;
+
+ use async_trait::async_trait;
+ use chrono::Utc;
+ use picloud_executor_core::{ExecError, ExecRequest, ExecResponse};
+ use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
+ use picloud_shared::{
+ AdminUserId, AppId, InstanceRole, Principal, Script, ScriptId, ScriptSandbox,
+ };
+ use uuid::Uuid;
+
+ use crate::abandoned_repo::{AbandonedRepo, NewAbandonedExecution};
+ use crate::dead_letter_repo::{DeadLetterRepo, NewDeadLetter};
+ use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow};
+ use crate::principal_resolver::{PrincipalResolver, PrincipalResolverError};
+ use crate::queue_repo::{ClaimedMessage, NewQueueMessage, QueueRepo, QueueStats};
+ use crate::repo::{NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError};
+ use crate::trigger_config::BackoffShape;
+ use crate::trigger_repo::{ActiveQueueConsumer, TriggerRepo};
+
+ // ---- ScriptRepository: only `get` is exercised. ----
+ pub(super) struct DisabledScriptRepo {
+ pub(super) script: Script,
+ }
+
+ #[async_trait]
+ impl ScriptRepository for DisabledScriptRepo {
+ async fn get(&self, _id: ScriptId) -> Result