feat(events): an expired ledger status and ctx.events.expired (MODULE_API 1.11.0)
All checks were successful
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 5m48s
PR Checks / bot-tests (pull_request) Successful in 7m51s

A game that ends a resource at its own deadline — a Rust zone erased when its
time is up — could reach core only through ctx.events.reconcile(), which files
it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
call files it as the plan working (Rust PLAN_FIXES F14, D170, D183):

- event_run_resources.status gains `expired`, terminal like `reverted`: the
  sweep never takes it back, live_marker releases the target, and it joins
  neither HELD nor UNRESOLVED. The ENUM ALTER re-runs as a no-op on every boot
  (checked on MariaDB 11.8 with the stored generated column depending on it).
- ctx.events.expired({ kind, ref }) marks the calling module's own pending,
  confirmed or orphaned rows for that target expired and logs
  `resource.expired`; a revert in flight is left to finish. The owner is bound
  by the loader, like reconcile. A finished run whose last unresolved row this
  was goes to cleanup `complete`, even from `incomplete`.
- The run console shows it green, "ended by the game on time".

Additions only, so minor. Module-uo (coreApi ^1.10.0) calls none of it and
reads no ledger status; the contract test now asserts the range still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:13:07 -05:00
parent 1a76b98d76
commit cc1f49af29
14 changed files with 309 additions and 10 deletions

View File

@@ -427,6 +427,64 @@ async function reconcileAll() {
return out
}
// The bounds on what a module may name. They are the columns' own: a longer
// value cannot be a row, so it is refused rather than truncated into a match
// against somebody else's.
const MAX_KIND = 64
const MAX_REF = 190
/**
* The game ended one of this module's resources by itself, as it was told to —
* `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183).
*
* **Expired is not orphaned, and the difference is the reason this exists.** A
* Rust zone is created with a deadline and the game erases it when the deadline
* passes, without being asked again — the fail-safe that makes an unattended
* world change defensible. Until a module could say so, core learned of it only
* through reconcile, which files it as `orphaned`: a thing that vanished while
* nobody was looking, amber on the console and still claimable for a revert.
* An expiry is the plan working, so it is terminal and green like `reverted`.
*
* `owner` is bound by the loader, never taken from the module's arguments, for
* `reconcile`'s reason: without the binding a module could close another
* module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`,
* because a module hears expiries of things no run ledgered too.
*/
async function expireResource(owner, { kind, ref } = {}) {
if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 }
if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 }
const rows = await resourcesDb.expirableByTarget(owner, kind, ref)
const runs = new Set()
let expired = 0
for (const row of rows) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
expired += 1
runs.add(row.run_id)
await logDb.write({
runId: row.run_id,
kind: 'resource.expired',
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
})
}
// A finished run whose last unresolved row this was has nothing left to clean
// up. The sweep would settle a `pending` one by itself, but not an
// `incomplete` one — that status takes a run out of the sweep's scan, and it
// would go on saying "not finished" about a ledger that is. A run still in
// flight is left alone: its own terminal path computes the status.
for (const runId of runs) {
const run = await runsDb.getById(runId)
if (!run || !runsDb.TERMINAL.includes(run.status)) continue
if ((await resourcesDb.unresolvedCount(runId)) === 0) {
await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete'])
}
}
return { expired }
}
module.exports = {
MAX_REVERT_ATTEMPTS,
classifyRevert,
@@ -434,4 +492,5 @@ module.exports = {
sweep,
reconcileModule,
reconcileAll,
expireResource,
}

View File

@@ -53,6 +53,9 @@ const KINDS = [
// question is about the world.
'resource.recorded', // a step reported what it created or borrowed, and it is ledgered
'resource.orphaned', // a module reports a ledgered resource is no longer in force
// MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at
// its own deadline, which is the plan working rather than something vanishing.
'resource.expired', // a module reports the game ended a ledgered resource by itself
'cleanup.reverted', // a group of resources came back
'cleanup.failed', // a group did not, with the reason and how it was left
'cleanup.swept', // one pass over a run's ledger, and what it found

View File

@@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) {
)
}
/**
* The rows one module holds for one target that the game may have let go of on
* its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0).
*
* `orphaned` is included on purpose: a reconcile that ran before the module heard
* the expiry filed the row as vanished, and the expiry is the better answer.
* `reverting` is not: a revert already in flight finds the thing gone, which §L
* calls a success, and it settles as `reverted` on its own.
*/
async function expirableByTarget(owner, kind, ref) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_resources
WHERE owner_module = ? AND kind = ? AND ref = ?
AND status IN ('pending', 'confirmed', 'orphaned')
ORDER BY id`,
[owner, kind, ref],
)
return rows.map(hydrate)
}
/**
* The game ended it by itself, as it was told to — a zone reaching its deadline.
* Terminal like `reverted`, so teardown never tries to give it back, and unlike
* `orphaned`, which is a thing that vanished with nobody asking. Guarded on the
* status so a row a revert has just claimed is left to that revert.
*/
async function markExpired(id, detail = null) {
const result = await query(
`UPDATE event_run_resources
SET status = 'expired', last_error = ?
WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`,
[detail === null ? null : String(detail).slice(0, 500), id],
)
return (result.affectedRows || 0) > 0
}
/**
* Terminal runs that still owe the world something — the cleanup leg's scan.
*
@@ -385,5 +421,7 @@ module.exports = {
liveForModule,
modulesWithLiveRows,
markOrphaned,
expirableByTarget,
markExpired,
runsNeedingCleanup,
}

View File

@@ -261,6 +261,27 @@ function buildCtx(id, moduleRoot) {
(err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) },
)
},
// MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this
// module's ledgered resources at its own deadline — a Rust zone erased when
// its time was up. Recorded as `expired`: terminal, never given back, and a
// different sentence on the console from `orphaned`, which is reconcile's
// word for a thing that vanished with nobody asking.
//
// `id` bound, fire-and-forget and returns undefined, all three for
// `reconcile`'s reasons directly above. A `{ kind, ref }` that names no
// live row is not an error: a module hears expiries of things no run ever
// ledgered, and telling it so would be noise it can do nothing with.
expired: (resource) => {
// eslint-disable-next-line global-require
require('../events/cleanup')
.expireResource(id, resource || {})
.then(
(summary) => {
if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary })
},
(err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) },
)
},
},
// The in-app sink (§5.1) — a module writing the inbox directly, without a
// rule. Live from Phase 7; it threw until the `user_notifications` table

View File

@@ -9,6 +9,18 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource
// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that
// ends something at its own deadline — a Rust zone erased when its time is up —
// could until now reach core only through `ctx.events.reconcile()`, which files
// it `orphaned`: amber, "it vanished", and still claimable for a revert. The new
// call files it as the plan working: terminal, green, never taken back.
//
// Additions only, so minor, and checked against the one module already built on
// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing
// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so
// the sweep, the manual retry and `cleanup_status` see exactly what they saw.
// 1.10.0 — the event contract opens to modules: `api.registerEventActions`,
// `api.registerEventBudgets`, `api.registerEventLeases` and
// `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md
@@ -155,6 +167,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.10.0'
const MODULE_API_VERSION = '1.11.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -237,7 +237,7 @@ eventsRouter.get(
'/runs/:runId',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */
/* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */