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

EVENTS.md gains the eleventh table, a new SS-E subsection on advance conditions
and the two writers a gate has, the diagnosis panel in SS-I, the "a condition
never fires" row in SS-L, and the observability rows for the gate table and its
three new log kinds. The `advance` route leaves the not-built list; the catalog
route now serves triggers.

The SS-D count said "nine" over a list of ten from the revision that added
`event_series`; with the gate table it is eleven, and it now says so.

EVENTS_PLAN.md marks Phase 5 complete in the shape Phases 0-4 use: the four
org-lead decisions, the three things the build settled, the MariaDB
left-to-right SET evaluation defect that only a real database found, the live
walk, and the three defects that walk turned up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
This commit is contained in:
2026-09-02 22:11:30 -05:00
parent 0f8293b538
commit 957d662a2f
2 changed files with 236 additions and 29 deletions

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.