fix(enabled): close disabled-script execution on the async paths (review)

Holistic Phase-1 review found the "a disabled script can't execute via
any path" guarantee (§4.3) held only for the sync user-route, the
execute-by-id bypass, and the trigger outbox arm — three paths still ran
disabled scripts:

- Queue arm: `list_active_queue_consumers` filtered the trigger's
  `enabled` but not the bound script's. Add `JOIN scripts … AND
  s.enabled = TRUE` so a disabled script's queue trigger stops consuming.
- Async-HTTP (202) + queued invoke() arms: `build_http_request` /
  `build_invoke_request` hardcoded `active: true`. Set it to
  `script.enabled`, and MOVE the dispatcher's fire-time `active` drop to
  after the source-kind match so it covers all three arms uniformly
  (previously trigger-only).
- `invoke()` (script-to-script): `resolve_id`/`resolve_name` checked
  cross-app isolation but not `enabled`. A disabled target now resolves
  to NotFound (indistinguishable from absent), matching the data plane.

Also (review LOW/§4.7):
- The route-on/script-off 404 returned `NotFound(script_id)`, leaking the
  internal id and distinguishable from absent. Return the same flat "no
  route matches" 404 as the unmatched case.
- Add the §4.7 "enabled endpoint with no route and no trigger" reachability
  warning (was unimplemented; only disabled-target shipped).

Tested: manager-core lib 368 + orchestrator 75 + 22 project-tool journeys
(incl. a new enabled-route→disabled-script flat-404 e2e) green; clippy
-D warnings clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-23 21:59:50 +02:00
parent 5e62f4acfe
commit b3f05dfe2a
6 changed files with 155 additions and 24 deletions

View File

@@ -611,23 +611,6 @@ impl Dispatcher {
| OutboxSourceKind::Pubsub
| OutboxSourceKind::Email => {
let resolved = self.resolve_trigger(&row).await?;
// §4.3 fire-time re-check: if the trigger or its target script
// was disabled after this row was enqueued, drop the row rather
// than fire a stale event. Closes the outbox gap where the
// match-time `enabled` check can't see a later toggle.
if !resolved.active {
tracing::debug!(
outbox_id = %row.id,
app_id = %row.app_id,
"trigger or target script disabled since enqueue; dropping row"
);
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
}
let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,
Err(err) => {
@@ -644,6 +627,26 @@ impl Dispatcher {
}
};
// §4.3 fire-time re-check, for EVERY outbox source (trigger, async
// HTTP/202, and invoke): if the target script (or, for triggers, the
// trigger) was disabled after this row was enqueued, drop it rather
// than fire a stale event. The match-time `enabled` check can't see a
// later toggle, so this is the gate that makes a disabled script
// genuinely non-invocable on the async paths too.
if !resolved.active {
tracing::debug!(
outbox_id = %row.id,
app_id = %row.app_id,
"target script/trigger disabled since enqueue; dropping outbox row"
);
self.outbox
.delete(row.id)
.await
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
drop(permit);
return Ok(());
}
// The gate permit auto-releases when this scope ends or when
// the executor finishes. We hand control to the executor and
// wait synchronously here — sync HTTP and dispatcher share the
@@ -863,7 +866,9 @@ impl Dispatcher {
let resolved = ResolvedTrigger {
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
is_dead_letter_handler: false,
active: true,
// §4.3: an async-HTTP (202) row whose script was disabled after
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id,
script_source: script.source,
script_name: payload.script_name,
@@ -961,7 +966,9 @@ impl Dispatcher {
let resolved = ResolvedTrigger {
trigger_kind: TriggerKind::Cron, // placeholder; not used downstream
is_dead_letter_handler: false,
active: true,
// §4.3: a queued invoke() whose target script was disabled after
// enqueue is dropped at fire time by the post-match active check.
active: script.enabled,
script_id: script.id,
script_source: script.source,
script_name: script.name,