The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.
Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.
Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.
Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.
`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.
Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.
`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.
The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.
2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.
Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
// ── Recording what a run changed in the world ──────────────────────────────
|
||
//
|
||
// EVENTS.md §D and §L, and Phase 8 of EVENTS_PLAN.md. The write half of the
|
||
// resource ledger; `events/cleanup.js` is the read-and-undo half.
|
||
//
|
||
// **Rule 1 is the whole reason this file is not two lines inside `drainStep`.**
|
||
// A resource is recorded BEFORE it is confirmed. The obstacle is that a spawn's
|
||
// serial does not exist until the module answers, so there is nothing to write a
|
||
// row about yet — which is why what goes in before the dispatch is a PLACEHOLDER
|
||
// keyed by the step's idempotency key rather than by the object:
|
||
//
|
||
// pre-dispatch INSERT pending { kind: '@step', ref: <idempotency key> }
|
||
// answer INSERT confirmed { kind: 'creature', ref: '0x40001234' } × n
|
||
// resolve the placeholder
|
||
// ack lost the placeholder is still `pending`
|
||
// cleanup revert({ idempotencyKey, resources: [] })
|
||
//
|
||
// That last line is why §F's `revert({ runId, resources, idempotencyKey })` takes
|
||
// the key at all. A module that half-ran and never answered is reachable by its
|
||
// key and by nothing else, and Phase 11's plugin-side key ledger is what makes
|
||
// answering it exact. Until then the contract is still honest, because §L
|
||
// requires reverting something that does not exist to be a SUCCESS.
|
||
//
|
||
// **Recording is idempotent, and the database is what makes it so.** A retry
|
||
// re-dispatches the same idempotency key, and a module that answers with the same
|
||
// resources twice must not produce two rows. `uq_evres_target` refuses the second
|
||
// insert, and this file reads that refusal as "already recorded" rather than as an
|
||
// error — the same posture `materialisePhase`'s INSERT IGNORE takes.
|
||
//
|
||
// **A lease does not use the placeholder.** Its target is knowable before the
|
||
// dispatch — it is the lease id the step names — so `core.lease` reserves the
|
||
// real row first, which is both a stronger form of rule 1 and the only place the
|
||
// two-events-one-target refusal can happen before the world has been written to.
|
||
|
||
const resourcesDb = require('../model/events/eventRunResources.db')
|
||
const runsDb = require('../model/events/eventRuns.db')
|
||
const registries = require('../modules/registries')
|
||
const log = require('../utils/logger')('events')
|
||
|
||
// Which reversible classes get a pre-dispatch placeholder. `none` is gone once
|
||
// done and `self` undoes itself, so neither has anything core could come back
|
||
// for; `override` reserves its own target instead (see the header). That leaves
|
||
// `ledger` — the class that declares `revert()`, which is exactly the class whose
|
||
// refs core cannot know until the module speaks.
|
||
const PLACEHOLDER_CLASSES = ['ledger']
|
||
|
||
// A resource `kind` may be anything a module likes except core's own reserved
|
||
// one. Bounded to the column, and refused rather than truncated: a truncated ref
|
||
// is a cleanup call naming the wrong object.
|
||
const MAX_KIND = 64
|
||
const MAX_REF = 190
|
||
const MAX_MEMBER_KEY = 190
|
||
|
||
/** Does this action produce anything core will have to come back for? */
|
||
const ledgers = (action) => action && (action.reversible === 'ledger' || action.reversible === 'override')
|
||
|
||
/**
|
||
* Turn one entry of a module's `resources` array into a row, or say why not.
|
||
*
|
||
* Every failure here is the module's mistake rather than the world's, so none of
|
||
* them is a retry: a badly shaped resource will be just as badly shaped on the
|
||
* second attempt. They are logged and dropped, and the step still counts as done
|
||
* — because it IS done; something happened in the world, and refusing to record
|
||
* it would be the one outcome worse than recording it imperfectly.
|
||
*/
|
||
function normalise(entry, actionId) {
|
||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||
return { ok: false, reason: `${actionId} reported a resource that is not an object` }
|
||
}
|
||
const kind = String(entry.kind || '')
|
||
const ref = String(entry.ref === undefined || entry.ref === null ? '' : entry.ref)
|
||
if (!kind || kind.length > MAX_KIND) {
|
||
return { ok: false, reason: `${actionId} reported a resource with a bad kind "${entry.kind}"` }
|
||
}
|
||
if (kind === resourcesDb.STEP_KIND) {
|
||
// Core's own. A module that could write one would be a module that could make
|
||
// its own step's placeholder look resolved.
|
||
return { ok: false, reason: `${actionId} reported a resource of the reserved kind "${kind}"` }
|
||
}
|
||
if (!ref || ref.length > MAX_REF) {
|
||
return { ok: false, reason: `${actionId} reported a resource with a bad ref "${entry.ref}"` }
|
||
}
|
||
const memberKey = entry.memberKey === undefined || entry.memberKey === null ? null : String(entry.memberKey)
|
||
if (memberKey !== null && memberKey.length > MAX_MEMBER_KEY) {
|
||
return { ok: false, reason: `${actionId} reported a resource with an over-long memberKey` }
|
||
}
|
||
|
||
let leaseUntil = null
|
||
if (entry.until !== undefined && entry.until !== null) {
|
||
const at = new Date(entry.until)
|
||
if (Number.isNaN(at.getTime())) {
|
||
return { ok: false, reason: `${actionId} reported a resource with a bad until "${entry.until}"` }
|
||
}
|
||
leaseUntil = at
|
||
}
|
||
|
||
// A borrowed value must name a lease core knows how to give back. Core restores
|
||
// an `override` through the lease registry — that is the split §F draws — so a
|
||
// ref naming nothing registered is a resource core would be recording with no
|
||
// way to undo it, which is the promise rule 2 exists to stop core making.
|
||
// Through the ref parser: a targeted lease's ref carries its target after a
|
||
// `#` (Phase 12b), and matching the whole string against the registry would
|
||
// reject a lease that IS registered.
|
||
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
|
||
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
row: {
|
||
kind,
|
||
ref,
|
||
payload: entry.payload === undefined ? null : entry.payload,
|
||
leaseUntil,
|
||
memberKey,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write the pre-dispatch placeholder for a step that is about to change the
|
||
* world. Answers the row id, or null when this action ledgers nothing.
|
||
*
|
||
* **A duplicate is not a failure.** A retry re-uses its step's idempotency key, so
|
||
* the second attempt's placeholder collides with the first's — and finding it
|
||
* already there is the correct answer, not an error. The existing row is reused.
|
||
*/
|
||
async function reserveStep(run, step, action) {
|
||
if (!ledgers(action) || !PLACEHOLDER_CLASSES.includes(action.reversible)) return null
|
||
|
||
const owner = action.owner || 'core'
|
||
const reserved = await resourcesDb.reserve({
|
||
runId: run.id,
|
||
stepId: step.id,
|
||
owner,
|
||
kind: resourcesDb.STEP_KIND,
|
||
ref: step.idempotency_key,
|
||
payload: { action: action.id, phase: step.phase, seq: step.seq },
|
||
})
|
||
if (reserved.ok) {
|
||
await markRunDirty(run.id)
|
||
return reserved.id
|
||
}
|
||
// The only way a '@step' row collides is with this step's own earlier attempt,
|
||
// because an idempotency key is minted once per step and never varies by
|
||
// attempt (§E). Reuse it.
|
||
const existing = await resourcesDb.findByTarget(owner, resourcesDb.STEP_KIND, step.idempotency_key)
|
||
return existing ? existing.id : null
|
||
}
|
||
|
||
/**
|
||
* Record what a module said it made, and close out the placeholder.
|
||
*
|
||
* Answers `{ recorded, rejected }` — how many rows went in, and the reasons any
|
||
* entry was dropped. Never throws: a step that changed the world has changed it,
|
||
* and a ledger that threw would turn a bookkeeping problem into a failed step and
|
||
* then into a retry of a world write that already happened.
|
||
*/
|
||
async function recordAnswer({ run, step, action, placeholderId, resources }) {
|
||
const out = { recorded: 0, rejected: [] }
|
||
if (!ledgers(action)) return out
|
||
|
||
const owner = action.owner || 'core'
|
||
const list = Array.isArray(resources) ? resources : []
|
||
|
||
for (const entry of list) {
|
||
const parsed = normalise(entry, action.id)
|
||
if (!parsed.ok) {
|
||
out.rejected.push(parsed.reason)
|
||
log.warn('event resource rejected', { run: run.id, step: step.id, reason: parsed.reason })
|
||
continue
|
||
}
|
||
try {
|
||
const reserved = await resourcesDb.reserve({
|
||
runId: run.id,
|
||
stepId: step.id,
|
||
owner,
|
||
kind: parsed.row.kind,
|
||
ref: parsed.row.ref,
|
||
payload: parsed.row.payload,
|
||
leaseUntil: parsed.row.leaseUntil,
|
||
memberKey: parsed.row.memberKey,
|
||
})
|
||
if (!reserved.ok) {
|
||
// Already ledgered — by this step's own earlier attempt, or (a module bug
|
||
// rather than a race) by another run that still holds the same target.
|
||
// Either way there is a live row for it and a second would be the double
|
||
// cleanup the unique key exists to prevent.
|
||
if (reserved.holder && reserved.holder.run_id !== run.id) {
|
||
out.rejected.push(`${parsed.row.kind} "${parsed.row.ref}" is already held by run ${reserved.holder.run_id}`)
|
||
}
|
||
continue
|
||
}
|
||
await resourcesDb.confirm(reserved.id)
|
||
out.recorded += 1
|
||
} catch (err) {
|
||
// Bookkeeping must not become the step's control flow.
|
||
out.rejected.push(err.message)
|
||
log.error('event resource insert failed', { run: run.id, step: step.id, message: err.message })
|
||
}
|
||
}
|
||
|
||
if (out.recorded > 0) await markRunDirty(run.id)
|
||
|
||
// The placeholder's job is over the moment the real rows exist. It is resolved
|
||
// even when the module reported nothing at all — an action that ledgers and
|
||
// then answers `ok` with an empty list is saying "I made nothing", and holding
|
||
// its placeholder open would make cleanup call `revert()` for a step that has
|
||
// nothing to give back on every terminal path for ever.
|
||
if (placeholderId) await resourcesDb.resolvePlaceholder(placeholderId)
|
||
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* There is now something to clean up. Idempotent and guarded, so it can never
|
||
* walk a run back from `complete` or `incomplete` to `pending` — only a human's
|
||
* cleanup does that, and it does it deliberately.
|
||
*/
|
||
async function markRunDirty(runId) {
|
||
await runsDb.setCleanupStatus(runId, 'pending', ['not_required'])
|
||
}
|
||
|
||
module.exports = { ledgers, normalise, reserveStep, recordAnswer, markRunDirty, PLACEHOLDER_CLASSES }
|