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

@@ -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.
*