Closes the second half of EVENTS_PLAN.md Phase 0. All of EVENTS.md §N1–N11 were answered by the org lead on 2026-09-01, before any code, which lifts the ⚠ gate from P11 and P12 and unblocks P1. Nine went the way §N recommended. Two did not, and each changes a phase: - N2 — publishing a version and starting a run are `admin` only; a moderator keeps live control of a run already in flight (cancel, abort) and nothing more. Start and stop are deliberately not the same gate: starting commits the deployment to everything a definition contains, unattended, while cancelling is incident response. §K's table and the API surface table are updated, and P3 and P6 carry it. - N4 — this deployment is single-instance and not planned to change, so P2 does not build the `--scale app=2` test. Every claim path in §E is built exactly as specified regardless — the unique index and the CAS equally protect a tick that overruns into the next one, and the lease and its reclaim recover a step whose process died mid-dispatch. §E records what to build first if this deployment is ever scaled. N1 was taken in full, item grants included, which is a reversal of two separate lines in ADMIN_CONTROLS.md §8 rather than one. §8's cut list names "item/gold grants" explicitly and separately from world building, so the new §8a amendment says both out loud rather than letting the grant ride along on the creation reopening. §8's bullet is left standing verbatim — it is the decision that was actually taken — with a pointer to the amendment, and §1's thesis paragraph gets the same pointer. Loot-table changes and arbitrary [set / [get / [add stay permanently excluded, along with the rest of the Tier-H moderation verbs. N3 gives ENGAGEMENT.md §7.1 Q6's "no evaluator yet" an owner: the Event System runner becomes the `kind: 'scheduled'` evaluator, built in that workstream's P2. Nothing in the engagement contract changes. One consequence was derived rather than decided and is flagged for review: POST /admin/events/:id/verify moves to `admin, editor` rather than staying with `start`. A dry run dispatches nothing, and the author who wrote the definition is who should price it against the caps before asking an admin to publish. Verified: every relative link and anchor in the four touched files resolves, including the renamed §N anchor (#n--decisions) and the new #8a. The docs repo has no CI workflow, so the plan's "docs CI link check" was run by hand. Co-Authored-By: Claude <noreply@anthropic.com>
30 KiB
The Event System — phased implementation plan
Derived from EVENTS.md revision 5, which is the design of record. This document
decides order: what lands in which pull request, what each one ships on its own merit, and how each
is proved. It re-specifies nothing — where this and EVENTS.md disagree, EVENTS.md wins and this
file is the one with the bug.
Seventeen phases, P0–P16. Every phase is independently shippable and leaves the site working.
No phase is gated any more. P11 and P12 hung on EVENTS.md §N1 (the ADMIN_CONTROLS.md §8
amendment); §N1 and the other ten were answered by the org lead on 2026-09-01, which closes P0
and lifts the gate. They stay deliberately late all the same — they are the phases that reach into
the world, and they belong after the ledger that makes them safe. Everything else — the engine, the
scheduler, the calendar, the conditions, the caps, the module contract, the ledger, the first wave of
UO actions, the integrations, the authoring UI, the public surface — needed no decision beyond P0.
Those fourteen phases reach the game only to announce, over verbs the write plane already carries; nothing in them creates or
changes a thing in the world.
Before anything: three facts about the ground
1. edge is free, and it is stale. The engagement workstream is fully cut over as of 2026-09-01 —
website main at 6331b36 (engagement Phase 14, retention), docs #205 merged, module-uo's ci/core-ref.json
re-pinned to main. No pull request is open in any of the six repos bar an automated
PROJECT_TREE.md sync.
But edge is 0 commits ahead of main and several behind it in website, docs and
module-uo — the cutover merged edge into main and nothing moved edge afterwards. So the first
act of P1 is to reset each edge to its main, not to branch off it as it stands; branching off
a stale edge would silently revert the engagement retention work the moment this workstream cut
over. Verify per repo before starting:
git rev-list --count origin/main..origin/edge # must be 0
git rev-list --count origin/edge..origin/main # if > 0, edge is stale — reset it
2. A MODULE_API_VERSION bump turns the integration kit red, on purpose. ci/core-ref.json pins
a main sha and checkCoreApi.js asserts equality with what that sha declares. P7 bumps the
contract to 1.10.0, so the kit goes red from P7 until the cutover re-pins it. That is the mechanism
working — it forces someone to re-read the chapters — and it must be stated in P7's PR body so nobody
"fixes" it. The same is true of module-uo's frozen-manifest check for the length of the edge
window.
3. servuo-plugins has no CI build. The plugin compiles only inside ServUO, and the dynamic
rebuild can silently reload a stale Scripts.dll — so "it booted clean" is not evidence the new code
is live. Every plugin-touching phase (P11, P12) verifies against the local tree at
C:\Users\colby\Desktop\ServUO with a build-offline-first step, and confirms new code is live rather
than trusting a clean boot.
Cross-cutting obligations, every phase
Not repeated per phase below. A PR that skips one of these is not done.
| Obligation | Command / rule |
|---|---|
docs/ updated in the same PR |
CLAUDE.md: a code change is not complete until docs/ reflects it |
| Server tests | cd website/server && npm test |
| OpenAPI regenerated when a route changed | npm run swagger → committed swagger/swagger-output.json |
| Route manifest proves no URL moved | npm run routes:manifest, zero-line diff in the PR |
| No game vocabulary in core | npm run check:modules (website root) |
| module-uo, when touched | npm run check:imports, npm run check:swagger, npm run build --prefix client before npm run check:externals, then both test suites |
| Conventional Commits + AI disclosure | type(scope): summary; Co-Authored-By: trailer; tick the PR-template box |
| Branch | from an up-to-date edge, never a stale one |
Ordering rationale — why this shape
Three choices in the ordering are deliberate and worth stating, because the obvious alternative is wrong in each case.
A demoable surface lands at P3, not at P13. The rich authoring UI is late (P13) and a minimal admin surface is early (P3). Splitting the UI in two looks like duplicated work and is not: this project reviews in a browser — the org lead's own review stack exists for exactly that — and ten backend phases with nothing to click is ten phases without feedback. P3 is a list, a form, a start button and a run console. P13 is the timeline editor, the option-source dropdowns and the cap meter, built once the schemas they render actually exist.
The module contract (P7) comes after the engine, not before it. The temptation is to design the
seam first. But registerEventActions is shaped by what the runner actually needs to hand an action
and what it does with the answer, and both are unknown until P2 and P6 are built. Core registers its
own core.announce / core.wait / core.cue through the same registry from P1 — exactly as
registries.registerCore() already does for streams and triggers — so the seam is exercised on every
boot long before a module uses it, and P7 is a generalisation of something working rather than a
guess.
Caps (P6) come before the module contract, and before any world write. A cap is cheap to add to an empty system and expensive to retrofit onto a live one, and it is the single control that bounds the two failures scheduling makes worse: a typo and a compromised session. P6 is a small phase placed early on purpose.
The phases
Phase 0 — Design of record (docs)
Complete, in two pull requests. The first introduced this file and
EVENTS.md; the second recorded the eleven decisions and amended the two documents they reach into. All of §N1–N11 were answered by the org lead on 2026-09-01, before any code, so nothing below is gated.
Land docs/website/EVENTS.md, and answer §N1–N11. Two of those answers have consequences outside this
document and should be written where they will be found:
- N1 is an amendment to
../link/ADMIN_CONTROLS.md§8, recorded there as an amendment with its date and reasoning — not a silent reversal. §8 is cited by name in four places; a decision that contradicts it and leaves it standing is worse than either decision. - N3 (does the event runner become the
kind: 'scheduled'evaluator) closesENGAGEMENT.md§7.1 Q6, which currently reads "no evaluator yet". If the answer is yes, that row changes in the same PR.
Ships: the design of record, and two documents that stop contradicting each other.
Verify: docs CI link check; every relative link resolves.
As answered, 2026-09-01. Nine of the eleven went the way
EVENTS.md§N recommended. Two did not, and each changes a phase below.
- N2 — starting a run is
adminonly. A moderator gets live control of a run already in flight — cancel and abort — and nothing more. Starting commits the deployment to everything a definition contains, unattended; cancelling is incident response, and gating the stop button on the same role as the start button would behave badly in exactly the case moderators exist for. P3 and P6 carry this, andEVENTS.md§K's table is the normative statement of it.- N4 — this deployment is single-instance, and not planned to change. P2 does not build the
--scale app=2test. Every claim path is built exactly as specified regardless: the unique index and the CAS equally protect a tick that overruns into the next one, and the lease and its reclaim recover a step whose process died mid-dispatch. The multi-instance property becomes true-by-construction rather than proved;EVENTS.md§E records what to build first if this deployment is ever scaled or acquires a rolling deploy.N1 was taken in full, item grants included — a reversal of two separate lines in
ADMIN_CONTROLS.md§8 rather than one, and the amendment at §8a says both out loud.
Phase 1 — Schema, CRUD and the core action registry (website + docs)
The six tables that do not depend on the module contract: event_definitions, event_series,
event_versions, event_runs, event_run_steps, event_run_log. Admin CRUD, publish (which
snapshots a version), archive. router/v1/admin/events.router.js + events.controller.js, models as
.model.js / .db.js pairs under model/events/.
The registry lands here, with core as its first registrant. modules/registries.js gains
registerEventActions staging and commit, and registries.registerCore() registers three core-owned
actions: core.announce (post to an announce leg / broadcast target), core.wait (a timed no-op) and
core.cue (post an instruction and wait for a human). None of them execute yet — P2 is what runs a
step — but the registry, the id grammar, the risk classes and the param validation are all live and
exercised on every boot.
Ships: nothing user-visible; the site is unchanged. A run row can be created and stays
scheduled forever, which is correct for this phase and must be visible as such rather than looking
broken.
Verify: npm test; the registry's collision and validation paths tested the way
registries.js's existing members are; routes manifest and swagger regenerated.
Trap: register() must not touch the database (MODULE_API.md §2.2) — routeManifest.js and
swagger.js both require app.js against a dead pool. Core's own action registration is subject to
the same rule.
Phase 2 — The runner (website)
utils/eventRunner.js, the eighth poller: same setInterval + unref() + stop() shape as the
other seven, wired into server.js's start and shutdown beside engagementWorker.
Its tick, in order: materialise due occurrences (INSERT IGNORE against
UNIQUE (definition_id, scope, scheduled_for)); advance runs (CAS scheduled → starting,
running → ending, materialise the phase's steps); drain due steps (CAS pending → running with a
lease, dispatch, classify, record). Executes the three core actions from P1.
Also here: missed and the grace window, the lease and its reclaim, concurrency_key rendered from
run params, and health as a column separate from status.
Ships: a manually started event that broadcasts, waits, and completes. Demoable over curl.
Verify: npm test. No two-instance test — §N4 settled this deployment as single-instance, so
the --scale app=2 rig the engagement workstream used is not built here. The claim paths are still
built exactly as EVENTS.md §E specifies, and they are still the point of the phase: they are what
protects a tick that overruns into the next one, and what recovers a step whose process died
mid-dispatch. Test both in-process. The decision is not licence to drop a CAS.
Two traps, both already paid for once in this codebase.
- A reclaim must not reset
attempts. Engagement Phase 14's defect: a sweep that returned every stale row to its start state madeMAX_ATTEMPTSunreachable, so the row cycled forever, never terminal, therefore never retention-eligible. - The unique index, not the claim, is what prevents a double run. The claim decides who advances an occurrence; the index is what stops two existing.
Phase 3 — The minimal admin surface (website)
client/src/routes/admin/views/EventsAdmin.jsx, EventEditor.jsx, EventRun.jsx, plus the nav rows.
A list with state and next occurrence; a create/edit form; publish; start now; cancel; and a run
console showing the phase, the step list with status and attempts, and the log.
Publish and start now are admin only from this phase, not from P6 (§N2). Cancel is admin +
moderator. The routes get their real gates here even though the switchboard they will eventually
consult does not exist yet — a button that is admin-only later and open now is a gate nobody
notices was missing.
The spec is edited as structured fields for the parts that exist (name, description, schedule, phases with their steps) and the step's params as a raw JSON field — a deliberate placeholder that P13 replaces with the schema-driven editor. Say so in the UI, so it does not read as the finished thing.
core.cue's confirm button lands here, which is what makes the GM cue usable.
Ships: an admin can author, schedule, start and watch an event that announces things and cues a human. This is the first phase with a demo, and it is the one to put in front of the org lead before building further. Verify: the client test suite; a browser walk on the local review stack.
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.
Ships: recurring and scheduled events, and the calendar that replaces the thing this feature
exists to replace.
Verify: npm test with DST-crossing cases as explicit fixtures — a Friday 20:00 event in
Europe/Berlin computed across the March and October transitions, and one in a zone with no DST at
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.
Phase 5 — Conditions and phase advancement (website + docs)
Phase advance on { after: '30m' } and on { on: '<triggerId>', where: <conditions>, 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.
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
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.
Phase 6 — Enablement, caps and mayInvoke (website + docs)
event_action_settings (one row per registered action, everything above notify disabled by default)
and event_run_budget with the conditional increment:
UPDATE event_run_budget SET consumed = consumed + ?
WHERE run_id = ? AND dimension = ? AND consumed + ? <= cap
A breach is refused — the step does not run, does not retry, and is surfaced to the author with the
dimension and the numbers, because "you asked for 40 and this deployment allows 30" is an authoring
error, not an outage.
The whole authorisation decision moves behind one function, mayInvoke(user, action, run): role,
enablement, cap, and the shard's own switch. Not for tidiness — it is what keeps an EM-style
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.
Ships: an admin switchboard, and a system that cannot be made to do an unbounded amount of
anything.
Verify: npm test, including two concurrent steps against one cap proving neither over-spends;
a 403 walk across all four roles on every route.
Phase 7 — The module contract (website + docs + a throwaway test module)
Generalise P1's registry into the public contract: registerEventActions with cost, risk,
reversible, budgetMs, params and perform / revert; registerEventBudgets;
registerEventLeases; and param option sources. MODULE_API_VERSION → 1.10.0, with §2.4 and
§1.1 written the way every other member is.
Dispatch through the envelope: a rejected promise, a throw, a timeout, a non-object and a missing
ok are all read as { ok: false, retry: true } — the inverse of registerTeamProvider's default,
because here the expensive mistake is recording a world change that did not happen.
verify: true — dry run — is a required parameter a module must honour, with the test in the kit.
Prove it with a throwaway module, not with module-uo. A contract validated only against the module it was carved out of has not been validated, and P9 should be the second consumer of this seam.
Ships: the seam. Core still does everything it did before.
Verify: npm test; the throwaway module exercising every failure shape; check:modules green.
This phase turns the integration kit red and that is the mechanism, not a bug.
checkCoreApi.jsasserts equality against the pinnedmainsha; it stays red until the cutover re-pins it. Say so in the PR body.
Phase 8 — The resource ledger, leases and cleanup (website + docs)
event_run_resources with lease_until, the drifted status, and
UNIQUE (owner_module, kind, ref) among non-reverted rows — which is what makes two events unable to
lease one target, and produces a refused step at authoring time rather than a corrupted baseline at
runtime.
Record before confirm. The step writes a pending row, dispatches, and promotes it on the answer.
Recording afterwards makes every object whose acknowledgement was lost invisible to cleanup forever —
so reverting a resource that does not exist must be a success.
Cleanup steps are generated from the ledger at teardown, on every terminal path — completion,
cancellation and abort alike. cleanup_status is its own column: a run reaches completed with
cleanup_status = 'incomplete' and stays on the admin screen, rather than being held running.
Reconcile-on-reconnect: the runner asks each ledgered resource's module what is still in force.
Ships: the safety property the whole world-write half depends on. Also useful on its own — the
platform gains a durable record of what it changed.
Verify: npm test; a rig run that kills the process mid-run and confirms cleanup completes on
restart; a run whose revert fails and stays visible.
Phase 9 — UO wave 1: the actions that need no protocol change (module-uo + docs)
module-uo registers its first event actions over the write plane that already exists:
uo.broadcast, uo.towncrier.post, uo.news.post. Option sources answered from the spawn
atlas — uo.options.regions, uo.options.landmarks, uo.options.creatures — which cost nothing
new and work with the shard down.
Ships: the first end-to-end event against a real shard: scheduled, announced in-game and on the site, cued to a GM, completed, recorded. Verify: the whole rig — ServUO + sidecar + website — running a real two-phase event.
Trap worth writing into the action declarations. These three verbs have different idempotency.
towncrier and news are keyed by id and re-posting replaces, so a retry is safe. broadcast is
not — a retry is a second announcement to everyone online — and there is no idempotency key on the
wire until P11. So uo.broadcast ships with on_failure: 'skip' rather than a retry, and the
declaration says why.
Phase 10 — Integrations (website + docs)
Core registers its own event. triggers — run.scheduled, run.started, phase.changed,
run.ending, run.completed, run.cancelled, and run.failed at ceiling: 'admin' — with seeded
templates through the mechanism registerEngagementSeeds already provides. Events owns none of the
delivery.
event_run_participants, results publication, and the core.announce.post action that links an
existing post to a run and enqueues it through announce_jobs — so the in-game town crier and Discord
both come free as already-registered legs with retry and classification.
ENGAGEMENT.md §8.6's row — "a scheduled event is starting · ❌ needs a manual/scheduled trigger
type" — is resolved in this PR.
Ships: every announcement channel the platform has, for every event, per user preference.
Verify: npm test; a mail-catcher rig confirming an event announcement reaches email, in-app and
push; the ceiling on run.failed proved to exclude a moderator.
Phase 11 — Protocol: idempotency, lease deadlines, participation (servuo-plugins + link + module-uo + installer + docs)
§N1 answered 2026-09-01 — no longer gated. A five-repo protocol bump, and the shape TEAMS Phase 1 already walked.
- Plugin: an idempotency key on every inbound command with a bounded recent-key set that answers a
repeat with the original result; a lease deadline timer that restores baseline without being
asked; a run-scoped participation ledger;
champ.boss.killedas a first-class kind. overlay.tomlprotocol version bumped in this PR — the installer refuses to pair a sidecar and an overlay that disagree, so a bump in a later PR means the next bundle silently fails to compose.- Sidecar:
PROTOCOL_VERSIONbumped, the new fields carried, the new kind stored and served. installer: joins the phase because of the pairing above.docs: a newdocs/link/v6.mdas the spec of record, plusINTEGRATION.md.
Ships: the game side can refuse a duplicate command and can put the world back on its own. Both are safety properties, not features. Verify: against the local ServUO tree with a build-offline-first step and confirmation the new code is live; a deliberate duplicate command; a lease whose website is killed before it expires, proving baseline returns anyway.
Phase 12 — UO wave 2: the world verbs (servuo-plugins + link + module-uo + docs)
§N1 answered 2026-09-01 — no longer gated, and taken in full, so the item grant row below stands. The capability set the two UO programs demonstrate, each cap-bounded, ledgered, and either owned or borrowed:
| Verb | Owned or borrowed | Cap dimension |
|---|---|---|
| Named, hued creatures from the atlas's ~800 constructible types | owned — deleted by serial | uo.creatures |
| "Simple" boss variants, as event-owned creature templates | owned | uo.bosses |
| Oracle NPCs with scripted dialogue | owned | uo.npcs |
| Temporary gates | owned, with a deadline | uo.gate.minutes |
| Temporary decoration lockdown | owned | uo.decor |
| A live config value | borrowed — a lease | — |
| A property on an existing object | borrowed — a lease | — |
| Seasonal-event toggle | borrowed | — |
| World save | neither — a one-shot | — |
| Item grant | owned, reversible: 'none' |
uo.rewards |
The lease allowlist ships with a boot-time self-check. 258 Config.Get call sites in ServUO split
between live reads and values cached at type initialisation, and a lease on the second kind applies
cleanly and does nothing. Each key sets, reads back and restores at boot, and drops itself from the
advertised catalog if it does not take — a capability that disappears loudly beats one that lies.
Ships: the invasion.
Verify: the whole rig, running a real multi-phase event with spawns, a lease, and a full teardown
back to baseline — plus a deliberate mid-event GM edit of a leased property, confirming drifted
rather than a silent revert of their change.
Phase 13 — The authoring UI proper (website)
Replaces P3's placeholders. The phase timeline (not a node graph — the condition grammar has no branching and a canvas would advertise power the engine does not have); the step editor rendering each action's declared params, with option-source dropdowns; the live cap meter; dry run; rehearsal.
Ships: an administrator can build the invasion without touching JSON, which is the acceptance criterion the whole feature was asked for. Verify: the client suite; a browser walk authoring a multi-phase event end to end with no raw JSON at any point.
Phase 14 — Public, player and mobile (website + android-app + docs)
GET /public/events, /public/events/:slug, /public/events/series/:slug,
/player/events/history, and an events capability string.
One prerequisite fix, in this phase and not after it. The Android app hardcodes
api/v1/public/shard/stream — a module path — and reads /public/modules nowhere, so it cannot
render a site whose module it has never heard of. That is a small fix and it blocks the app seeing
events at all.
Ships: the public calendar, event pages with their storyline and results, participation history, and the app. Verify: the client and app suites; an emulator walk.
Phase 15 — Integration Kit chapter 5 (integration-kit)
An event-capable module. Teach and link out; re-specify nothing — EVENTS.md and MODULE_API.md stay
normative.
The four things a second module's author will get wrong, one paragraph each, all invisible until an
outage: the envelope's failure default, the idempotency passthrough, recording a resource before
confirming it, and under-declaring cost.
Like Teams Phase 11, this cannot merge until the cutover exists — the kit is pinned to a main
sha, and the contract it teaches is not on main until then.
Phase 16 — Acceptance walk and cutover
The walk first, against released artefacts, not a working tree. The whole rig — ServUO, sidecar, website, emulator — running a real multi-phase event, including three deliberate failures:
- a mid-run process restart, proving the run resumes and no step double-executes;
- a sidecar kill mid-phase, proving the run degrades rather than failing and world writes park;
- a cap breach, proving
refusedand an author who is told why.
Then edge → main, in the order every previous cutover used: the protocol side first, the module,
core, docs, then the kit's re-pin and runicgateway.com.
Two documents that are cutover-window work by construction.
runicgateway.com—checkFactsreadsmain, so any claim about events is unverifiable until the cutover lands. Same 12a/12b split the engagement workstream needed..profile— the org landing page is updated when the shape of the project changes, which a new subsystem is.
What this plan does not do
Stated so the omissions are choices rather than oversights.
- No node-graph editor (P13). The engine has no branching.
- No delegation, grants or proposal queue. Permissions gate on the existing admin roles. P6's
mayInvokekeeps it a cheap later option. - No points. No core points system exists and no write path to any loyalty board exists.
- No event invoking another event. It already works by composition — a second event's condition can
be
event.run.completed— and a direct edge would need cycle detection and a story about cancelling a parent. - No core SSE. The run console polls. Adding a live channel to core is a larger change than this feature needs, and if it is ever built it should be core infrastructure with module-uo's two streams migrating onto it.
- No mutation of shard-owned content without a baseline. Loot tables and arbitrary
[set/[get/[addstay excluded.
Dependency summary
flowchart TD
P0["P0 · design of record"] --> P1["P1 · schema + registry"]
P1 --> P2["P2 · the runner"]
P2 --> P3["P3 · minimal admin UI<br/><b>first demo</b>"]
P3 --> P4["P4 · schedule + calendar"]
P2 --> P5["P5 · conditions + diagnosis"]
P2 --> P6["P6 · enablement + caps"]
P6 --> P7["P7 · module contract<br/>MODULE_API 1.10.0"]
P7 --> P8["P8 · ledger + leases + cleanup"]
P8 --> P9["P9 · UO wave 1<br/>no protocol change"]
P5 --> P10["P10 · integrations"]
P9 --> P10
P0 -.->|"§N1 answered"| P11["P11 · protocol bump"]
P8 --> P11
P11 --> P12["P12 · UO world verbs"]
P7 --> P13["P13 · authoring UI"]
P4 --> P13
P10 --> P14["P14 · public + mobile"]
P13 --> P16["P16 · walk + cutover"]
P12 --> P16
P14 --> P16
P7 --> P15["P15 · kit chapter 5"]
P15 --> P16
The critical path is P0 → P1 → P2 → P6 → P7 → P8 → P11 → P12 → P16. Everything else can run beside it. P3, P4, P5 and P10 are the phases that could be picked up by a second pair of hands without blocking the spine.
If only part of this gets built
Three defensible stopping points, in case scope has to shrink.
Stop after P5 — a scheduled, multi-phase, condition-advanced event engine that announces through core's own actions and cues a human for anything in-world. No module contract, no ledger, no protocol change. This is a real product and it is roughly a third of the work.
Stop after P10 — everything above plus the module seam, the ledger, the first UO actions and every
notification channel. The website orchestrates and announces; a GM does the target-driven parts
in-client, exactly as ADMIN_CONTROLS.md §8 argued they should. This is the recommended minimum
viable scope, and it was the scope that required no answer to §N1 at all. §N1 has since been
answered in full, so this is now a scope choice rather than a way around an open decision.
Stop after P14 — everything, including the world verbs and the public calendar. P15 and P16 are
not optional if anything ships to main; they are the cost of the edge discipline.