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