From b846c0c6b4d95809d6d6325c6fe13bc02c15ffbd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 01:52:16 -0500 Subject: [PATCH] =?UTF-8?q?docs(events):=20a=20trigger=20can=20start=20a?= =?UTF-8?q?=20run=20(Rust=20=C2=A714.3=20stage=209,=20D370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EVENTS.md §E "Starting a run from a trigger": `starts`, the third emit subscriber, the four rules (verified, one at a time, cooldown, no event.*), the run's origin, and what is deliberately not in it. §D: event_runs.origin and a twelfth table, event_trigger_starts. API surface: triggerStarts on GET /admin/events/:id, origin on the run console. - BACKEND_DESIGN.md: event_runs.origin and event_trigger_starts. - PLAN_REDESIGNS §14.3.21: stage 9 as built, eight settled-in-code points, tests and the Module-uo proof; pointers in §9, §14.3.11 and §14.3.12. This ends the §14.3 build. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- modules/rust/PLAN_REDESIGNS.md | 62 +++++++++++++++++++++++++++++- website/BACKEND_DESIGN.md | 8 ++++ website/EVENTS.md | 70 +++++++++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 7 deletions(-) diff --git a/modules/rust/PLAN_REDESIGNS.md b/modules/rust/PLAN_REDESIGNS.md index 264f0b5..49363a5 100644 --- a/modules/rust/PLAN_REDESIGNS.md +++ b/modules/rust/PLAN_REDESIGNS.md @@ -931,7 +931,8 @@ pulls) is research, not a walk, so it still comes first: build, in §14.3.11's nine stages (two of them core), §13 first. **Stage 1, §13, was built on 2026-10-15 (§14.3.14)**, with D378 and D379. **Stage 2, core's `retryAfterMs` (MODULE_API 1.15.0), was built on 2026-10-15 (§14.3.15).** **Stage 3, TruePVE in the zone step, was built on 2026-10-15 (§14.3.16)**, and **stage 4, Dynamic PVP, the same day (§14.3.17)**, and **stage 5, Dangerous Treasures, the same day (§14.3.18)**, and **stage 6, Guarded Crate, the same day (§14.3.19)**, and **stage 7, ZLevels, the same day (§14.3.20).** **Stage 8, Server Rewards, was taken off the plan on - 2026-10-16 (D381).** + 2026-10-16 (D381).** **Stage 9, core's trigger starts, was built on 2026-10-16 (§14.3.21)**, which ends the + build. **Then one walk, on Oxide and Carbon, of everything still owed (D331):** §2.7; §11's rows; §12.9; §13's and §14's rows; the in-game rows @@ -2755,7 +2756,8 @@ until the end (D331, D359). The org lead is asked before each stage starts. 8. ~~**Server Rewards**~~: dropped on 2026-10-16 (D381), not built. 9. **Core: starting a run from a trigger** (§14.3.12, D370), a website PR of its own with the Module-uo proof, as §2 was. It can be built alongside any stage after 1; it is last here only because nothing else waits on it. - Module-Rust needs no code for it, because the world events are already triggers. + Module-Rust needs no code for it, because the world events are already triggers. **Built 2026-10-16 + (§14.3.21).** #### 14.3.12 Core: starting a run from a trigger (D370) @@ -2791,6 +2793,9 @@ nothing and are counted; two instances emitting the same firing make one run; an `starts`; a dormant trigger; the run's origin on its page and in the audit. **Module-uo proof:** one definition starting on `uo.champ.boss_up`. +> **Built on 2026-10-16** (§14.3.21), as planned here; `started_by` stayed the user column (NULL) and the origin +> went into a column of its own, `event_runs.origin`, because `started_by` is a foreign key to `users`. + **Walk rows.** On a Rust rig: *"start when Bradley is destroyed"* with a short cooldown. Destroy two in a minute and see one run and one ignored firing; destroy one after the cooldown and see a second run. @@ -3184,6 +3189,59 @@ were then cleaned of ZLevels and its files, and put back on the protocol-13 brid **Walk rows** are §14.3.6's, with one more: Server settings' line for a server with and without ZLevels. +#### 14.3.21 Stage 9, built: starting a run from a trigger (2026-10-16) + +Built in `website` as §14.3.12 planned it (D370), and specified in +[EVENTS.md](../../website/EVENTS.md) §E *Starting a run from a trigger*, with the schema in §D and +[BACKEND_DESIGN.md](../../website/BACKEND_DESIGN.md). **No module contract changed and no MODULE_API version +moved**: a definition starts on the triggers modules already register, so Module-Rust and Module-uo need no +code, and the Integration Kit's pin is untouched. + +**What was built:** a spec's `starts` (`{ on, where, cooldown }`, at most ten, validated by the phase gate's +own function, a dormant start refusing publish, `event.*` refused); `events/starts.js`, the emit path's third +subscriber; a table, `event_trigger_starts`, whose conditional `UPDATE` is the claim; `event_runs.origin`; +the editor's *Started by the game* panel (the gate's condition builder, a cooldown, and the line *"Last started +run #12; since then 2 firings ignored while a run was going"*); and the run console's *"Started because … +fired"* line, also on the `run.created` audit line. + +**Settled in the code, and overrulable:** + +1. **An unverified version is not started by a trigger**, and the firing is counted. A trigger start is + unattended, as a scheduled one is, so §K's gate applies to it; §14.3.12 said "verify before publish", and + this is that rule where it actually bites. +2. **"A run is going"** is any run of the definition that has started and not ended, **or is `scheduled` and + already due**, whoever started it. A trigger-started run is `scheduled` for up to one tick, and a scheduled + occurrence in its grace window is about to start. A future occurrence does not block. +3. **The cooldown belongs to each start but is measured from the definition's last trigger-started run.** + Two starts on one definition share that clock; each keeps its own length. +4. **The counts are since the last trigger-started run**, which zeroes them, so the editor answers "is it + ignoring things now". A repeat of the firing that started the last run (same second) is not counted. +5. **The run's scope is `''`**, as a scheduled occurrence's is: there is no scope registry, and the condition + is what narrows a start to one server. +6. **A firing older than the definition's grace window starts nothing** (the missed sweep would take it next + tick), and is logged; one stamped in the future starts now. +7. **The origin keeps the trigger's declared variables only**, bounded at 4 KB (past that, only the variables + the condition named, marked `_truncated`). The run row is admin-only; nothing public reads it. +8. **A step cannot read the firing's variables.** That would put `origin` into `perform()`'s envelope, a + MODULE_API minor, and nothing asks for it yet. Noted in EVENTS.md. + +**Tests.** Server: `eventStarts.test.js`, 21 (the spec rules; one run for a matching firing and its origin; a +non-match; running and cooldown counted; a second run after the cooldown; one firing reported twice; unverified; +stale; future-stamped; `event.*` never looked up; a dormant start; two definitions on one trigger; a create +that throws; the 4 KB bound; the state line). `eventRunnerSql.test.js` gained 7 run against a real MariaDB +(75/75): `findStartingOn` reads only ready definitions' published versions and every entry of the list; five +claims at once have one winner; the cooldown boundary; going runs block and a future occurrence does not; a +claim older than a minute is taken; the counts and their reset; `origin` through the insert. Client: +`eventStarts.test.js`, 8. **The Module-uo proof**, against the real core, the real `uo` module and the dev +MariaDB: a definition starting on `uo.champ.boss_up` where `spawnName` is Abyss, cooldown one minute. Firing +before the dry run: held, counted as unverified. After it: Destard started nothing; Abyss started one run with +its origin (*"A champion boss is up"*, spawnName Abyss, location Felucca 5187, 570); the same firing again +started nothing and counted nothing; a firing while it was going and one inside the cooldown were counted; a +firing after the minute started a second run. Then deleted. + +**Walk rows** are §14.3.12's: on a Rust rig, *"start when Bradley is destroyed"* with a short cooldown; destroy +two in a minute and see one run and one ignored firing; destroy one after the cooldown and see a second run. + ### 14.4 Questions for the org lead **Answered on 2026-10-14:** Q17 as proposed, out (D358). **Q18 against the proposal: the end walk waits for diff --git a/website/BACKEND_DESIGN.md b/website/BACKEND_DESIGN.md index b04bf24..c78b91a 100644 --- a/website/BACKEND_DESIGN.md +++ b/website/BACKEND_DESIGN.md @@ -1136,6 +1136,13 @@ pipeline has already announced without either job standing on the other's toes. means *"the post's job"* — the post admin panel, its retry button, `posts.announced_at` — still means the one with a NULL `run_id`. +**Starting a run from a trigger** (2026-10, Rust PLAN_REDESIGNS §14.3.12) added `event_runs.origin` +(below) and a seventh table, **`event_trigger_starts`**: one row per definition a trigger has tried to +start — `definition_id` (PK, `ON DELETE CASCADE`), `claimed_at`, `last_started_at`, `last_run_id`, the +counts `ignored_running` / `ignored_cooldown` / `ignored_unverified`, and `last_ignored_at` / +`last_ignored_reason`. The claim that makes one-run-at-a-time and the cooldown hold is a conditional +`UPDATE` of it; the counts are since the last trigger-started run and are what the editor shows. + Core owns the engine; a module owns the meaning. No column below carries a game noun: an action id, a `scope`, a resource kind and a budget dimension are opaque strings core stores and never interprets. @@ -1217,6 +1224,7 @@ makes a run reproducible and an audit answerable after the definition has moved | results_published_at | DATETIME NULL | when the run's results table was last ranked and published (phase 10). A stamp rather than a status: *"may I show this table"* and *"when was it settled"* are the same column. `core.results.publish` re-stamps rather than guarding on NULL, because a second publication after a late correction is a real one | | claimed_by / claim_expires_at | VARCHAR(64) NULL / DATETIME NULL | the lease. Written by the runner | | started_by | INT NULL FK→users(id) ON DELETE SET NULL | | +| origin | JSON NULL | the game firing that started it, when a trigger did (2026-10, EVENTS.md §E *Starting a run from a trigger*): trigger id and label, when it fired, its subject, the matched condition and the trigger's declared variables. NULL for a run started by hand or by the schedule. A column and not only a log line because the run log is pruned | | last_error | VARCHAR(500) NULL | | | created_at / updated_at | DATETIME | | diff --git a/website/EVENTS.md b/website/EVENTS.md index 37d0943..2849a17 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -413,8 +413,9 @@ completion, cancellation and abort alike. ## D — Data model -Eleven core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core -tables carry no module prefix. (The count said "nine" over a list of ten from the revision that added +Twelve core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core +tables carry no module prefix. (The twelfth, `event_trigger_starts`, came with starting a run from a +trigger in 2026-10, [§E](#starting-a-run-from-a-trigger).) (The count said "nine" over a list of ten from the revision that added `event_series` until Phase 5 counted them.) | Table | Holds | Why a table | @@ -422,7 +423,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | `event_definitions` | id, title, slug, summary, storyline body, image, `owner_module` **nullable**, `state` `ENUM('draft','ready','archived')`, `current_version_id`, `series_id`, `series_order`, `spec` (the working copy — see below), `concurrency_key`, `grace_seconds`, timezone, `listed` (Phase 14a), created/updated by. | The thing that is listed, searched, scheduled and audited. Three states, not five: an admin publishes their own work, so there is nobody to submit it to. **`listed` is announcement, not permission** — see below. | | `event_series` | id, name, slug, description, ordering. Definitions optionally belong to one, at their own `series_order` within it. | **The arc.** "Royal Spy Mission → Risky Partner → Message From the Void" is continuity that exists nowhere in the tooling this replaces. One small table buys it. | | `event_versions` | `definition_id`, `version`, `spec` JSON — phases, steps, schedule, conditions, announcements — `published_at`, `published_by`, and (Phase 6) `verified_at` / `verified_by`. Immutable. | A run pins one. This is what makes a run reproducible and an audit answerable after an edit. The two `verified_*` columns are the exception that proves the immutability: they describe something that happened **to** the version afterwards — a review — rather than changing the plan reviewed, which is also why a pass recorded against one stays true for ever ([§K](#k--security-model)). | -| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`. **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. | +| `event_runs` | `definition_id`, `version_id`, `scope` (module-opaque), `status`, `health`, `current_phase`, `scheduled_for`, `timezone`, `started_at`, `ended_at`, `cleanup_status`, `claimed_by`, `claim_expires_at`, `started_by`, `origin` JSON (the firing that started it, when a trigger did). **`UNIQUE (definition_id, scope, scheduled_for)`** | The unique index — not the claim — is what makes "one run per occurrence per scope" true under two instances. `scope` is in the key so a worldwide event fans out to many servers without colliding with itself. | | `event_run_steps` | `run_id`, `phase`, `seq`, `action_id`, `params` JSON, `action_version`, `status`, `due_at`, `attempts`, `on_failure`, `idempotency_key`, `claimed_by`, `claim_expires_at`, `last_error`. `INDEX (status, due_at)` | The work queue, claimed with the outbox's compare-and-set. | | `event_action_settings` | `action_id` (the primary key), `enabled`, `caps` JSON (`{dimension: perRunCap}`), `updated_by`, `updated_at`. | **The deployment's switchboard, and the whole of the permission model beyond the role.** One row per action an admin has an opinion about; **a missing row is not "disabled", it is the default for the action's risk class** — see [§K](#k--security-model). Not a grant table — nobody is named, because the role check already answered who. Rows outlive their actions, so uninstalling a module and re-installing it restores the caps the operator chose. | | `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap` **nullable**, `effective_from`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND (cap IS NULL OR consumed + ? <= cap)` — so the cap holds under concurrent steps without a transaction. **A NULL cap is uncapped and still a row**, so the console's meter counts what nothing bounds, and a *missing* row keeps its one meaning: a step spending a dimension its own run's version never priced, which is refused. `effective_from` names the action whose cap won, so a number on the meter traces back to a switch. | @@ -430,6 +431,7 @@ tables carry no module prefix. (The count said "nine" over a list of ten from th | `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque **and NOT NULL**, `score` `DECIMAL(18,4)`, `rank_at`, `joined_at`, `meta` JSON. `UNIQUE (run_id, member_key)` | Results and profile history read it. `SET NULL` not `CASCADE`, matching `engagement_sends`: a record of what happened must survive an account deletion. The unique key is what makes a retried collect step an upsert rather than a doubled leaderboard, and `rank_at` carries the suffix because `rank` is a reserved word from MariaDB 10.2 — one forgotten pair of backticks away from a syntax error in a query nothing runs until a run completes at four in the morning. **Written only from an action's success envelope** (Phase 10): core stores what a module tells it and sources nothing, because a `member_key` → account mapping is one game's. | | `event_run_phase_gates` | `run_id`, `phase`, `kind` `ENUM('after','on')`, `after_seconds`, `trigger_id`, `conditions` JSON, `needed`, `tally`, `entered_at`, `due_at`, `last_event` JSON, `satisfied_at`, `satisfied_by`, `forced_by`. `UNIQUE (run_id, phase)`, `INDEX (trigger_id, satisfied_at)` | **What a phase is waiting for, and how far it has got** (Phase 5). The one fact in this feature that is not derivable from a row somebody already wrote: `{ on: …, count: 3 }` counts things that happen *between* two ticks, and the runner is not running when they happen. The unique key is what makes opening a gate an `INSERT IGNORE`; the index is the emit path's only query and the one index here on a hot path. | | `event_run_log` | `run_id`, `step_id` nullable, `kind` (closed set), `phase`, `detail` JSON, `at`. | `activity_log.detail` is `TEXT` and unqueryable. "Why didn't phase 3 start?" must be a query. | +| `event_trigger_starts` | `definition_id` (the primary key, `CASCADE`), `claimed_at`, `last_started_at`, `last_run_id`, `ignored_running`, `ignored_cooldown`, `ignored_unverified`, `last_ignored_at`, `last_ignored_reason`. | **One row per definition a trigger has tried to start** ([§E](#starting-a-run-from-a-trigger)). The claim that makes "one run at a time" true is a conditional `UPDATE` of it, and the counts are what the editor's *"2 firings ignored while running"* reads. Not columns on `event_definitions`: they change on every firing, and that row's `updated_at` orders the admin list. | > **`listed` is Phase 14a's one addition to this table**, and it is deliberately not a second answer > to what `state` answers. `state` says whether an event is SCHEDULABLE; `listed` says whether it is @@ -639,6 +641,64 @@ publish, because a version runs are pinned to must not wait on a trigger that ca predicate is carried through unvalidated rather than dropped — there is no declaration to check it against, and deleting it would silently discard what the author wrote. +### Starting a run from a trigger + +Built 2026-10-16, the core half of Rust [PLAN_REDESIGNS](../modules/rust/PLAN_REDESIGNS.md) §14.3.12 +(D370). *"When anyone destroys Bradley on Main, start Launch Site Lockdown"*; *"when a champion's boss +appears, start Champion's Call"*. Until then a trigger could only advance a phase of a run already +going, or send a notice. **No module contract changed**: a definition starts on the triggers modules +already register, and no MODULE_API version was bumped. + +**A spec's third top-level key, `starts`**, beside `schedule` (a definition may have either, or +both): + +```js +starts: [{ on: 'rust.world.bradley_destroyed', where: { variable: 'server', cmp: 'eq', value: 'main' }, cooldown: '30m' }] +``` + +`on` and `where` are a phase gate's own — the same catalogue, the same grammar, validated by the same +function, so a variable the trigger does not declare is refused by name at save and a trigger nothing +registers makes the start **dormant** (it saves, and it refuses to publish). `cooldown` is the `after` +grammar with zero allowed, `0m` to `7d`, **30 minutes when left out**, normalised as `after` is. At +most ten starts. The key is omitted when the list is empty, for the reason an absent `advance` is. + +**The emit path's third subscriber**, `events/starts.js`, beside `engine.dispatch` and the gate +observer and in their posture: never awaited, never allowed to reject. A firing looks up the `ready` +definitions whose **published version** starts on it — one query over the versions' JSON; a +deployment has tens of definitions and a JSON column cannot carry an index, and if that ever changes +a side table written at publish is the fix — and for each one whose condition matches: + +1. **The version must have passed its dry run.** A trigger start is unattended, as a scheduled one + is, so §K's gate falls on it: an unverified version starts nothing, and the firing is counted. +2. **One run at a time, and a cooldown.** A firing while a run of the definition is going (started + and not ended, or `scheduled` and already due), or within the matching start's `cooldown` of the + last run a trigger started, starts nothing and is counted. Both rules, and a guard against a claim + younger than a minute, are **one conditional `UPDATE`** on `event_trigger_starts`, so two firings + arriving together cannot both start: the `event_run_budget` argument once more, proved against a + real server in `eventRunnerSql.test.js`. +3. **The run is created now**, through `runs.create()` as every run is, with `source: 'trigger'`, + `scheduled_for` = the firing's time in whole seconds (never in the future), scope `''` (as a + scheduled occurrence's), and `started_by` NULL. The occurrence key then makes **one firing + reported twice one run**, and a repeat of the firing that started the last run is not counted. +4. **Its `origin` is stored on the run** and in the `run.created` audit line: the trigger, its label, + when it fired, its subject, the start's condition in the builder's words, and the trigger's + **declared** variables (nothing else a module sent; past 4 KB only the variables the condition + named). The console reads *"Started because “A champion boss is up” fired (matching spawnName is + "Abyss"): spawnName Abyss · location Felucca 5187, 570"*. A column rather than only a log line, + because the log is pruned and the run is not. + +A firing older than the definition's grace window starts nothing (the missed sweep would take the run +on its next tick) and is logged. **Core's own `event.*` triggers cannot start a run**, refused at save +and skipped on the emit path, so a run starting can never start another; a run whose own steps cause +its start trigger is held by one-at-a-time and the cooldown. **Everything else is a run like any +other**: caps and budgets, concurrency key, announcements, cleanup. The counts are since the last +trigger-started run, which zeroes them, and the editor shows them: *"Last started run #12; since then +2 firings ignored while a run was going, 1 firing ignored in the cooldown."* + +**Not in it, deliberately:** a step cannot read the firing's variables (Bradley's grid is on the run, +not in `perform()`'s envelope). That is a module-contract change, a MODULE_API minor, and nothing has +asked for it yet. + ### Enablement and caps, in front of every dispatch Phase 6. The runner gained one thing: before it asks a module to do anything, it asks @@ -1915,7 +1975,7 @@ no URL moved. | Route | Gate | | | --- | --- | --- | | `GET /admin/events` | staff | definitions, state, next occurrence, health | -| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads. With **`draftAhead`** (MODULE_API 1.13.0): whether the working spec differs from the published version, so the start dialog can ask which to run. `PUT` and `publish` answer with it too | +| `GET /admin/events/:id` | staff | one definition, working spec included — what the editor reads. With **`draftAhead`** (MODULE_API 1.13.0): whether the working spec differs from the published version, so the start dialog can ask which to run. `PUT` and `publish` answer with it too. With **`triggerStarts`** (2026-10, [§E](#starting-a-run-from-a-trigger)): the last run a trigger started and the firings ignored since, by why — null until a trigger first tries | | `POST /admin/events` | admin, editor | create a draft | | `PUT /admin/events/:id` | admin, editor | edit the draft spec | | `POST /admin/events/:id/publish` | admin | snapshot a version and go `ready` | @@ -1925,7 +1985,7 @@ no URL moved. | `POST /admin/events/price` | admin, editor | **the live cap meter** (Phase 13) — price the spec IN THE BODY against the per-run caps. Dispatches nothing and records nothing, which is what separates it from the dry run and what makes it safe to call on a debounce while an author types; the spec is in the body because the plan being priced is unsaved between keystrokes. It therefore knows nothing a module knows — whether the landmark exists, whether the shard is up — and the screen says so under the numbers. A step it cannot price is reported in `unpriced` rather than counted as free; an **undeclared dimension is still counted**, because the action really will try to spend it and it is the enforcement rather than the amount that is missing. Over the caps is a `200`, for `verify`'s reason | | `POST /admin/events/:id/runs` | admin | start now; optional `rehearsal: true`, optional `scope` — and optional `params`, which **Phase 13 was the first thing to send**: the concurrency key is a `{placeholder}` template rendered from the run's own params, so a UI posting an empty body rendered the same key for every manual run and had the second refused as an overlap with the first | | `GET /admin/events/runs` | staff | run history across definitions | -| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup | +| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup — and the run's `origin` when a game firing started it ([§E](#starting-a-run-from-a-trigger)) | | `GET /admin/events/runs/:runId/log` | staff | the diagnostic log | | `POST /admin/events/runs/:runId/pause\|resume\|cancel` | admin, moderator | live control of a run in flight (Phase 3); `cancel` takes `{ reason }` and, since Phase 8, `{ cleanup }` — **defaulting to true**, so §L's *"cancelling WITHOUT cleanup is a separate, logged, admin-only action"* is the flag that has to be asked for. `cleanup: false` is refused to a moderator in the MODEL rather than in middleware, because which of the two roles you have to be depends on what is in the body. Either way the request answers at once: the teardown is the runner's cleanup leg, not this call | | `POST /admin/events/runs/:runId/advance` | admin, moderator | force the current phase past its advance condition (Phase 5). Legal only while the phase is genuinely waiting on its gate; `409` naming what it is waiting on otherwise. Satisfies the gate and stops — the next tick performs the boundary |