From 957d662a2fad1fc4255fe784b103a0e0a6b98fb0 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 22:11:30 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL --- website/EVENTS.md | 132 ++++++++++++++++++++++++++++++++++------ website/EVENTS_PLAN.md | 133 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 236 insertions(+), 29 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index 1c1f3d4..331d111 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -381,6 +381,12 @@ A phase advances on `{ after: '30m' }` or `{ on: '', where: **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: '', where: , 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` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 9fc5aca..0dde84a 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -453,16 +453,120 @@ vendor.* ### Phase 5 — Conditions and phase advancement (`website` + `docs`) -Phase advance on `{ after: '30m' }` and on `{ on: '', where: , 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: '', where: , 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.