fix(events): carry a module's own account of a successful step
`EVENTS.md` §H told a module the revert contract accepts a `detail` on its
envelope. `classify()` reads `ok`, `retry`, `error`, `await`, `holdFor`,
`resources` and `participants` — and has never read a `detail`. So a module
that answered one was writing into nothing.
`module-uo` believed it, twice, since Phase 12b:
* `uo.item.grant` answers `{ granted, missed, why }`
* `uo.world.save` answers `{ started: true }`
The grant is the one that matters. A grant reaches the players a run's
participation ledger holds, and **which of them missed out is knowable only to
the module and reported nowhere else** — so an operator saw a step marked
`done` and never learned four of twelve got nothing.
Found writing the integration kit's chapter 5 (`Integration-kit#10`), whose
template made the same mistake on §H's authority.
## What this adds
`detail` becomes a real, optional member of the two SUCCESS envelopes, beside
`resources` and `participants` — on both, because `await: 'human'` is a success
and a cue's confirm finishes the step without a second dispatch, so that is the
only moment its module could ever have said anything.
**Core never interprets it.** `safeDetail()` bounds it and nothing else reads a
key out of it, here or in the runner or in the browser. That is the point: a
module knows things about its own verb core cannot compute, and it had no other
way to say them.
* objects only — the column is JSON and the console renders keys, so a bare
string has nothing to render under, and core inventing a key would be core
interpreting it after all;
* 4KB of serialised JSON, dropped rather than truncated, because half a JSON
object is not a JSON object;
* unserialisable (circular, a throwing `toJSON`) is dropped — reaching the
runner would make the log INSERT throw, inside the one write documented
never to;
* re-parsed rather than passed through, so core holds no live reference into
a module's object;
* **anything wrong with it is dropped and logged, never a failure.** A step
that did what it was asked must not be re-run because its module's
commentary was malformed: that is a world write repeated for a log line.
The runner writes it as a `step.detail` run-log row, its own kind rather than a
field on `resource.recorded` — the grant that forced this ledgers nothing
(`reversible: 'none'`) and reports no participants, so it would have had
nowhere to ride.
## The renderer, which is half the fix
`describeLogLine`'s default returns a kind WORD, so a `step.detail` row falling
through would have rendered as the literal string "step.detail" — the channel
existing and showing nothing, exactly the failure being fixed. It gets a case
that renders whatever keys the module put there, generically: a switch on known
keys would be the browser learning one module's vocabulary.
uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true
**`module-uo` needs no change**: the code it already shipped starts working.
MODULE_API stays 1.10.0, amended in place — it is still on `edge`. Zero-line
route manifest diff; no route added. 2057 server tests, 400 client tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -36,6 +36,71 @@ const OUTCOMES = ['done', 'parked', 'retry', 'terminal']
|
||||
// worked.
|
||||
const MAX_HOLD_SECONDS = 7 * 24 * 60 * 60
|
||||
|
||||
// The upper bound on a module's `detail`, in bytes of serialised JSON. It lands
|
||||
// in `event_run_log.detail` and is read back by the run console, so it is a
|
||||
// diagnostic line rather than a data channel — a module with more to say than
|
||||
// this has a table of its own to say it in. Dropped rather than truncated when it
|
||||
// is over: a truncated JSON object is not a JSON object, and a console that
|
||||
// rendered half of one would be a second bug on top of the first.
|
||||
const MAX_DETAIL_BYTES = 4096
|
||||
|
||||
/**
|
||||
* A module's own account of what a successful step actually did.
|
||||
*
|
||||
* Optional, module-opaque, and **never interpreted by core** — it is carried to
|
||||
* the run log and rendered, and nothing here or in the runner reads a key out of
|
||||
* it. That is the whole contract: a module knows things about its own verb that
|
||||
* core cannot compute and has no other way to say. `uo.item.grant` is the case
|
||||
* that forced it — a grant reaches the players a run's ledger holds, and *which
|
||||
* of them missed out* is knowable only to the module and reported nowhere else,
|
||||
* so an operator saw a step marked `done` and never learned four of twelve got
|
||||
* nothing.
|
||||
*
|
||||
* **Anything wrong with it is dropped and logged, never a failure.** A step that
|
||||
* did what it was asked must not be re-run because its module's commentary was
|
||||
* malformed — that would be a world write repeated for a log line. Same posture
|
||||
* `participants` takes, and for the same reason.
|
||||
*/
|
||||
function safeDetail(detail, actionId) {
|
||||
if (detail === undefined || detail === null) return null
|
||||
|
||||
// Objects only. The column is JSON and the console renders keys, so a bare
|
||||
// string or a number has nothing to render under — and core inventing a key to
|
||||
// put it beneath would be core interpreting it after all.
|
||||
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
||||
log.warn('action detail is not an object', { action: actionId, type: typeof detail })
|
||||
return null
|
||||
}
|
||||
|
||||
let encoded
|
||||
try {
|
||||
encoded = JSON.stringify(detail)
|
||||
} catch (err) {
|
||||
// A circular reference, or a `toJSON` that throws. Reaching the runner would
|
||||
// make the INSERT throw instead, inside the one write that is documented
|
||||
// never to.
|
||||
log.warn('action detail could not be serialised', { action: actionId, message: err.message })
|
||||
return null
|
||||
}
|
||||
if (encoded === undefined) {
|
||||
log.warn('action detail serialised to nothing', { action: actionId })
|
||||
return null
|
||||
}
|
||||
if (Buffer.byteLength(encoded, 'utf8') > MAX_DETAIL_BYTES) {
|
||||
log.warn('action detail is too large', {
|
||||
action: actionId,
|
||||
bytes: Buffer.byteLength(encoded, 'utf8'),
|
||||
max: MAX_DETAIL_BYTES,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-parsed rather than passed through, so what the runner writes is a plain
|
||||
// JSON value with no getters, no prototype and no live reference into whatever
|
||||
// the module still holds.
|
||||
return JSON.parse(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn()` under a deadline.
|
||||
*
|
||||
@@ -103,6 +168,7 @@ function classify(result, actionId) {
|
||||
error: null,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +191,11 @@ function classify(result, actionId) {
|
||||
holdSeconds,
|
||||
resources: result.resources || [],
|
||||
participants: result.participants || [],
|
||||
// On the same two success shapes as `resources` and `participants`, and for
|
||||
// the same reason: `await: 'human'` is a success, and a cue's confirm
|
||||
// finishes the step without a second dispatch, so this is the only moment
|
||||
// its module could ever have said anything.
|
||||
detail: safeDetail(result.detail, actionId),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,4 +250,4 @@ async function dispatchStep(step, { run, actor = null, verify = false } = {}) {
|
||||
return classification
|
||||
}
|
||||
|
||||
module.exports = { dispatchStep, classify, withDeadline, OUTCOMES, MAX_HOLD_SECONDS }
|
||||
module.exports = { dispatchStep, classify, withDeadline, safeDetail, OUTCOMES, MAX_HOLD_SECONDS, MAX_DETAIL_BYTES }
|
||||
|
||||
@@ -65,6 +65,12 @@ const KINDS = [
|
||||
'results.published', // the results table was ranked and stamped
|
||||
'announcement.emitted', // a lifecycle trigger fired, with its id and ceiling
|
||||
'announcement.enqueued', // a post was linked to this run and queued on the legs
|
||||
// Phase 15's one, and it is the only kind whose payload core does not compose.
|
||||
// A module may answer a success envelope with a `detail` object; it is bounded
|
||||
// and sanitised at the dispatcher and written here verbatim beside the action
|
||||
// id. Nothing reads a key out of it — it exists because a module knows things
|
||||
// about its own verb that core cannot compute and had no other way to say.
|
||||
'step.detail', // a module's own account of what a successful step did
|
||||
]
|
||||
|
||||
const hydrate = (row) => row && { ...row, detail: parseJson(row.detail, null) }
|
||||
|
||||
@@ -361,6 +361,27 @@ async function drainStep(run, step, now, carry = {}) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// **What the module has to say about it**, which is the third thing a
|
||||
// success envelope can carry and the only one core does not interpret.
|
||||
// `resources` is what to undo and `participants` is who took part; this is
|
||||
// everything else the module knows and core cannot compute — how many of a
|
||||
// run's players a grant actually reached, whether a save had already started.
|
||||
// Bounded and sanitised in `dispatch.js`; by here it is a plain JSON object
|
||||
// or null.
|
||||
//
|
||||
// Its own line rather than a field on one of the two above, because a step
|
||||
// very often has this and neither of those — the grant that forced it
|
||||
// ledgers nothing (`reversible: 'none'`) and reports no participants.
|
||||
if (result.detail) {
|
||||
await logDb.write({
|
||||
runId: run.id,
|
||||
stepId: step.id,
|
||||
kind: 'step.detail',
|
||||
phase: step.phase,
|
||||
detail: { action: step.action_id, ...result.detail },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (result.outcome === 'parked') {
|
||||
|
||||
Reference in New Issue
Block a user