docs(events): Phase 5 as built — conditions, phase advancement and the panel #213

Merged
whitlocktech merged 1 commits from docs/events-phase-5 into edge 2026-09-03 03:26:13 +00:00
2 changed files with 236 additions and 29 deletions
Showing only changes of commit 957d662a2f - Show all commits

View File

@@ -381,6 +381,12 @@ A phase advances on `{ after: '30m' }` or `{ on: '<triggerId>', where: <conditio
trigger catalog a module already ships *is* the catalog of things that can advance a phase, so a
module gains phase conditions by adding one entry to an array it already declares.
> **Built in Phase 5, and `engagement/conditions.js` is reused unchanged** — its grammar, its type
> checking against the declaration, its depth and list bounds, and its operator labels. What Phase 5
> added is where the words are *read*: the same labels that fill the rule editor's dropdowns render
> the diagnosis panel, so `gte` says *"is at least"* in both places because there is only one place
> it is written down.
**A cap is checked before dispatch, and a breach is a refusal, not a failure.**
A step that would exceed a cap does not run and does not retry. It is recorded `refused` with the
dimension and the numbers, and shown to the author — because "you asked for 40 monsters and this
@@ -395,8 +401,9 @@ completion, cancellation and abort alike.
## D — Data model
Nine core tables. MariaDB, raw parameterised SQL, no ORM, `CREATE TABLE IF NOT EXISTS`, and core
tables carry no module prefix.
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
`event_series` until Phase 5 counted them.)
| Table | Holds | Why a table |
| --- | --- | --- |
@@ -409,6 +416,7 @@ tables carry no module prefix.
| `event_run_budget` | `run_id`, `dimension`, `consumed`, `cap`. `UNIQUE (run_id, dimension)` | Consumption is incremented with a conditional update — `… SET consumed = consumed + ? WHERE run_id=? AND dimension=? AND consumed + ? <= cap` — so the cap holds under concurrent steps without a transaction. |
| `event_run_resources` | `run_id`, `step_id`, `owner_module`, `kind` and `ref` (both module-opaque), `payload` JSON, `lease_until` nullable, `status` `ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')`, `revert_attempts`, `last_error`, optional `member_key`. `UNIQUE (owner_module, kind, ref)` among non-reverted rows | **The cleanup ledger, and it holds both kinds of thing an event owns** — objects it created (`kind: 'creature'`, `ref` = a serial) and values it leased (`kind: 'override'`, `payload` = baseline + applied). `drifted` is the compare-and-set refusal; the unique index is what stops two events leasing one target. |
| `event_run_participants` | `run_id`, `user_id` nullable `SET NULL`, `member_key` module-opaque, `score`, `rank`, `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. |
| `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. |
> **`spec` on `event_definitions` is Phase 1's one addition to this table's column list**, and it
@@ -462,6 +470,12 @@ resources.
**As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
**materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
**A phase advances on every one of its steps going terminal — and, if it authored one, on its GATE
being open as well** (Phase 5). The gate is an *additional* condition and never a replacement: a
phase whose steps are still running is not advanced by a boss that spawned early, and the force
control refuses a phase held by a step for the same reason. A phase with no gate behaves exactly as
every phase did before Phase 5, and carries no `advance` key at all.
**Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
(`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
@@ -552,6 +566,56 @@ is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occ
occurrence would not come back on the new version, it would vanish. A run that **has** started keeps
its pin for ever.
### Advance conditions, and the two writers a gate has
A phase's `advance` is one of exactly two shapes, both validated at save:
| Shape | Means | Closed by |
| --- | --- | --- |
| `{ after: '30m' }` | thirty minutes from the moment the phase was **entered**, whatever its steps took | the runner's tick, `satisfied_by: 'elapsed'` |
| `{ on: '<triggerId>', where: <conditions>, count: n }` | `n` firings of that trigger that satisfy the predicate, counted from phase entry | the **emit path**, `satisfied_by: 'condition'` |
`after` is one integer and one unit from `s m h d`, normalised to its largest whole unit — `'120m'`
is stored as `'2h'` — for the reason `days` is normalised into week order: the spec is diffed between
versions, and two spellings of one delay differing as JSON is a version history reporting edits
nobody made. `count` defaults to 1; `where` is optional and `null` means any firing.
**The two shapes are closed by two different writers, and that division is the design.** A gate
waiting on three boss spawns is counting things that happen *between* two ticks; fifteen seconds
after the third spawn there is nothing left for a poller to observe, and a tally held in a process's
memory is one a restart silently returns to zero — with the phase then waiting for three more of
something that already happened. So `ctx.events.emit` writes the count where it happens, beside
`engine.dispatch` and with the same posture (never awaited, never allowed to reject), and the tick
reads the row. The increment is **one statement with the threshold inside it**, the argument
`event_run_budget`'s conditional increment makes one phase early: two firings arriving together each
add one and exactly one of them crosses `needed`.
**The clock and the tally both start at phase ENTRY**, not when the steps finish. `after: '30m'` is
thirty minutes after the phase began however long its dispatches took, and a trigger that fires while
the announce step is still in flight counts. Both are what makes a gate predictable from the authored
spec alone.
**A gate that never opens is held, and made loud** (org lead, 2026-09-02). There is deliberately no
authored timeout and no disposition to choose: nothing advances a phase but its condition or a human.
What the engine does instead is *say so* — an `on` gate that has waited past `EVENT_PHASE_STALL_MS`
(one hour) takes the run's `health` to **`stalled`**, §E's third value and the first thing in this
system ever to write it, logged once. It has to be loud, because a held run also holds its
concurrency key, so every later occurrence of the same definition goes `missed` behind it. An
`after` gate is never stalled: a phase waiting out six hours it was authored to wait is working, and
health that said otherwise would train an operator to ignore it.
**Health is a high-water mark, and `setHealth` now enforces it.** Nothing has ever cleared
`degraded` — a run whose announcement landed on the second attempt did have trouble, and that stays
true for the rest of its life — so the write is escalation-only. Without that guard, a step retrying
after a stall would demote `stalled` back to `degraded`, and a run that waited ninety minutes on a
boss that never came would end its life claiming it merely wobbled.
**A gate on a trigger nothing registers is DORMANT**, exactly as a step naming an unregistered action
is: it saves, so uninstalling a module is not destructive to an author's work, and it refuses to
publish, because a version runs are pinned to must not wait on a trigger that can never fire. Its
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.
### Concurrency
| Contention | Protection | Not an in-process mutex, because |
@@ -945,14 +1009,24 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
cancel-with-cleanup, cancel-without-cleanup — each logged with the actor. *Editing* a running event
is not one of them (see [Versioning](#versioning-and-editing-a-live-event)).
> **Six of those exist as of Phase 3, and two do not — for reasons, not for scope.** Pause, resume,
> cancel, and a step's confirm, skip and retry are built and gated to `admin` + `moderator`.
> **`advance` — force a phase forward — is not**, because it has no honest meaning yet: a phase today
> advances when its steps go terminal, and the per-step skip already does that one step at a time.
> Phase 5 gives a phase an advance *condition*, and that is the first moment "force it anyway" names
> something an operator could predict. **Cancel takes `{ reason }` and not `{ cleanup }`**, because
> the resource ledger a cleanup would work over arrives in Phase 8; a flag that changes nothing is
> the "control that answers 200 and does nothing" this plan has refused twice already.
> **Seven of those exist as of Phase 5, and one does not — for a reason, not for scope.** Pause,
> resume, cancel, and a step's confirm, skip and retry arrived in Phase 3, gated to `admin` +
> `moderator`. **`advance` joined them in Phase 5**, which is when it started meaning something: a
> phase used to advance when its steps went terminal and on nothing else, so "force it anyway" named
> no state an operator could be in, and the per-step skip already did that one step at a time. A
> phase with a gate can wait on a boss that will never spawn, and then it names exactly one.
>
> **Its three refusals are the design.** A run that is not `running` is waiting on nothing. A phase
> with no advance condition already advances on its steps, so forcing it would duplicate the runner
> rather than override it. And a phase whose steps have not all gone terminal is held by a *step*,
> not by its gate — the step-level skip is the honest control for that, one step at a time, and a
> force that swept past pending steps would be a cancel of half a phase under a button labelled
> advance. It satisfies the gate and stops; the next tick performs the phase boundary, exactly as it
> does after `resume`, so there is one implementation of what a phase boundary is rather than two.
>
> **Cancel takes `{ reason }` and not `{ cleanup }`**, because the resource ledger a cleanup would
> work over arrives in Phase 8; a flag that changes nothing is the "control that answers 200 and does
> nothing" this plan has refused twice already.
> **Retry is one control, not two.** A step may be retried only while its run is `paused`, and a
> paused run is paused *at* that step — so re-queueing without resuming would leave the run in
@@ -973,6 +1047,21 @@ dimensions' labels. `check:modules` already fails core's build on a UO identifie
> the row immortal. `EVENT_STEP_MAX_ATTEMPTS` bounds what the runner does **unattended**, and a named
> person deciding once is the thing it is unattended from.
**The diagnosis panel** (Phase 5) sits above the step list for the same reason the parked cue does:
a phase waiting on a condition is `running` and looks completely healthy, and the one screen an
operator opens to find out why nothing is happening must say so before they have to read a log. It
carries the clause in the condition builder's own words, the tally, how long the phase has waited,
and the **last related firing whether or not it counted** — because *"the boss did spawn, in
Britain"* and *"no boss has spawned"* are different answers that look identical without it.
**The sentence is rendered on the server**, and this is the one place this feature does not hand the
client a vocabulary to render. `gates[].where` arrives as text. The labels are defined in
`engagement/conditions.js`; a renderer in the browser would be a second implementation of a grammar
the server owns, and the first clause the two spelled differently would meet its operator at two in
the morning. Only the variables the condition **names** are recorded on the gate row and shown — not
the payload — because that row is read back onto an admin screen and a copy of a whole game event's
data would be a second copy of exactly the content `engagement_sends` is careful not to keep.
**Public surface.** An upcoming-events calendar with series and arcs, a live-status page, and
published results. It must tolerate a run with `health: 'degraded'` without saying so — "the shard is
having trouble" is operator information, and the existing shard pages already model exactly this by
@@ -1081,6 +1170,7 @@ controller stamps it from the session.
| **An action succeeds but the ack is lost** | The step retries with the same idempotency key; the plugin recognises the repeat and re-answers the original result. *This is the only mechanism that makes it safe, and it does not exist today.* |
| **Core dies while a lease is held** | The plugin restores baseline on the lease deadline **without being asked**. This is the fail-safe that makes unattended scheduled world changes defensible: the worst case is a world that returns to baseline early rather than one stuck changed indefinitely. |
| **A GM changes a leased property in-client** | Restore is compare-and-set: current value ≠ what the event applied, so nothing is written. The resource becomes `drifted` and is surfaced beside the unreverted ones. |
| **A phase's advance condition never fires** | The phase is **held**, indefinitely, and nothing advances it but a human (org lead, 2026-09-02). There is no authored timeout, because "what should happen when the world did not cooperate" is a decision an operator makes live rather than one an author guesses at months earlier. What the engine owes is visibility: after `EVENT_PHASE_STALL_MS` the run's `health` becomes `stalled` and it says so on the run list, logged once. This matters more than it looks — a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. |
| **A step would exceed its cap** | `refused`, with the dimension and the numbers, surfaced to the author. Not a retry and not a failure — it is an authoring error. |
| **An action fails** | Per-step `on_failure`, defaulted from the risk class: `retry(n) → skip` for `notify`, `retry(n) → pause` for `change`, `retry(n) → abort_run` for `irreversible`. `pause` stops the run advancing and waits for a human — the right default when the world is half-changed. `n` is `EVENT_STEP_MAX_ATTEMPTS`, 3 by default. **All three dispositions write the STEP `failed`**: `on_failure` says what happens to the run, and a step attempted three times that never worked is `failed` under every one of them. `skipped` is reserved for a step a human skipped from the run console — a status meaning both "nobody ran this" and "this failed and we moved on" would make the console's summary line unreadable. |
| **A run is cancelled** | Pending steps `cancelled`; a running one is left to finish or time out (nothing can recall a sent command); cleanup steps are generated from the ledger and run. Cancelling *without* cleanup is a separate, logged, admin-only action. **A PARKED step is cancelled with the pending ones** (Phase 3): a cue is not a command already sent, it is an instruction nobody is holding, and leaving it `running` would have the console claim a cancelled event is still waiting for someone. The live lease is what tells the two apart, and it is in the `WHERE` clause. |
@@ -1140,10 +1230,10 @@ no URL moved.
| `GET /admin/events/runs/:runId` | staff | status, phase, steps, caps, resources, cleanup |
| `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 gains `cleanup` with the ledger in Phase 8 |
| `POST /admin/events/runs/:runId/advance` | admin, moderator | force a phase forward — **not built**; it has no honest meaning until Phase 5 gives a phase an advance condition |
| `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 |
| `POST /admin/events/runs/:runId/steps/:stepId/skip\|retry\|confirm` | admin, moderator | Phase 3. `confirm` resolves a GM cue step; `retry` re-queues the step a paused run is stopped at and resumes it |
| `POST /admin/events/runs/:runId/cleanup` | admin | re-run cleanup over unreverted resources |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions |
| `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions — and, since Phase 5, the **trigger** catalog and condition operators the advance form renders. Served here rather than borrowed from `/admin/engagement/triggers` because that route is `adminOnly` while a definition is authored by `admin` **and** `editor` |
| `GET /admin/events/catalog/options/:sourceId` | staff | a module's option list for a param |
| `GET /admin/events/series` | staff | the arcs a definition may belong to |
| `POST /admin/events/series` | admin, editor | create an arc (Phase 4) |
@@ -1191,9 +1281,14 @@ runner ticks every fifteen seconds, so a console rendered thirty seconds ago des
since moved, and a control that checked in JavaScript and then wrote would race the tick it exists to
interrupt. A refusal is a `409` naming the status the run is actually in.
Still absent, and still for reasons rather than for scope: `advance` (no advance condition until Phase
5), `cleanup` and cancel's `cleanup` flag (no resource ledger until Phase 8), `verify` and `GET/PUT
/admin/events/actions` (no caps to price against and no switchboard to serve until Phase 6).
Still absent, and still for reasons rather than for scope: `cleanup` and cancel's `cleanup` flag (no
resource ledger until Phase 8), `verify` and `GET/PUT /admin/events/actions` (no caps to price
against and no switchboard to serve until Phase 6). `advance` left this list in Phase 5.
A third derived field joined `waitingSteps` and `parked` there: a run's detail carries **`gates`**,
one entry per phase that authored an advance condition, already rendered. Every gate the run has
opened is returned rather than only the current phase's — *"how long did phase 2 wait, and what
released it"* is the same question as the live one, asked the morning after.
Two response fields arrived with the console and are worth naming because both are **derived, not
columns**. A run carries `waitingSteps`, the number of its steps parked on a human, so the run LIST
@@ -1213,7 +1308,8 @@ which is what keeps the browser from being able to name a transport.
An administrator must be able to answer *"why didn't phase 3 start?"* without reading server logs —
and there is a piece of reuse here better than it first looks. The condition grammar already carries
**human labels**, written for the rule editor's dropdowns: `eq` is *"is"*, `gte` is *"is at least"*,
`present` is *"is present"*. The same labels render the diagnosis.
`present` is *"is present"*. The same labels render the diagnosis. **Built in Phase 5**, on the
server, from those labels — the caps line arrives with the budgets in Phase 6.
```
Phase 3 — "The Boss" has not started.
@@ -1230,6 +1326,8 @@ Phase 3 — "The Boss" has not started.
| Every step: action, params, attempt, duration, outcome, module answer | `event_run_steps` + `event_run_log` |
| Cap draws and refusals, per dimension | `event_run_budget` + log |
| Trigger evaluations that did and did not satisfy a condition | `event_run_log`, kind `condition.evaluated` |
| What a phase is waiting for, its tally and its last related firing | `event_run_phase_gates`, served already-rendered as a run's `gates` |
| A phase opening a gate, and a gate opening — on a firing, a deadline or a human | `event_run_log`, kinds `phase.gate` and `phase.advanced` |
| Module acknowledgement, or its absence with the budget exceeded | `event_run_steps.last_error` |
| Resources created, confirmed, leased, reverted, orphaned, drifted | `event_run_resources` |
@@ -1260,7 +1358,7 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; **
| **P2** ✓ | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
| **P3** ✓ | The minimal admin surface — **first demo** | `website` |
| **P4** ✓ | Schedule, recurrence, timezones, series and the calendar | `website` `docs` |
| **P5** | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
| **P5** | Conditions, phase advancement, and the "why didn't phase 3 start?" panel | `website` `docs` |
| **P6** | Enablement, per-run caps, and the single `mayInvoke` decision point | `website` `docs` |
| **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |
| **P8** | The resource ledger, leases and generated cleanup | `website` `docs` |

View File

@@ -453,16 +453,120 @@ vendor.*
### Phase 5 — Conditions and phase advancement (`website` + `docs`)
Phase advance on `{ after: '30m' }` and on `{ on: '<triggerId>', where: <conditions>, count: n }`,
reusing `engagement/conditions.js` **unchanged** — its grammar, its type checking against the
declaration, its depth and list bounds, and its operator labels.
The runner subscribes to the trigger stream it already has; `event_run_log` gains
`condition.evaluated` rows for both outcomes.
**The diagnosis panel lands here**, and it is the phase's real deliverable: the operator question
*"why didn't phase 3 start?"* answered in the condition builder's own words — `gte` renders as *"is at
least"*, `present` as *"is present"* — with the tally, the elapsed time and the last related event.
> **Complete.** `edge` in `website` and `docs`. Phase advance on `{ after: '30m' }` and on
> `{ on: '<triggerId>', where: <conditions>, count: n }`, reusing `engagement/conditions.js`
> **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds,
> and its operator labels. `event_run_log` gained `phase.gate`, `condition.evaluated` (written for
> **both** outcomes) and `phase.advanced`. The diagnosis panel is the phase's real deliverable, and
> `POST /admin/events/runs/:runId/advance` — absent since Phase 3 for want of a meaning — arrived
> beside it. One new table, `event_run_phase_gates`.
>
> **The decisions the org lead settled (2026-09-02), all as recommended:**
>
> - **A new table, not a query over the log.** The tally, the entry time and the last related firing
> are a row with an atomic conditional increment, the protection §E gives caps. Deriving them from
> `event_run_log` would have been a JSON predicate no index supports, and it would have made the
> retention sweep load-bearing for whether a phase advances.
> - **A gate that never opens is HELD, and the run goes `stalled`.** No automatic advance, ever, and
> no authored timeout: "what should happen when the world did not cooperate" is a decision an
> operator makes live, not one an author guesses at months earlier. What the engine owes is
> visibility — `EVENT_PHASE_STALL_MS` (1h) takes `health` to §E's third value, the first thing in
> this system ever to write it, logged once. It has to be loud: **a held run keeps its concurrency
> key**, so every later occurrence of that definition goes `missed` behind it.
> - **Force-advance ships here, not in Phase 6.** A gate without an override is a panel that explains
> a problem nobody can act on, and §K already had the gate written (`admin` + `moderator`).
> - **The clock and the tally start at phase ENTRY**, not when the steps finish. `after: '30m'` means
> thirty minutes from the moment the phase began whatever its dispatches took, and a firing during
> the announce counts. Both make a gate predictable from the authored spec alone.
>
> **Three things the build settled:**
>
> - **A gate is an ADDITIONAL condition, never a replacement.** A phase whose steps are still running
> is not advanced by a boss that spawned early, and force-advance refuses a phase held by a step —
> that phase is held by the *step*, and skip is its control, one step at a time. A force that swept
> past pending steps would be a cancel of half a phase under a button labelled advance.
> - **The emit path writes, the tick reads.** A gate waiting on three spawns counts things that
> happen *between* two ticks; fifteen seconds later there is nothing left for a poller to see, and
> a tally in a process's memory is one a restart silently zeroes. So `observe()` sits beside
> `engine.dispatch` in `ctx.events.emit` — a second subscriber rather than a leg of dispatch,
> because a rules lookup that throws must not lose the count and a gate write that throws must not
> lose the mail.
> - **The panel's sentence is rendered on the SERVER.** Everywhere else this feature serves the
> client a vocabulary; here it serves text. The labels live in `engagement/conditions.js`, and a
> renderer in the browser would be a second implementation of a grammar the server owns. Only the
> variables the condition **names** are stored on the gate row — the row is read onto an admin
> screen, and a copy of a whole game event's payload would be a second copy of what
> `engagement_sends` is careful not to keep.
>
> **The defect only a real database found, and it was the phase's own statement.** The conditional
> increment was written `SET tally = tally + 1, … satisfied_at = CASE WHEN tally + 1 >= needed …`,
> which is wrong on MariaDB: **an UPDATE's SET assignments are evaluated left to right, each seeing
> the values already assigned**, so the CASE read the incremented tally and a gate needing two
> firings closed on the first. Every stub agreed with the intent rather than with the server, exactly
> as engagement's cooldown claim did over `foundRows: true`. The increment now comes **last** and the
> order of that SET list is load-bearing; `eventRunnerSql.test.js` is what catches a reorder.
>
> **Two things that had to change underneath.** `setHealth` is now **escalation-only** — health has
> always been a high-water mark here, and without a rank a retry after a stall would demote
> `stalled` back to `degraded`. And the **catalog route serves triggers**: `/admin/engagement/triggers`
> is `adminOnly` while a definition is authored by `admin` *and* `editor`, so pointing the editor at
> it would have left an editor typing a trigger id from memory into a field the save path refuses.
>
> **A leg a stubbing file did not know about, for the third time.** `runs.detail()` gained the gate
> read, and `eventsAdmin.test.js` does not stub `eventPhaseGates.db` — so the run-console test hung
> ten seconds against the dead-port pool and failed with `ECONNREFUSED`, saying nothing whatever
> about the route it was testing. Phase 4's expansion leg did the same to `eventRunner.test.js`,
> where it only made the file slow. **When the runner or a model gains a leg, every file that stubs
> the layer under it needs the stub** — and the symptom is a ten-second test, whether it then fails
> or merely passes.
>
> **Verified:** `npm test` — **1810 tests, 1746 pass, 63 skipped, 1 fail**, that one still the
> pre-existing `engagementManifest.test.js` CRLF failure (confirmed by stashing this branch's changes
> and watching it fail unchanged; `edge` before: 1768/1711/56/1). **+42 is exactly the tests added**,
> and the +7 skipped are the new SQL cases skipping without a database. `eventGates.test.js` (14) covers the
> renderer against the grammar's own labels and the observer's near-miss branch; 10 in
> `eventRunner.test.js`, 5 in `eventRunControls.test.js`, 6 in `eventSpec.test.js`, and **7 in
> `eventRunnerSql.test.js` against a real MariaDB** (39/39 with a database, skipped without) — one of
> which is the left-to-right defect above. Client: **361 pass**, 7 new. One route added, none moved;
> the client builds.
>
> **The live walk, on the local review stack.** A three-phase `Yew Champion Muster` — an `on` gate
> needing two `uo.champ.boss_up` firings `where location contains "Yew"`, then an `after: '10m'`
> gate, then an ungated wind-down — authored, published and started as `navadmin`, with firings sent
> through the real `ctx.events.emit` seam:
>
> - **The save-time refusals named the variable**, live: `"regoin" is not a variable of
> "uo.champ.boss_up"`, `"gt" cannot be applied to a string`, and `1h30m` refused with the grammar
> spelled out. That is the phase's Trap, held on a running server.
> - **A near miss was recorded and did not count.** A boss up in *Britain* left the tally at 0 of 2
> and put `did not count (location: "Britain (10, 20, 0)")` on the panel — and **only `location`**,
> the one variable the condition names. `spawnName`, `bossName` and `spawnSerial` were in the
> payload and never touched the row.
> - **Two matching firings advanced the phase**, and the next phase opened its own `after` gate with
> a `dueAt` ten minutes out. The log reads: three `condition.evaluated` lines (0 of 2, 1 of 2, 2 of
> 2), `phase.advanced`, `phase.completed`, `phase.gate`, `phase.entered`.
> - **A second run went `stalled`** with `EVENT_PHASE_STALL_MS=45000` — `degraded` first from a
> failing announce, then escalated to `stalled` and logged **once**, which is the escalation-only
> guard working live.
> - **The panel said exactly what §Observability asked for**: *"Phase muster has not started —
> STALLED / waiting on `uo.champ.boss_up` where location contains "Yew" / seen so far 0 of 2 /
> since 9:56:58 PM (6 min) / last related event …"*.
> - **§N2's split held:** `navmod` pressed *Advance phase* and got 200; the refusals answered 409
> naming what was actually happening — *waiting on step 0 (core.announce), not on its advance
> condition* — and a second force said *already past its advance condition*.
>
> **The walk found three defects, all fixed here:**
>
> 1. **`validate` refused its own output.** The normalised gate carries `dormant`, and the input
> check did not allow it — so a gated definition *saved* and then *failed to publish* over a field
> the validator itself wrote. The rule was already on the page for a step's `actionVersion` and
> `dormant`; the gate just had to follow it. `validate(validate(x)) === validate(x)` is now a test.
> 2. **A forced advance was logged twice** — once by the control with the actor and the reason, then
> again by the tick that acted on the satisfied gate, the less informative one last. `phase.advanced`
> is now written by whoever made the decision, and the tick skips `forced`.
> 3. **A satisfied gate's clock kept running.** `elapsedSeconds` measured to read time, so the panel
> said 139s beside a logged `waitedSeconds` of 121. It now stops at `satisfied_at`: live it answers
> "how long has this been waiting", afterwards "how long did it wait".
**Ships:** multi-phase events that advance on what happens in the game rather than only on a clock.
**Verify:** `npm test`; a rig run where a phase legitimately does not advance, confirming the panel
@@ -470,7 +574,11 @@ explains why without a server log.
**Trap:** a condition is validated at **save** against the trigger's declaration, with the offending
variable named — not at evaluation. A predicate that silently reads `undefined` is a phase that
silently never advances, and the day you find out is the night of the event.
silently never advances, and the day you find out is the night of the event. *Held: `spec.js` calls
`conditions.validate(declaration, where)` at save and re-roots the grammar's own errors at the phase,
so an author fixing five clauses at once can tell which phase each belongs to. A gate naming a
trigger nothing registers is **dormant** on the rule a step's unregistered action already follows —
it saves, and it will not publish.*
---
@@ -495,7 +603,8 @@ delegation model a later option rather than a redesign.
The role split from `EVENTS.md` §K is applied to the routes here — completing what P3 started, and
including §N2's departure from the module-uo shape: **publishing and starting are `admin` only,
while cancelling and aborting a run in flight are `admin` + `moderator`**. Start and stop are
deliberately not the same gate.
deliberately not the same gate. (`advance` took that same gate in Phase 5, which is when it first
named a state an operator could be in.)
**Ships:** an admin switchboard, and a system that cannot be made to do an unbounded amount of
anything.