docs(design): record async disabled-execution + cross-app backstops

Update §4.3: the fire-time `enabled` re-check is shipped and test-backed on
BOTH async arms (the unified outbox `!resolved.active` gate and the queue
arm's fresh-script nack), with the specific regression tests cited, plus the
same-app backstop now present on every async build path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 19:06:52 +02:00
parent d4b5632db1
commit 695987d6b7

View File

@@ -296,12 +296,23 @@ CLI builds bundle (manifests + script sources) for the subtree
and it is **honored at match/schedule time** (`trigger_repo.rs`, `cron_scheduler.rs` — verified).
New work is `enabled` on **scripts** and **routes** only, plus runtime honoring in the matcher /
invoker.
- **Outbox fire-time gap (verified):** the dispatcher does **not** re-check `enabled` on an
already-enqueued outbox row (`dispatcher.rs:699`), so disabling a trigger stops *new* matches while
a pending item still fires. **Fix:** add an `enabled` re-check (trigger *and* script) at fire time
in `resolve_trigger`, so a pending outbox row for a now-disabled trigger/script is dropped when it
comes up — closing the gap cheaply, with no cache or kill-switch involvement. This is the home for
the §5.1 security-disable guarantee on the trigger path.
- **Fire-time re-check (shipped, test-backed):** the dispatcher re-checks `enabled` at fire time on
every async path, so a pending event for a now-disabled trigger/script is dropped (not executed)
when it comes up — the §5.1 security-disable guarantee on the trigger path. **Outbox arm:**
`resolve_trigger` sets `active = trigger.enabled && script.enabled` and the async-HTTP/invoke
builders set `active = script.enabled`; `dispatch_one` drops the row via a single unified
`if !resolved.active` gate. **Queue arm:** `dispatch_one_queue` runs a separate path (it claims from
`queue_messages`, not the outbox), so in addition to `list_active_queue_consumers` filtering
`s.enabled`/`t.enabled` at list time, it re-checks the **freshly-read** `script.enabled` before
executing and **releases the claim (`nack`)** when disabled — closing the per-tick TOCTOU window
(a script disabled after the list snapshot but before its in-flight message runs). Both arms are
regression-locked: `dispatcher::tests::outbox_enabled_gate::disabled_http_outbox_row_is_dropped_without_executing`
covers the unified `!resolved.active` outbox gate, and
`dispatcher::tests::queue_enabled_gate::disabled_queue_consumer_releases_claim_without_executing`
covers the queue arm. **Same-app backstop:** every async build path (`resolve_trigger`,
`build_http_request`, `build_invoke_request`, `dispatch_one_queue`) rejects a row whose script's
`app_id` ≠ the row's `app_id`, so a hand-edited/restored row can't run one app's script under
another's `SdkCallCx` — the cross-app isolation boundary.
- **Provenance caveat (accepted):** a single boolean carries no "manual vs manifest" provenance, so a
disabled entity looks the same however it got there — which is *why* the §4.2 conflict bit on the
`enabled`/secrets subset exists, to stop the next apply silently reverting an operational disable.