feat(events): conditions, phase advancement and the diagnosis panel (Phase 5) #187

Merged
whitlocktech merged 1 commits from feature/events-phase-5 into edge 2026-09-03 03:26:41 +00:00
Member

Event System Phase 5 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#213.

A phase can now wait for the world. It used to advance on one fact — every step terminal. It can now also carry an advance CONDITION, and when that condition does not happen, the operator's question "why didn't phase 3 start?" is answered on the screen, in the condition builder's own words. Walked end to end on the local review stack, which found three defects (details at the bottom).

One new table, event_run_phase_gates. engagement/conditions.js is reused unchanged — its grammar, its type checking against the declaration, its depth and list bounds, and its operator labels.

  1. 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, one phase early. 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.
  2. 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 instead 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, because a held run keeps its concurrency key, so every later occurrence of that definition goes missed behind it.
  3. 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.
  4. 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 are what 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 after the third there is nothing left for a poller to observe, and a tally in a process's memory is one a restart silently returns to zero. 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 hands the client a vocabulary; here it hands it text. The labels are defined in engagement/conditions.js, and a renderer in the browser would be a second implementation of a grammar the server owns — the first clause the two spelled differently would meet its operator at two in the morning. Only the variables the condition names reach the gate row: it is read back onto an admin screen, and a copy of a whole game event's payload would be a second copy of exactly what engagement_sends is careful not to keep.

The defect only a real database found, and it was this phase's own statement

The conditional increment was written the obvious way:

SET tally = tally + 1,  satisfied_at = CASE WHEN tally + 1 >= needed THEN ? END

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, which is why eventRunnerSql.test.js exists at all. The increment now comes last, the order of that SET list is load-bearing, and a test says so.

Two things that had to change underneath

  • setHealth is escalation-only. Health has always been a high-water mark here — nothing has ever cleared degraded — and Phase 5 gave the column a second writer. Without a rank, 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.
  • The catalog route serves triggers. §C's claim is that the trigger catalog a module already ships is the catalog of things that can advance a phase — the same registry, read twice. /admin/engagement/triggers is adminOnly while an event 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 then refuses. Each trigger is reduced to id, label and declared variables; a trigger's audience and ceiling are about who gets mailed, which is not this screen's question.

Verify

  • npm test1810 tests, 1746 pass, 63 skipped, 1 fail. The one failure is engagementManifest.test.js, pre-existing and environmental (CRLF under core.autocrlf=true) — confirmed by stashing this branch's changes and watching it fail unchanged. edge before this branch is 1768/1711/56/1, and +42 is exactly the tests added; the +7 skipped are the new SQL cases skipping without a database.
  • 42 new tests. eventGates.test.js (14) checks the renderer against the grammar's own labels rather than against strings the test wrote down, and covers the observer's near-miss branch and its never-rejects contract. 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 them the left-to-right defect above. Client: 361 pass, 7 new.
  • npm run routes:manifest and npm run swaggerone 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 this phase's stated 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. Nobody pressed anything.
  • A second run went stalled with EVENT_PHASE_STALL_MS=45000degraded 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 uo.champ.boss_up at 10:03:05 PM — did not count".
  • §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.

Three defects the walk found, 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 had written. 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".

And one in the harness, 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.


  • AI-assisted: authored with Claude Code (Claude Opus).
Event System **Phase 5** (`EVENTS_PLAN.md`). Docs half: **RunicGateway/docs#213**. > **A phase can now wait for the world.** It used to advance on one fact — every step terminal. It can now also carry an advance CONDITION, and when that condition does not happen, the operator's question *"why didn't phase 3 start?"* is answered on the screen, in the condition builder's own words. Walked end to end on the local review stack, which found three defects (details at the bottom). **One new table**, `event_run_phase_gates`. `engagement/conditions.js` is reused **unchanged** — its grammar, its type checking against the declaration, its depth and list bounds, and its operator labels. ## Four decisions, settled 2026-09-02, all as recommended 1. **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, one phase early. 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. 2. **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 instead 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, because a held run keeps its concurrency key, so every later occurrence of that definition goes `missed` behind it. 3. **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. 4. **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 are what 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 after the third there is nothing left for a poller to observe, and a tally in a process's memory is one a restart silently returns to zero. 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 hands the client a vocabulary; here it hands it text. The labels are defined in `engagement/conditions.js`, and a renderer in the browser would be a second implementation of a grammar the server owns — the first clause the two spelled differently would meet its operator at two in the morning. Only the variables the condition **names** reach the gate row: it is read back onto an admin screen, and a copy of a whole game event's payload would be a second copy of exactly what `engagement_sends` is careful not to keep. ## The defect only a real database found, and it was this phase's own statement The conditional increment was written the obvious way: ```sql SET tally = tally + 1, … satisfied_at = CASE WHEN tally + 1 >= needed THEN ? END ``` 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`, which is why `eventRunnerSql.test.js` exists at all. The increment now comes **last**, the order of that SET list is load-bearing, and a test says so. ## Two things that had to change underneath - **`setHealth` is escalation-only.** Health has always been a high-water mark here — nothing has ever cleared `degraded` — and Phase 5 gave the column a second writer. Without a rank, 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. - **The catalog route serves triggers.** §C's claim is that the trigger catalog a module already ships *is* the catalog of things that can advance a phase — the same registry, read twice. `/admin/engagement/triggers` is `adminOnly` while an event 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 then refuses. Each trigger is reduced to id, label and declared variables; a trigger's audience and ceiling are about who gets *mailed*, which is not this screen's question. ## Verify - `npm test` — **1810 tests, 1746 pass, 63 skipped, 1 fail.** The one failure is `engagementManifest.test.js`, pre-existing and environmental (CRLF under `core.autocrlf=true`) — **confirmed by stashing this branch's changes and watching it fail unchanged**. `edge` before this branch is 1768/1711/56/1, and **+42 is exactly the tests added**; the +7 skipped are the new SQL cases skipping without a database. - **42 new tests.** `eventGates.test.js` (14) checks the renderer **against the grammar's own labels** rather than against strings the test wrote down, and covers the observer's near-miss branch and its never-rejects contract. 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 them the left-to-right defect above. Client: **361 pass**, 7 new. - `npm run routes:manifest` and `npm run swagger` — **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 this phase's stated 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. Nobody pressed anything. - **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 `uo.champ.boss_up` at 10:03:05 PM — did not count"*. - **§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*. **Three defects the walk found, 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 had written. 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"*. **And one in the harness, 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. --- - [x] AI-assisted: authored with Claude Code (Claude Opus).
wtclaude added 1 commit 2026-09-03 03:12:32 +00:00
feat(events): conditions, phase advancement and the diagnosis panel (Phase 5)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 5m28s
PR Checks / client-build (pull_request) Successful in 8m47s
9bc0bf5a3d
A phase used to advance on one fact - every step terminal. It can now also carry
an advance CONDITION: `{ after: '30m' }` or `{ on: '<triggerId>', where:
<conditions>, count: n }`, reusing `engagement/conditions.js` unchanged. The
phase's real deliverable is the diagnosis panel: "why didn't phase 3 start?"
answered in the condition builder's own words, with the tally, the elapsed time
and the last related firing whether or not it counted.

`POST /admin/events/runs/:runId/advance` arrives beside it. It has been absent
since Phase 3 for want of a meaning; a phase with a gate can wait on a boss that
will never spawn, and that is the one state "force it anyway" names.

One new table, `event_run_phase_gates`. The emit path writes the tally at the
moment a firing happens - a gate waiting on three spawns counts things that
occur between two ticks, and a tally held in a process's memory is one a restart
silently zeroes - and the runner's tick reads it.

A gate that never opens is HELD, with no automatic advance and no authored
timeout (org lead, 2026-09-02). What the engine owes instead is visibility:
`EVENT_PHASE_STALL_MS` takes the run's health to `stalled`, and `setHealth` is
now escalation-only so a later retry cannot demote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
whitlocktech merged commit 4ac917c3a3 into edge 2026-09-03 03:26:41 +00:00
whitlocktech deleted branch feature/events-phase-5 2026-09-03 03:26:42 +00:00
Sign in to join this conversation.
No description provided.