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 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:10:50 -05:00
parent a8cc051f7a
commit 9410181560
2 changed files with 153 additions and 19 deletions

View File

@@ -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` |