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