docs(events): Phase 4 as built — schedule, recurrence and the calendar #212

Merged
whitlocktech merged 1 commits from docs/events-phase-4 into edge 2026-09-03 01:57:44 +00:00
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 evaluating phase conditions; **drain** due steps, checking caps, dispatching, classifying, recording
resources. resources.
**As built in Phase 2, the tick has four legs and one of them is smaller than the above implies.** **As built, the tick has four legs**, ordered: **reclaim** (release leases whose holder died),
Ordered: **reclaim** (release leases whose holder died), **materialise**, **advance**, **drain**, then **materialise**, **advance**, **drain**, then a **prune** on its own six-hourly clock.
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 **Materialise is two halves, and Phase 4 completed it.** The first EXPANDS: every `ready` definition's
and the only occurrences that exist are the ones an admin created. The half that is already real is recurrence is computed in its own IANA zone, and every occurrence inside a **fourteen-day horizon**
the half that already matters: a run whose instant passed while the process was down becomes `missed` (`EVENT_MATERIALISE_AHEAD_DAYS`) becomes a real `scheduled` row via `INSERT IGNORE` against the
rather than starting late and silently. Phase 4 adds the expansion above it. 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 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 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: 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. 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 ### Concurrency
| Contention | Protection | Not an in-process mutex, because | | 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 | | 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. | | **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. | | **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. | | **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. | | **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 **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 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 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` | 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/catalog/options/:sourceId` | staff | a module's option list for a param |
| `GET /admin/events/series` | staff | the arcs a definition may belong to | | `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/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` | — | the calendar: upcoming and live, by category, scope and series |
| `GET /public/events/:slug` | — | one event: storyline, venue, schedule, live phase, results | | `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 | | Phase | | Repos |
| --- | --- | --- | | --- | --- | --- |
| **P0** ✓ | Design of record; §N answered 2026-09-01; `ADMIN_CONTROLS.md` §8 amended | `docs` | | **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` | | **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` | | **P2** | The runner — materialise, claim, advance, drain; leases, `missed`, concurrency | `website` |
| **P3** | The minimal admin surface — **first demo** | `website` | | **P3** | The minimal admin surface — **first demo** | `website` |
| **P4** | Schedule, recurrence, timezones, series and the calendar | `website` `docs` | | **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` | | **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` | | **P7** | The module contract — MODULE_API 1.10.0, proved with a throwaway module | `website` `docs` |

View File

@@ -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 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. > 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 > 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. > 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`) ### Phase 4 — Schedule, recurrence and the calendar (`website` + `docs`)
The closed recurrence shapes — `once`, `weekly`, `monthly` (nth weekday), `manual` — computed in the > **Complete.** `edge` in `website` and `docs`. The closed recurrence shapes — `once`, `weekly`,
definition's **IANA timezone** and stored as UTC in `scheduled_for`. `event_series` becomes usable: a > `monthly` (nth weekday), `manual` — computed in the definition's **IANA timezone** and stored as UTC
definition may belong to a series, and the series has an ordering. > 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
An admin calendar view (month + list), filtered by state, scope and series. > 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 **Ships:** recurring and scheduled events, and the calendar that replaces the thing this feature
exists to replace. 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 **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 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.*
--- ---