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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user