fix(core): the last fixes' core half (W1 W2 W5 W19)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 49s
PR Checks / client-build (pull_request) Successful in 1m4s
PR Checks / server-tests (pull_request) Successful in 6m6s

Stage 4 of docs/modules/rust/PLAN_LAST_FIXES.md, with the org lead's
2026-10-22 decisions.

W19: one module per site at boot, as at install. The first module
directory by name holds the site, whether or not it then loads; every
other is recorded startup_failed at the manifest stage, before a line of
its code is required, with a reason naming the module that holds the
site. Module-uo and Module-Rust side by side used to fail the second
with a slot collision. Loader, lifecycle, schema and event-contract
tests that loaded several fixture modules now load each case alone.

W1: a reconcile files a resource the game no longer has, and that is
past its `until`, as expired rather than orphaned, with the same log
line expired() writes. No MODULE_API bump: `until` and reconcile's
answer are unchanged.

W5: a dry run that passes on the draft is kept with the fingerprint of
the validated spec (three nullable columns on event_definitions).
Publish stamps the new version verified, with that dry run's reviewer
and time, when the spec it freezes has the same fingerprint, and writes
version.verified on the occurrences it re-pins. Verify answers
draftRecorded and publish answers verified; OpenAPI regenerated.

W2: the run console says "Phase p1: steps done, waiting to advance" and
"Why this phase has not advanced", not "has not started".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-22 00:38:34 -05:00
parent 9a1654fc54
commit f40dd765c3
18 changed files with 632 additions and 210 deletions

View File

@@ -357,13 +357,16 @@ async function sweep() {
* Reverting it would be core recording that it put something back when what
* actually happened is that the thing vanished while nobody was looking, and the
* two are different sentences to the operator reading the console afterwards.
* One gone after its `until` is neither: the game ended it on time, so it is
* `expired`, the line `expired()` would have written (W1).
*
* A module with no `reconcile()` on the action is not broken: core keeps
* believing its own ledger, which is precisely the behaviour before this phase.
*/
async function reconcileModule(owner) {
async function reconcileModule(owner, now = new Date()) {
const rows = await resourcesDb.liveForModule(owner)
const summary = { asked: 0, inForce: 0, orphaned: 0, unanswered: 0 }
const summary = { asked: 0, inForce: 0, orphaned: 0, expired: 0, unanswered: 0 }
const settled = new Set()
if (!rows.length) return summary
const byStep = new Map()
@@ -411,6 +414,19 @@ async function reconcileModule(owner) {
summary.inForce += 1
continue
}
// **Gone after its deadline is the deadline working** (W1, Rust
// PLAN_LAST_FIXES). A resource that carries `until` is one the game ends
// by itself, and on a reconnect the module's reconcile can run before the
// game's backlog says so: the run log read `resource.orphaned` five seconds
// before `resource.expired`. Past its `until`, gone is expired. Before it,
// gone is still nobody-was-looking, which is what orphaned means.
if (row.lease_until && new Date(row.lease_until).getTime() <= now.getTime()) {
if (await settleExpired(owner, row)) {
summary.expired += 1
settled.add(row.run_id)
}
continue
}
await resourcesDb.markOrphaned(row.id, 'the module reports this is no longer in force')
summary.orphaned += 1
await logDb.write({
@@ -420,9 +436,42 @@ async function reconcileModule(owner) {
})
}
}
await closeSettledRuns(settled)
return summary
}
/**
* One row the game ended at its deadline: `expired`, and its log line. The same
* line whichever way core learned it, from the module's `expired()` or from a
* reconcile that found it gone past its `until`, so the console reads one way.
*/
async function settleExpired(owner, row) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) return false
await logDb.write({
runId: row.run_id,
kind: 'resource.expired',
detail: { module: owner, resource: `${row.kind}:${row.ref}` },
})
return true
}
/**
* A finished run whose last unresolved row just expired 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.
*/
async function closeSettledRuns(runIds) {
for (const runId of runIds) {
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'])
}
}
}
/** Ask every module that owns a live row. Core's own boot-time sweep. */
async function reconcileAll() {
const owners = await resourcesDb.modulesWithLiveRows()
@@ -469,28 +518,11 @@ async function expireResource(owner, { kind, ref } = {}) {
let expired = 0
for (const row of rows) {
if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue
if (!(await settleExpired(owner, row))) 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'])
}
}
await closeSettledRuns(runs)
return { expired }
}

View File

@@ -29,6 +29,8 @@
// DST gap shifts it, it is never rejected), so a schedule that validates here
// computes there.
const crypto = require('crypto')
const registries = require('../modules/registries')
// For `priceOf` and `undeclaredDimensions` only — the save-time half of §F's
// fail-closed budget rule (Phase 7). Nothing here reaches the database:
@@ -730,6 +732,31 @@ function publishable(spec) {
return dormant.length ? { ok: false, dormant: [...new Set(dormant)] } : { ok: true, dormant: [] }
}
/** JSON with every object's keys sorted, so equal specs serialise equally. */
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
if (value && typeof value === 'object') {
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`
}
return JSON.stringify(value) ?? 'null'
}
/**
* A spec's fingerprint: the sha256 of its canonical JSON (W5, Rust
* PLAN_LAST_FIXES).
*
* What lets a dry run that passed on a draft count for the version published
* from it. The draft is fingerprinted when the dry run passes and the version
* when it is published, both from `validate`'s output, so an edit in between is
* a different fingerprint and nothing carries over. Key order is not part of it:
* a spec that round-trips through the database with its keys reordered is the
* same plan.
*/
function fingerprint(spec) {
return crypto.createHash('sha256').update(canonical(spec)).digest('hex')
}
/** An empty, valid spec — what a newly created draft carries. */
const emptySpec = () => ({
schedule: { kind: 'manual' },
@@ -742,6 +769,7 @@ module.exports = {
// 1.13.0): eventDefinitions.model `buildFromTemplate`.
checkParams,
publishable,
fingerprint,
actionIdsIn,
emptySpec,
defaultOnFailure,

View File

@@ -126,6 +126,18 @@ const markReady = (id, versionId, userId) =>
[versionId, userId, id],
)
/**
* Record a dry run that passed on the draft, with the fingerprint of the spec it
* passed on (W5). Publish reads it back through `getById`'s `d.*`.
*/
const markDraftVerified = (id, { at = new Date(), userId = null, hash }) =>
query(
`UPDATE event_definitions
SET draft_verified_at = ?, draft_verified_by = ?, draft_verified_hash = ?
WHERE id = ?`,
[at, userId, hash, id],
)
/**
* Every definition the runner should expand a recurrence for (Phase 4).
*
@@ -234,5 +246,6 @@ module.exports = {
insert,
update,
markReady,
markDraftVerified,
archive,
}

View File

@@ -249,6 +249,16 @@ async function publish(id, userId) {
const versionId = await versionsDb.insert(id, version, checked.spec, userId)
await db.markReady(id, versionId, userId)
// A dry run that passed on this exact draft counts for the version made from
// it (W5), stamped with that pass's reviewer and time rather than the
// publisher's and now: "who last looked at this before it ran unattended" is
// the person who ran the check. Any edit since is a different fingerprint.
const verified = Boolean(
existing.draft_verified_hash &&
existing.draft_verified_hash === spec.fingerprint(checked.spec) &&
(await versionsDb.markVerified(versionId, existing.draft_verified_by || null, existing.draft_verified_at || new Date())),
)
// Occurrences already materialised ahead of their instant move to the new
// version; ones that have begun do not (org lead, 2026-09-02). Logged per run
// rather than only counted, because "which version did this run actually use"
@@ -270,9 +280,18 @@ async function publish(id, userId) {
by: userId,
},
})
// The held occurrence is now going to start, and the line saying why
// belongs on it, as it does when the version is dry-run by hand.
if (verified) {
await logDb.write({
runId: run.id,
kind: 'version.verified',
detail: { versionId, version, by: existing.draft_verified_by || null, fromDraft: true },
})
}
}
return { ok: true, versionId, version, repinned, definition: await db.getById(id) }
return { ok: true, versionId, version, repinned, verified, definition: await db.getById(id) }
}
/**
@@ -373,6 +392,19 @@ async function verify(id, user) {
const report = await verifier.verifySpec(target, { user })
// A pass on the draft is kept with the fingerprint of the spec it passed on,
// for publish to compare (W5). Fingerprinted from `validate`'s output, as
// publish fingerprints the spec it freezes; a draft that no longer validates
// could not be published anyway, so there is nothing to keep.
let draftRecorded = false
if (!version && report.ok) {
const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec) })
if (checked.ok) {
await db.markDraftVerified(id, { userId: user?.id || null, hash: spec.fingerprint(checked.spec) })
draftRecorded = true
}
}
if (version && report.ok) {
await versionsDb.markVerified(version.id, user?.id || null)
// Written to every run already pinned to this version, because that is where
@@ -397,6 +429,9 @@ async function verify(id, user) {
versionId: version?.id || null,
version: version?.version || null,
recorded: Boolean(version && report.ok),
// A pass on the draft, kept so that publishing this spec unchanged makes a
// verified version (W5).
draftRecorded,
}
}

View File

@@ -813,10 +813,26 @@ function load(tierRouters) {
return // no modules directory is the normal case for a bare core
}
// One module per site, at boot as at install (`install.js`, org lead
// 2026-09-23; W19 in Rust PLAN_LAST_FIXES, 2026-10-22). Install already
// refuses a second module, so two on the volume means someone copied one in by
// hand. The contract's singletons assume one: Module-uo and Module-Rust both
// fill `admin.users.detail`, and the second used to fail at register with a
// slot collision that named neither the rule nor the fix. Now the first in
// scan order is the site's module, whether or not it then loads, and every
// other is refused before a line of its code is required.
let holder = null
for (const id of entries) {
const dir = path.join(MODULES_DIR, id)
if (!fs.existsSync(path.join(dir, 'module.json'))) continue
if (holder) {
refuseSecond(id, dir, holder)
continue
}
holder = id
const record = {
id,
dir,
@@ -886,8 +902,10 @@ function load(tierRouters) {
for (const record of modules.values()) {
if (record.state !== 'registered') continue
try {
// Commit what this module staged. Collisions with core or with an earlier
// module surface here, in scan order, and cost only this module.
// Commit what this module staged. Collisions with core surface here and
// cost only this module. (An earlier module is no longer possible — one
// module per site, above — and the module-against-module checks stay as
// the second line behind that rule.)
registries.apply(record.staged.staged)
} catch (err) {
record.state = 'startup_failed'
@@ -902,6 +920,45 @@ function load(tierRouters) {
}
}
/**
* Record a module refused because another already holds the site (W19).
*
* `startup_failed` at the `manifest` stage, like any module that never reached
* register: no routes, no nav, no tables, and Admin → Modules says why. The
* name and version are read for that panel and nothing else; a module.json that does not
* parse is still refused for the same reason, since the reason is not its
* contents.
*/
function refuseSecond(id, dir, holder) {
const manifest = { id, version: 'unknown' }
try {
const raw = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'))
if (raw && typeof raw.version === 'string') manifest.version = raw.version
if (raw && typeof raw.name === 'string') manifest.name = raw.name
} catch {
// the panel shows "unknown"; the refusal stands either way
}
const reason =
`this site already runs the module "${holder}", and a site runs one module. ` +
`Remove "${id}" or "${holder}" from the modules directory; the one that sorts first by name is the one that starts.`
modules.set(id, {
id,
dir,
manifest,
routes: { public: new Map(), admin: new Map(), player: new Map() },
staged: null,
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
client: null,
ctx: null,
state: 'startup_failed',
stage: 'manifest',
reason,
})
log.error(`module "${id}" was not started — continuing without it`, { stage: 'manifest', reason })
}
/**
* Mount one module's routers onto the tier routers, behind the dispatch guard.
*

View File

@@ -445,7 +445,13 @@ exports.publish = async (req, res) => {
await activity.log({
req,
action: 'event.definition.published',
detail: { id, version: result.version, versionId: result.versionId, repinned: result.repinned },
detail: {
id,
version: result.version,
versionId: result.versionId,
repinned: result.repinned,
verified: result.verified,
},
})
return res.json({
event: await shapeEditable(result.definition),
@@ -455,6 +461,9 @@ exports.publish = async (req, res) => {
// screen says so, because "my fix did not reach next Friday" is otherwise
// found out on Friday.
repinned: result.repinned,
// Whether a dry run that passed on this exact draft made the version
// verified at birth (W5), so the screen can say it needs no second one.
verified: result.verified,
})
}
@@ -492,6 +501,7 @@ exports.verify = async (req, res) => {
versionId: result.versionId,
version: result.version,
recorded: result.recorded,
draftRecorded: result.draftRecorded,
report: result.report,
})
}

View File

@@ -475,9 +475,9 @@ eventsRouter.post(
'/:id/verify',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Dry run: dispatch every step with verify true, change nothing, and report the cost against the caps'
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
// #swagger.description = 'EVENTS.md I. admin AND editor rather than admin, deliberately: a dry run dispatches nothing, and the author who wrote the definition is exactly who should be able to price it before asking an admin to publish it. What is verified follows the state -- a ready definition is checked against its PUBLISHED version, which is the only thing that ever actually runs, and a draft against the working spec the author is still holding; `target` says which. A pass against a version is RECORDED on it, and that is EVENTS.md K last bound: a scheduled occurrence of a version that has never been verified is held rather than started unattended. A pass against a draft is kept with the fingerprint of the spec it passed on (`draftRecorded`), and publishing that spec unchanged makes a version that is verified from the start; an edit in between changes the fingerprint, and nothing carries over. Findings come back with a 200 -- the request succeeded, the plan has problems -- and the whole-plan cost check is the one thing no other path makes: three steps each spawning 15 under a cap of 30 pass every individual check and breach it on the third, at two in the morning, with the world half-changed.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
/* #swagger.responses[200] = { description: 'The report: findings per step, and the total cost per budget dimension', content: { "application/json": { schema: { type: "object", properties: { target: { type: "string", example: "version" }, versionId: { type: "integer" }, version: { type: "integer" }, recorded: { type: "boolean" }, draftRecorded: { type: "boolean" }, report: { type: "object", properties: { ok: { type: "boolean" }, steps: { type: "integer" }, findings: { type: "array", items: { type: "object", additionalProperties: true } }, cost: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } } } */
/* #swagger.responses[404] = { description: 'No such definition', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'The definition is archived, or has no phases to verify', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin or editor', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
@@ -489,9 +489,9 @@ eventsRouter.post(
'/:id/publish',
// #swagger.tags = ['Admin · Events']
// #swagger.summary = 'Snapshot the working spec into an immutable version and mark the definition ready'
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned.'
// #swagger.description = 'Admin only, deliberately, and not the same gate as the live run controls: publishing commits a definition that a schedule will later start unattended. The spec is re-validated against the registries as they stand right now rather than trusted from the save that wrote it, so a module uninstalled in between blocks the publish instead of producing a run that fails at dispatch. Publishing also RE-PINS every occurrence of this definition that is still scheduled and has not started, and `repinned` says how many moved: occurrences are materialised a fortnight ahead, so without this an edit would reach none of the runs already on the calendar. A run that has begun keeps the version it pinned. `verified` is true when a dry run that passed on this exact draft made the new version verified, with that dry run reviewer and time, so it needs no second dry run before it starts unattended.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" } } } } } } */
/* #swagger.responses[200] = { description: 'The definition, now ready, the version that was cut, and how many scheduled occurrences moved to it', content: { "application/json": { schema: { type: "object", properties: { event: { type: "object", additionalProperties: true }, version: { type: "integer" }, versionId: { type: "integer" }, repinned: { type: "integer" }, verified: { type: "boolean" } } } } } } */
/* #swagger.responses[400] = { description: 'The spec is invalid, or no phase has any steps', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[409] = { description: 'A step names an action no module registers, or the definition is archived', content: { "application/json": { schema: { type: "object", properties: { errors: { type: "array", items: { type: "string" } } } } } } } */
/* #swagger.responses[403] = { description: 'Not an admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */