feat(events): the resource ledger, leases and cleanup (Phase 8)
Event System Phase 8 (EVENTS_PLAN.md). Docs half: RunicGateway/docs#NNN. One table, one core action, one route, one body field, and two members added to MODULE_API 1.10.0 in place. The safety property the whole world-write half depends on: core now remembers what a run changed in the world, and gives it back on every terminal path. Four decisions settled by the org lead on 2026-09-03, all as recommended: - A lease is acquired by a new CORE action, `core.lease`. Section 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 re-implemented once per module, advisory everywhere. - 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 what core writes before the dispatch is `kind: '@step'`, `ref` = that key. If the answer never comes it stands, and cleanup calls revert() with the key and no resources -- which is why section F's revert takes the key at all. - Cleanup is one sweep over the ledger, not synthetic step rows. The step-shaped version costs a second retry counter beside `revert_attempts`. - `reconcile` is declared here and TRIGGERED BY THE MODULE, through `ctx.events.reconcile()`. Core has no concept of the game being up, so it cannot decide when to ask; it asks once at its own boot. MODULE_API stays 1.10.0. A protocol owes a bump once it has landed on `main`; while it is on `edge` it is amended in place, so the whole module contract reaches an author as one version they read once. Verify - `npm test` -- 2025 tests, 1935 pass, 89 skipped, 1 fail. That one is the pre-existing engagementManifest CRLF failure, in a file this branch does not touch (`edge` before: 1950/1876/73/1). +75 tests. - 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 on UPDATE, and whether the SET NULL foreign key survives beside it are properties of the server. eventRunnerSql.test.js gained 16 tests; 65 pass against the container. The real schema.sql was applied to a fresh database and to an existing one. - Client: 362 pass, and it builds. routes:manifest and swagger -- one route added, none moved. The live walk found three defects, and two of them are the phase's real finding Driven by a throwaway `rig` module in website/modules/, deleted before commit. 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. 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. The test covering the bound asserted `<= 3` and was satisfied by 1: a bound has two halves, and a test that only asserts the ceiling passes against a floor. 3. The first fix for (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. Found by opening the page. Both (1) and (2) are the same mistake: deriving "is there anything to do" from a summary column instead of from the rows. Neither was visible to a unit test, because a test that calls the sweep directly never asks what would have selected the run. The two properties that need the process to die were walked as the plan asks. With the module's perform() hanging, the placeholder existed while the dispatch was in flight and nothing was named; after taskkill and a restart the reclaim re-dispatched the same idempotency key, the retry re-used its own placeholder, and everything was given back. Then, with the module reporting one of two resources as no longer in force, the boot-time reconcile marked the other `orphaned` -- never `reverted`. This branch does NOT bump MODULE_API_VERSION, so the integration kit stays as Phase 7 left it: red until the Phase 16 cutover re-pins ci/core-ref.json. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2090,7 +2090,7 @@ CREATE TABLE IF NOT EXISTS engagement_suppressions (
|
||||
-- module contract. The rest arrive with the phases that give them a writer
|
||||
-- rather than as empty tables nothing reads -- `event_run_phase_gates` in P5,
|
||||
-- `event_action_settings` and `event_run_budget` in P6, `event_run_resources` in
|
||||
-- P8 and `event_run_participants` in P10.
|
||||
-- P8 (below) and `event_run_participants` in P10.
|
||||
--
|
||||
-- Core tables, so no module prefix, and no game vocabulary anywhere below: an
|
||||
-- action id, a scope, a resource kind and a budget dimension are all opaque
|
||||
@@ -2492,6 +2492,101 @@ CREATE TABLE IF NOT EXISTS event_run_budget (
|
||||
UNIQUE KEY uq_evbud_dim (run_id, dimension)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The cleanup ledger: everything one run created or leased, and what became of
|
||||
-- it (EVENTS.md §D, §L "The ledger's two rules"; Phase 8).
|
||||
--
|
||||
-- **It holds both kinds of thing an event owns.** An OBJECT it created is
|
||||
-- `kind: 'creature'` with `ref` a serial, reverted by its own action's
|
||||
-- `revert()`. A VALUE it leased is `kind: 'override'` with `ref` the lease id and
|
||||
-- `payload` carrying the baseline and what was applied, restored by the lease's
|
||||
-- own `restore()`. One table, because cleanup asks both the same question --
|
||||
-- what is still out there, and did putting it back work.
|
||||
--
|
||||
-- **Rule 1: a resource is recorded BEFORE it is confirmed.** A spawn's serial
|
||||
-- does not exist until the module answers, so what is written before the dispatch
|
||||
-- is a PLACEHOLDER keyed by the step's idempotency key (`kind` = the reserved
|
||||
-- '@step', `ref` = that key). On the answer the reported resources are inserted
|
||||
-- `confirmed` and the placeholder is resolved. If the acknowledgement is lost the
|
||||
-- placeholder survives, and cleanup calls `revert()` with the idempotency key and
|
||||
-- no resources -- which is why §F's `revert({ runId, resources, idempotencyKey })`
|
||||
-- takes the key at all. Recording afterwards instead would make every object
|
||||
-- whose ack was lost invisible to cleanup for ever.
|
||||
--
|
||||
-- **Rule 2: revert is idempotent, and its failure is loud and sticky.** A row
|
||||
-- that never reverts stays visible -- the run reaches `completed` with
|
||||
-- `cleanup_status = 'incomplete'` rather than being held `running`, because a
|
||||
-- tidy `completed` over a shard full of orphaned monsters is the failure that
|
||||
-- would end this feature's credibility on its first bad night.
|
||||
--
|
||||
-- **The unique key is what stops two events leasing one target**, and it must
|
||||
-- hold among LIVE rows only: last week's finished event must not keep this
|
||||
-- week's from leasing the same rate. MariaDB has no partial index, so the
|
||||
-- encoding is a STORED generated column that is NULL once the row is no longer
|
||||
-- ours -- and multiple NULLs do not collide in a unique index. It is derived from
|
||||
-- `status` ALONE and the opaque columns stay in the KEY, which is the shape
|
||||
-- TEAMS.md §2.5 had to be corrected into: MariaDB refuses ON DELETE SET NULL on a
|
||||
-- foreign key whose column is a base column of a stored generated column
|
||||
-- (error 1901), so `step_id` must not appear in the expression.
|
||||
--
|
||||
-- **The key is held by the three statuses that mean "core still believes this is
|
||||
-- ours"** -- `pending`, `confirmed`, `reverting` -- and released by the three that
|
||||
-- mean it is not. §D says "among non-reverted rows", which 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. `drifted` means somebody else has
|
||||
-- hold of the value and this run has deliberately let go of it; `orphaned` means
|
||||
-- it vanished. Neither is a claim on the target, and both stay LOUD by another
|
||||
-- mechanism -- `cleanup_status = 'incomplete'` and a row on the run console --
|
||||
-- which is what §L's rule 2 actually asks for. Amended 2026-09-03.
|
||||
CREATE TABLE IF NOT EXISTS event_run_resources (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL,
|
||||
-- Which step made it. It is how cleanup finds the ACTION to call `revert()` on:
|
||||
-- the row records the module and the opaque names, and the step records the
|
||||
-- verb. SET NULL rather than CASCADE, for `engagement_sends`' reason -- a record
|
||||
-- of what was changed in the world must outlive the row that scheduled it.
|
||||
step_id BIGINT NULL,
|
||||
-- The registering module, copied at record time rather than derived from the
|
||||
-- action id, so an uninstalled module still names itself on the console.
|
||||
owner_module VARCHAR(64) NOT NULL,
|
||||
-- Both module-opaque, stored verbatim, never interpreted -- `ctx.teams.activity.push`'s
|
||||
-- treatment. '@step' is the one reserved `kind` and core owns it.
|
||||
kind VARCHAR(64) NOT NULL,
|
||||
ref VARCHAR(190) NOT NULL,
|
||||
payload JSON NULL,
|
||||
-- A lease's deadline, and NULL for an owned object. It goes DOWN THE WIRE as
|
||||
-- well: the game side restores baseline when it passes, without being asked
|
||||
-- again, which is the fail-safe that makes an unattended world change
|
||||
-- defensible. This column is core's copy of that promise, for the console and
|
||||
-- for the boot-time check.
|
||||
lease_until DATETIME NULL,
|
||||
status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted')
|
||||
NOT NULL DEFAULT 'pending',
|
||||
-- Bounded like a step's `attempts`, and for the same reason: a revert that can
|
||||
-- never succeed must become visible rather than cycling for ever. Engagement
|
||||
-- Phase 14's rule -- only a terminal row is ever retention-eligible -- is what
|
||||
-- makes an unbounded counter a row nothing can ever sweep.
|
||||
revert_attempts INT NOT NULL DEFAULT 0,
|
||||
last_error VARCHAR(500) NULL,
|
||||
-- Optional, and module-opaque like the rest: who received it, for a granted
|
||||
-- reward that results should be able to name. `event_run_participants` joins on
|
||||
-- the same key in Phase 10.
|
||||
member_key VARCHAR(190) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
-- 1 while core still believes this resource is this run's, NULL once it is not.
|
||||
-- See the unique key below; derived from `status` alone, deliberately.
|
||||
live_marker TINYINT AS (IF(status IN ('pending','confirmed','reverting'), 1, NULL)) STORED,
|
||||
CONSTRAINT fk_evres_run FOREIGN KEY (run_id) REFERENCES event_runs(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_evres_step FOREIGN KEY (step_id) REFERENCES event_run_steps(id) ON DELETE SET NULL,
|
||||
-- "Two events cannot hold a lease on one target", among non-reverted rows.
|
||||
UNIQUE KEY uq_evres_target (owner_module, kind, ref, live_marker),
|
||||
-- The run console, and the cleanup sweep's read: one run's ledger in order.
|
||||
INDEX idx_evres_run (run_id, status),
|
||||
-- The cleanup leg's scan across runs, and the boot-time lease self-check.
|
||||
INDEX idx_evres_live (status, lease_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- §K's last bound: "a scheduled definition that has never been verified is the
|
||||
-- case worth refusing to start". A version is immutable, so a dry run that passed
|
||||
-- against it stays true — which is what makes the pass a property of the VERSION
|
||||
|
||||
Reference in New Issue
Block a user