From 695987d6b7e87a297d04b68dc42fa1612e63ef56 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 19:06:52 +0200 Subject: [PATCH] docs(design): record async disabled-execution + cross-app backstops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/design/groups-and-project-tool.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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.