diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 5158628..8017cc0 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -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.