From 9410181560c299eeded3b40edd83289f3e4f0547 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 2 Sep 2026 16:10:50 -0500 Subject: [PATCH] docs(events): Phase 4 as built - schedule, recurrence and the calendar EVENTS.md: the materialise leg as two halves and why the horizon is what makes the missed sweep meaningful; the recurrence shapes as built, incl. the two DST rules and why nth has no fifth; publishing as the schedule switch and the re-pin; the calendar's run-vs-projection distinction and the reader's-zone/event's-zone split; the four new routes; P1-P4 ticked. EVENTS_PLAN.md: Phase 4 marked complete with the five org-lead decisions, the three the build settled, the test-harness defect the phase introduced and fixed, and the verified numbers. Website: RunicGateway/website#PENDING Co-Authored-By: Claude --- website/EVENTS.md | 111 ++++++++++++++++++++++++++++++++++++----- website/EVENTS_PLAN.md | 61 +++++++++++++++++++--- 2 files changed, 153 insertions(+), 19 deletions(-) diff --git a/website/EVENTS.md b/website/EVENTS.md index f913b24..1c1f3d4 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -459,13 +459,27 @@ which cannot load module code. Same `setInterval` + `unref()` + `stop()` shape, evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording resources. -**As built in Phase 2, the tick has four legs and one of them is smaller than the above implies.** -Ordered: **reclaim** (release leases whose holder died), **materialise**, **advance**, **drain**, then -a **prune** on its own six-hourly clock. What "materialise" covers today is only the grace window — -the spec validator accepts `kind: 'manual'` alone until Phase 4, so there is no recurrence to expand -and the only occurrences that exist are the ones an admin created. The half that is already real is -the half that already matters: a run whose instant passed while the process was down becomes `missed` -rather than starting late and silently. Phase 4 adds the expansion above it. +**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. + +**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 +occurrence key — so the tick that already made one makes nothing, which is what lets it run every +fifteen seconds for ever. The second SWEEPS: a run whose instant passed while the process was down +becomes `missed` rather than starting late and silently. + +**The two halves need each other, and the horizon is why.** Expansion looks forward from +`now - grace_seconds` only, so an occurrence nobody ever materialised is never invented +retroactively — waking up after three days down must not manufacture three days of `missed` history +that no operator could have seen or cancelled. It does not have to: because rows exist a fortnight +ahead of their instant, an outage spanning an occurrence finds the row already there and the sweep +marks it honestly. **The horizon is what makes the missed sweep mean anything for a recurring event.** + +**Automatic expansion is at the empty scope** (org lead, 2026-09-02). A fan-out across named scopes +needs a registry of what a scope *is*, which no phase owns yet; inventing one before the module +contract would be a contract the modules were never asked about. An admin's own +`POST /admin/events/:id/runs` still takes any scope. Three numbers govern a step, and they live in the runner rather than in a column because no authoring surface would ever show them: `EVENT_STEP_MAX_ATTEMPTS` (3), `EVENT_STEP_RETRY_MS` (60 000, flat), and @@ -495,6 +509,49 @@ The only cron precedent is in the bot, in another process, with no parser in the tree — and a cron string is the one field an operator cannot proofread. Monthly-nth is not padding: the fishing contest on Drachenfels is exactly that shape. +**As built in Phase 4.** `time` is `HH:MM` and `days`/`weekday` are English weekday names rather than +numbers, for the same proofreading reason that rejected cron; `at` is a **local wall clock** +(`YYYY-MM-DDTHH:MM`) in the definition's own zone, never a UTC instant, because the schedule belongs +to the event and the instant is derived at materialisation. `days` is normalised into week order, so +two spellings of one schedule do not show as an edit nobody made in the version history. **`nth` is +`1..4` or `-1` for "last"** (org lead, 2026-09-02): every month has a first through fourth of every +weekday, so the closed set has no absent-occurrence case to define, and `-1` is the shape a +"last Friday" contest actually is — it is not a synonym for `4`. + +There is **no date library in the server's dependency tree** and Phase 4 did not add one. Node ships +the full tzdata behind `Intl.DateTimeFormat`, which is the same database a library would vendor a +copy of and is already what the zone-name check uses. `events/recurrence.js` inverts the +instant-to-wall-clock mapping by search; it is the one place an occurrence is computed, so the +runner's expansion and the calendar's forecast cannot disagree. + +**The two DST rules** (org lead, 2026-09-02), which exist because a weekly 02:30 event in +`Europe/Berlin` is a thing an operator will really author: + +- A **nonexistent** local time — the spring-forward gap — steps forward to the first wall clock that + does exist. 02:30 becomes 03:00, not 03:30: the event happens as close to the authored time as the + calendar allows. +- An **ambiguous** local time — the fall-back hour, which comes round twice — takes the **first**, + at the pre-transition offset. + +Neither rule ever drops an occurrence: a weekly event happens every week. Both are recorded on the +run as `detail.dstAdjusted`, so nobody has to rediscover daylight saving at 3am on the last Sunday in +October. + +**Publishing is the schedule switch, and archiving is how it is turned off.** `ready` is defined as +"a version has been published and the schedule is live", so a second enabled flag would be another +answer to a question `state` already answers, and the two would eventually disagree. The expansion +reads the **published version's** spec, never the definition's working copy: a half-typed recurrence +an author is midway through must not materialise anything. + +**Publishing also re-pins the occurrences that have not started** (org lead, 2026-09-02). Every run of +that definition still `scheduled` with a NULL `started_at` moves to the new version, and the count +comes back on the publish response. A version pin exists to make a run that **has run** reproducible; +a run that has not begun has nothing to reproduce yet. Without this an editor's fix would reach none +of the fortnight already on the calendar, and the only recourse — cancelling each stale occurrence — +is worse than the problem: a cancelled row still holds its slot in `uq_evrun_occurrence`, so the +occurrence would not come back on the new version, it would vanish. A run that **has** started keeps +its pin for ever. + ### Concurrency | Contention | Protection | Not an in-process mutex, because | @@ -829,12 +886,38 @@ already fails core's build on a UO identifier, so it is enforced in CI rather th | Screen | Pattern it reuses | What is new | | --- | --- | --- | -| **Calendar** — month and list view, filtered by category, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. | +| **Calendar** — month and list view, filtered by state, scope and series | New, but this is the deliverable that replaces a WordPress plugin | Series, recurrence, local timezone rendering. | | **Definition editor** — basics, storyline, venue, schedule, phases | Sectioned admin form; schedule as a closed shape | The phase timeline. | | **Step editor** — pick an action, fill its params | **The condition builder, exactly**: core serves a catalog, the module declared the schema, core renders a form it does not understand | Option sources; a live cap meter. | | **Actions** — which are enabled on this deployment, and their per-run caps | `SettingsAdmin.jsx` shape, one row per registered action | The cap editor. | | **Run console** — live status, steps, caps, failures, cleanup | Shaped like `EngagementSendLog.jsx` | The "why didn't phase 3 start?" panel. | +**A calendar entry is one of two things, and Phase 4 draws the difference rather than stating it.** +A **run** is a real `event_runs` row: it has a status, a pinned version and a console, and somebody can +cancel it. A **projection** is arithmetic beyond the materialisation horizon — no row, nothing +committed, nothing to open. The API says which each is (`kind`), and the UI draws a projection dashed +and dimmed, because an operator acting on a forecast as though it were a booking would have been +misled by the screen rather than by the server. **A projection is never emitted for an instant a run +already occupies**, which keeps the fortnight inside the horizon from being drawn twice — and which +also means a **cancelled** occurrence does not reappear as a forecast looking like it is still coming. +Filtering by run `status`, or by a named `scope`, suppresses projections entirely: a forecast has no +status, and automatic expansion happens at the empty scope. + +**The grid's date axis is the reader's timezone; each entry's time is the event's.** §E gives the zone +to the event because every listing this replaces is written in the shard's local zone — but "what is +happening this month" is a question about the month the person reading is living in. So the cell an +event lands in is the reader's date, and the time beside it always carries the event's own zone +(`20:00 Europe/Berlin`), which misreads as nothing. The server returns UTC instants and does not guess +the reader's zone; the client places them. + +**Series are managed on the calendar**, inline, because the calendar is what makes an arc visible in +the first place. Their writes are `admin, editor` rather than `admin`: naming an arc is authoring, and +[§N2](#n--decisions)'s narrow gate is about committing the deployment to a run. A series **delete** is +a real delete — the only one in this feature, where a definition is archived instead. A series pins +nothing and no run references one; `event_definitions.series_id` is `ON DELETE SET NULL`, so its +definitions survive without an arc and re-attaching one is a dropdown. The response says how many were +detached, because that is the entire consequence of the act. + **The phase editor should be a timeline, not a node graph.** A canvas is the obvious thing to reach for and it is wrong here for a reason that comes out of the code: **the condition grammar has no branching.** It is `and`/`or`/`not` over comparisons, bounded at depth 5, deliberately closed, and @@ -1063,6 +1146,10 @@ no URL moved. | `GET /admin/events/catalog` | staff | registered actions, param schemas, risk classes, budget dimensions | | `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) | +| `PUT /admin/events/series/:seriesId` | admin, editor | rename or reorder it; the slug is frozen | +| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many | +| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) | | `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps | | `GET /public/events` | — | the calendar: upcoming and live, by category, scope and series | | `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results | @@ -1169,10 +1256,10 @@ and needed no answer to [N1](#n--decisions). P11 and P12 were the gated pair; ** | Phase | | Repos | | --- | --- | --- | | **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` | -| **P1** | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` | -| **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` | +| **P1** ✓ | Schema, CRUD, and the action registry with core as its first registrant | `website` `docs` | +| **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` | | **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` | diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 6cdc1be..9fc5aca 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -233,7 +233,7 @@ the same rule. > the run enters it. The instant is now carried across the boundary. Found by writing the test, and > the test was re-run against the unfixed code to confirm it fails. > -> **What "materialise" means here.** The spec validator accepts `kind: 'manual'` alone until Phase 4, +> **What "materialise" meant in THIS phase.** The spec validator accepted `kind: 'manual'` alone, > so there is no recurrence to expand — this leg builds the half that is already real, the grace > window, and Phase 4 adds the expansion above it. > @@ -386,11 +386,56 @@ before building further. ### Phase 4 — Schedule, recurrence and the calendar (`website` + `docs`) -The closed recurrence shapes — `once`, `weekly`, `monthly` (nth weekday), `manual` — computed in the -definition's **IANA timezone** and stored as UTC in `scheduled_for`. `event_series` becomes usable: a -definition may belong to a series, and the series has an ordering. - -An admin calendar view (month + list), filtered by state, scope and series. +> **Complete.** `edge` in `website` and `docs`. The closed recurrence shapes — `once`, `weekly`, +> `monthly` (nth weekday), `manual` — computed in the definition's **IANA timezone** and stored as UTC +> in `scheduled_for`; `event_series` is usable and managed inline on the calendar; an admin calendar +> (month + list) filtered by state, scope and series. **An event now happens on its own.** No schema +> change: Phase 1 built every column this needed. +> +> **The decisions the org lead settled (2026-09-02), all as recommended:** +> +> - **A fourteen-day materialisation horizon, with projections beyond it.** Inside it an occurrence is +> a real row an operator can see, cancel and reschedule one at a time; beyond it the calendar +> forecasts from the same arithmetic, so a monthly event is still visible three weeks out. Drawn +> differently on purpose — acting on a forecast as though it were a booking would be the UI's fault. +> - **Automatic expansion is at the EMPTY scope.** A fan-out across named scopes needs a registry of +> what a scope *is*, which no phase owns yet; inventing one here would be a contract the modules were +> never asked about. The admin's own start route still takes any scope. +> - **DST: skip-forward, take-first.** A local time the spring gap swallows moves forward to the first +> one that exists (02:30 becomes 03:00, not 03:30); an hour that happens twice takes the first. Both +> are recorded as `detail.dstAdjusted`. **Neither rule ever drops an occurrence.** +> - **`nth` is 1..4 plus -1 for "last".** There is no fifth, so there is no absent-occurrence case to +> define — every month has a first through fourth of every weekday. `-1` is not a synonym for `4`. +> - **Publishing re-pins the occurrences that have not started.** Asked mid-build, once the horizon +> made it real: on the day an editor fixes a typo there are already fourteen days of rows carrying +> the old spec. A pin makes a run that **has run** reproducible; one that has not begun has nothing +> to reproduce. The alternative was worse than doing nothing — cancelling a stale occurrence leaves +> its slot held in `uq_evrun_occurrence`, so it would not come back on the new version, it would +> vanish. +> +> **Three things the build settled:** +> +> - **`now - grace` is the window start, not `now`.** An occurrence nobody ever materialised is never +> invented retroactively — three days down must not manufacture three days of `missed` history no +> operator could have seen. It does not need to: rows exist a fortnight early, so a real outage finds +> them already there. **The horizon is what makes the missed sweep mean anything for a recurrence.** +> - **Publishing is the schedule switch; archiving turns it off.** `ready` already means "a version has +> been published and the schedule is live", so a second enabled flag would be another answer to a +> question `state` answers. Expansion reads the **published version's** spec, never the working copy. +> - **Series writes are `admin, editor`.** Naming an arc is authoring; §N2's narrow gate is about +> committing the deployment to a run. +> +> **A defect this phase introduced into the test harness, and fixed.** Putting the expansion leg in +> front of `tick()` made `eventRunner.test.js` reach the dead-port pool on every tick — the file passed +> and took minutes. Stubbing `findSchedulable` there returned it to 0.45s. Worth naming because a suite +> that is merely *slow* reads as a suite that is fine. +> +> **Verified:** `npm test` — **1768 tests, 1711 pass, 56 skipped, 1 fail**, that one the pre-existing +> `engagementManifest.test.js` CRLF failure (`edge` before this branch: 1714/1662/51/1). **54 new +> tests**, of which `eventRecurrence.test.js` (17) is the DST fixture set this plan asked for, and 5 in +> `eventRunnerSql.test.js` prove `findSchedulable`, `listInWindow` and `repinScheduled` **against a +> real MariaDB** (32/32 with a database, skipped without). Client: 354 pass. Four routes added, none +> moved; `check:modules` and `check:hosts` clean; the client builds. **Ships:** recurring and scheduled events, and the calendar that replaces the thing this feature exists to replace. @@ -400,7 +445,9 @@ all. **Trap:** the temptation is a cron string. There is no cron parser in the server dependency tree, the only precedent is in the bot (a different process), and a cron expression is the one field an operator -cannot proofread. Closed shapes render as a form. +cannot proofread. Closed shapes render as a form. *Held: `events/recurrence.js` inverts +`Intl.DateTimeFormat` rather than adding a date library, since Node already ships the tzdata one would +vendor.* --- -- 2.49.1