Files
website/server/src/events/ledger.js
wtclaude 37f4623068
Some checks failed
PR Checks / client-build (pull_request) Successful in 40s
PR Checks / server-tests (pull_request) Failing after 5m46s
PR Checks / bot-tests (pull_request) Successful in 8m28s
feat(events): targeted leases, value sets and searchable sources (Phase 12b)
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
2026-09-07 08:06:37 -05:00

225 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 }