docs(events): Phase 8 as built -- the resource ledger, leases and cleanup
The docs half of Event System Phase 8. Code: RunicGateway/website#NNN.
EVENTS.md
Section D's ledger row is corrected in two places and section L gains a new
subsection recording what the build settled:
- Rule 1 needed a mechanism, because a spawn's ref does not exist until the
module answers. What goes in before the dispatch is a PLACEHOLDER keyed by the
step's idempotency key, and that is why revert() takes the key at all.
- A LEASE does not use it and gets rule 1 in a stronger form: its target is the
lease id the step already names, so `core.lease` writes the real row first --
the only moment the two-events-one-target refusal can happen before the world
has been written to.
- The unique key is held by three statuses and released by three (amended
2026-09-03). "Among non-reverted rows" was written before the six statuses had
their meanings; taken literally it makes `drifted` and `orphaned` hold a target
for ever, so one bad night would disable a lease permanently with no control
able to clear it.
- MariaDB has no partial index, so the encoding is a STORED generated column
reading `status` ALONE -- TEAMS.md 2.5's correction, because MariaDB refuses
ON DELETE SET NULL on a foreign key whose column is a base column of one.
- Cleanup is one sweep, not synthetic step rows, and it runs from one place.
- What that sweep SELECTS cost two live-walk defects in opposite directions, and
the pair is the finding worth keeping: deriving "is there anything to do" from
a summary column instead of from the rows stranded a lease outright, and then
made the retry bound mean one attempt. Both are recorded with the reasoning.
Section F gains the lease's verb and the reconcile bullet; section I's "cancel
takes { reason } and not { cleanup }" becomes what shipped; section K's role
table puts re-running cleanup in the admin-only row; section L's cancel and
cleanup-fails rows are updated; Observability names the six new log kinds; and
the API surface table's absent-routes list is now empty.
EVENTS_PLAN.md
Phase 8 marked complete in the shape Phases 0-7 use: the four org-lead
decisions, the four things the build settled, the verified numbers, and the live
walk -- including the three defects only it could find and the two properties
that needed the process to die.
MODULE_API.md
1.10.0 gains `reconcile()` and `ctx.events.reconcile()` IN PLACE rather than a
new version. A protocol owes a bump once it has landed on `main`; while it is on
`edge` it is amended in place, which is the rule the Teams workstream arrived
at, applied to a module API for the first time. The rule list grows from six to
ten: the lease's verb is core's, `until` goes down the wire, revert is
idempotent and reverting something that does not exist is a success, revert is
also called with the key and an empty list, and reconcile is optional where
revert is required.
Checked by hand
`docs` has no CI, so: every anchor in EVENTS.md resolves (checked by generating
the heading slugs and diffing), and every route, identifier, env var and file
path named here exists on the website branch -- `core.lease`,
`core.options.leases`, `ctx.events.reconcile`, `EVENT_REVERT_MAX_ATTEMPTS`,
`event_run_resources`, `live_marker`, `uq_evres_target`, the six log kinds, and
the route in both routes.manifest.json and the OpenAPI spec. Diffs are 144/21,
139/16 and 67/9 with NO CRLF artefact, verified by comparing --numstat against
the real content diff rather than trusting it.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -866,23 +866,146 @@ it was carved out of has not been validated, and P9 should be the *second* consu
|
||||
|
||||
### 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.
|
||||
> **Complete.** `edge` in `website` and `docs`. One table (`event_run_resources`), one core action
|
||||
> (`core.lease`) and one option source (`core.options.leases`), one route
|
||||
> (`POST /admin/events/runs/:runId/cleanup`), one body field (`cancel`'s `{ cleanup }`), and two
|
||||
> members added to MODULE_API **1.10.0 in place** — `reconcile()` on an action and
|
||||
> `ctx.events.reconcile()` on the module context.
|
||||
>
|
||||
> **The version was amended rather than bumped** (org lead, 2026-09-03). A protocol owes a bump once
|
||||
> it has landed on `main`; while it is on `edge` it is amended in place — the rule the Teams
|
||||
> workstream arrived at, applied to a module API for the first time. 1.10.0 has not shipped, so the
|
||||
> whole module contract reaches an author as one version they read once, which was the argument for
|
||||
> putting the lease declaration in it a phase early.
|
||||
>
|
||||
> **The four decisions the org lead settled (2026-09-03), all as recommended:**
|
||||
>
|
||||
> - **A lease is acquired by a new CORE action, `core.lease`.** §F puts the duration bound and the
|
||||
> two-events-one-target conflict check on core's side of the seam, and a lease verb per module
|
||||
> would be both of those re-implemented once per module — advisory everywhere, and wrong in the
|
||||
> first one that forgot. It is `risk: 'change'`, so it is the first core action that is default-off,
|
||||
> admin-only and cap-checked like any module verb.
|
||||
> - **Record-before-confirm is a PLACEHOLDER keyed by the step's idempotency key.** A spawn's `ref`
|
||||
> does not exist until the module answers, so there is nothing to write a row about — the row core
|
||||
> writes beforehand is `kind: '@step'`, `ref` = that key. On the answer the reported resources are
|
||||
> inserted `confirmed` and the placeholder is resolved; if the answer never comes it stands, and
|
||||
> cleanup calls `revert({ idempotencyKey, resources: [] })`. **That is why §F's `revert` takes the
|
||||
> key at all.** A lease skips it and reserves its real target instead, which is the same rule in a
|
||||
> stronger form.
|
||||
> - **Cleanup is one sweep over the ledger, not synthetic `event_run_steps` rows.** The step-shaped
|
||||
> version buys the console's per-step retry for free and costs a second retry counter beside
|
||||
> `revert_attempts` — two counters that disagree the first time a step reverts three of its four
|
||||
> resources. The manual retry is the route this plan already promised.
|
||||
> - **`reconcile` is declared here and TRIGGERED BY THE MODULE**, through `ctx.events.reconcile()`.
|
||||
> Core has no concept of the game being up (§F), so it cannot decide when to ask; module-uo already
|
||||
> watches `bootId` to tell a shard restart from a sidecar reconnect, and that is the moment. Core
|
||||
> asks once at its own boot, which is the one reconnect it can see.
|
||||
>
|
||||
> **Four things the build settled:**
|
||||
>
|
||||
> - **The unique key is held by three statuses and released by three**, which corrects §D's *"among
|
||||
> non-reverted rows"* — written before the six statuses had their meanings. Taken literally it makes
|
||||
> `drifted` and `orphaned` hold a target for ever, so one bad night would disable a lease
|
||||
> permanently with no control able to clear it. `drifted` means somebody else has hold of the value
|
||||
> and this run has let go; `orphaned` means it vanished. Neither is a claim, and both stay loud
|
||||
> through `cleanup_status` and the console instead, which is what rule 2 actually asks for.
|
||||
> Recorded as a dated amendment in `EVENTS.md`.
|
||||
> - **MariaDB has no partial index, so the key is a STORED generated column that is NULL once the row
|
||||
> is no longer ours** — and it reads `status` ALONE. `TEAMS.md` §2.5 had to be corrected on this
|
||||
> exact shape: MariaDB refuses `ON DELETE SET NULL` on a foreign key whose column is a base column
|
||||
> of a stored generated column (error 1901), and `step_id` must stay SET NULL because a record of
|
||||
> what was changed in the world has to outlive the row that scheduled it.
|
||||
> - **Cleanup runs from ONE place: a fifth leg of the runner's tick**, ordered after advance so a run
|
||||
> that completes in one tick is torn down in the same one. Hooking each terminal path would be four
|
||||
> call sites, three of them inside a request, and none would survive a process that died
|
||||
> mid-teardown. It is also why `cancel` answers at once — the right behaviour for a control pressed
|
||||
> at 2am against a shard that may be the reason.
|
||||
> - **What that leg SELECTS is the phase's real finding, and it took the live walk twice.** See
|
||||
> below: the obvious reading of `cleanup_status` stranded a lease outright, and then made
|
||||
> `EVENT_REVERT_MAX_ATTEMPTS` mean one attempt.
|
||||
>
|
||||
> **Verified:** `npm test` — **2025 tests, 1935 pass, 89 skipped, 1 fail**, that one still the
|
||||
> pre-existing `engagementManifest.test.js` CRLF failure in a file this branch does not touch
|
||||
> (`edge` before: 1950/1876/73/1). **+75 tests**: 14 in the new `eventLedger.test.js`, 20 in the new
|
||||
> `eventCleanup.test.js`, 16 in `eventRunnerSql.test.js` (which skip without a database — see
|
||||
> below), 8 in `eventRunner.test.js`, 9 in `eventRunControls.test.js`, 5 in
|
||||
> `eventModuleContract.test.js`, 2 in `eventActionRegistry.test.js` and 1 in `eventsRoles.test.js`.
|
||||
> Client: **362 pass**; the client builds. `npm run routes:manifest` and `npm run swagger` — **one
|
||||
> route added, none moved.**
|
||||
>
|
||||
> **The unique key was proved against a real MariaDB, because nothing else can prove it.** Whether
|
||||
> multiple NULLs collide in a unique index, whether a STORED generated column is recomputed and
|
||||
> re-indexed on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the
|
||||
> server and of nothing else. `eventRunnerSql.test.js` gained 14 tests covering all three, plus the
|
||||
> ledger model's own statements: `DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=… node
|
||||
> --test test/eventRunnerSql.test.js` — **63 pass**. The real `schema.sql` was also applied to a fresh
|
||||
> database *and* to an existing one, because `CREATE TABLE IF NOT EXISTS` next to a generated column
|
||||
> is where a migration silently does nothing.
|
||||
>
|
||||
> **And that file had a latent bug this phase found.** Its model-backed tests required
|
||||
> `eventRunBudget.db`, whose pool `utils/db` builds at require time from `DB_NAME` — and `utils/db`'s
|
||||
> own `dotenv.config()` reads `server/.env`. So on a developer's machine those tests were reaching
|
||||
> that developer's real schema while the fixtures they asserted against were being written into the
|
||||
> throwaway database next door. They passed only because both tables happened to exist in both.
|
||||
> `process.env.DB_NAME = DB` before the require fixes it, and the whole run is disposable again.
|
||||
>
|
||||
> **The stale-stub trap, for the fourth time in this feature.** Phase 4's expansion leg, Phase 5's
|
||||
> gate read, Phase 6's settings read and now the ledger write: a new leg under a model needs a stub in
|
||||
> every file that stubs that layer, and unstubbed it is not a wrong answer — it is a ten-second
|
||||
> `ECONNREFUSED` against the dead port. One missing stub cost `eventsAdmin.test.js`'s run-detail test
|
||||
> ten seconds and said nothing about the route it was testing.
|
||||
>
|
||||
> ### The live walk, and the three defects only it could find
|
||||
>
|
||||
> Driven by a throwaway `rig` module in `website/modules/` — a real module directory scanned by the
|
||||
> real loader, **deleted before commit** — registering one ledgering action and one lease, with its
|
||||
> behaviour driven by a JSON file on disk so a revert could be made to fail and then succeed on the
|
||||
> same boot. 34 assertions, all green at the end. `rig` declared `coreApi: "^1.10.0"` and module-uo's
|
||||
> `"^1.9.0"` still resolved, on the same boot: the additive claim proved rather than asserted.
|
||||
>
|
||||
> **Defect 1 — a lease was never given back at all.** `core.lease` reserves its own ledger row, so it
|
||||
> never went through the ledger's dirty-marking, so a run holding only a lease kept
|
||||
> `cleanup_status = 'not_required'` and the cleanup leg — which selected on `pending` — never looked
|
||||
> at it. Every unit test passed: they call the sweep directly, and a test that calls the sweep never
|
||||
> asks what would have SELECTED the run.
|
||||
>
|
||||
> **Defect 2 — `EVENT_REVERT_MAX_ATTEMPTS` meant one attempt, not three.** The first failing sweep
|
||||
> moved the run to `incomplete`, which took it out of the leg's own scan for ever. Visible only as
|
||||
> `revert_attempts` sitting at 1 through half a minute of live ticks; the test that covered the bound
|
||||
> asserted `<= 3` and was satisfied by 1. **A bound has two halves — it retried, and it stopped — and
|
||||
> a test that only asserts the ceiling passes against a floor.** Both halves are asserted now.
|
||||
>
|
||||
> **Defect 3 — the first fix for defect 2 made the console lie.** Spending every row's
|
||||
> `revert_attempts` was a tidy way to take a `cleanup: false` run out of a counter-bounded scan, and
|
||||
> the run page then rendered *"3 attempts"* beside resources nothing had ever tried — which reads as
|
||||
> *"core tried three times and could not"*. Found by opening the page, exactly like Phase 7's React
|
||||
> defect. **A counter that means two things is a counter a screen cannot render.**
|
||||
>
|
||||
> **What the walk proved, beyond the defects:** three wisps recorded and given back with the run
|
||||
> reaching `complete`; a failing revert leaving the run `completed` + `incomplete` with the reason on
|
||||
> each row; the cleanup route rescuing it and a moderator being refused it; the route refusing a run
|
||||
> still in flight and one that recorded nothing; a lease taken, its baseline and applied value in the
|
||||
> payload, and a **second run refused it by name**; a GM's mid-event edit producing `drifted` with the
|
||||
> world left alone, and a later run still able to lease that target; and `cancel { cleanup: false }`
|
||||
> refused to a moderator, allowed to an admin, and logged.
|
||||
>
|
||||
> **The two that needed the process to die**, which is the plan's own verify line. With the module's
|
||||
> `perform()` hanging: the placeholder existed while the dispatch was in flight, **nothing was named**
|
||||
> (`unresolvedResources: 1`, an empty list — rule 1 visible in the wild), and after `taskkill` and a
|
||||
> restart the reclaim re-dispatched the SAME idempotency key, the retry re-used its own placeholder
|
||||
> rather than writing a second, and everything was given back. Then, with the module reporting one of
|
||||
> two resources as no longer in force, core's boot-time `reconcileAll()` marked the other `orphaned`
|
||||
> — never `reverted` — and logged it.
|
||||
>
|
||||
> **The console, in all three states.** A clean run: neutral border, *"Everything this run created or
|
||||
> borrowed has been given back"*, green rows, no button. An unresolved run: amber border, *"3 of these
|
||||
> are still unresolved"*, *"still out there"*, and **Try cleanup again** — which was clicked, answered
|
||||
> `200`, and flipped the panel green in place. A drifted lease: *"someone else moved it"*, its
|
||||
> deadline, its genuine attempt count, and the sentence naming the value that is there now.
|
||||
>
|
||||
> **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.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user