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:
@@ -1028,6 +1028,112 @@ test('classify: the two success shapes that are not "finished"', () => {
|
||||
assert.ok(classify({ ok: true, holdFor: 1e12 }, 'a').holdSeconds <= 7 * 24 * 60 * 60, 'holdFor is bounded')
|
||||
})
|
||||
|
||||
// ── A module's own account of a successful step (Phase 15) ────────────────
|
||||
//
|
||||
// The third thing a success envelope may carry, and the only one core does not
|
||||
// interpret. It exists because a module knows things about its own verb that core
|
||||
// cannot compute and had no other channel for: `uo.item.grant` reaches the players
|
||||
// a run's ledger holds, and WHICH OF THEM MISSED OUT is reported nowhere else —
|
||||
// so before this, an operator saw a step marked `done` and never learned that four
|
||||
// of twelve got nothing. `module-uo` had been answering `detail` since Phase 12b
|
||||
// on the strength of one sentence in EVENTS.md §H, and core had never read it.
|
||||
|
||||
test('classify: a success may carry a module detail, and it is never interpreted', () => {
|
||||
assert.equal(classify({ ok: true }, 'a').detail, null, 'absent is null, not undefined')
|
||||
assert.deepEqual(
|
||||
classify({ ok: true, detail: { granted: 8, missed: 4 } }, 'a').detail,
|
||||
{ granted: 8, missed: 4 },
|
||||
"the keys are the module own and core changes none of them",
|
||||
)
|
||||
// The other success shape. A cue's confirm finishes the step without a second
|
||||
// dispatch, so this is the only moment its module could ever have said anything.
|
||||
assert.deepEqual(
|
||||
classify({ ok: true, await: 'human', detail: { cued: 'britain' } }, 'a').detail,
|
||||
{ cued: 'britain' },
|
||||
)
|
||||
})
|
||||
|
||||
test('classify: a bad detail is dropped, and never fails the step', () => {
|
||||
// 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. Every one of these is `done` with a null detail.
|
||||
const dropped = [
|
||||
{ ok: true, detail: 'a string' },
|
||||
{ ok: true, detail: 42 },
|
||||
{ ok: true, detail: ['an', 'array'] },
|
||||
{ ok: true, detail: { big: 'x'.repeat(5000) } },
|
||||
]
|
||||
for (const envelope of dropped) {
|
||||
const verdict = classify(envelope, 'a')
|
||||
assert.equal(verdict.outcome, 'done', 'a bad detail must not change the outcome')
|
||||
assert.equal(verdict.detail, null)
|
||||
}
|
||||
|
||||
// A circular object throws inside JSON.stringify. Reaching the runner would
|
||||
// make the log INSERT throw instead, inside the one write documented never to.
|
||||
const circular = { ok: true, detail: {} }
|
||||
circular.detail.self = circular.detail
|
||||
assert.equal(classify(circular, 'a').outcome, 'done')
|
||||
assert.equal(classify(circular, 'a').detail, null)
|
||||
})
|
||||
|
||||
test("classify: the detail core carries is a copy, not the module object", () => {
|
||||
const live = { granted: 8 }
|
||||
const carried = classify({ ok: true, detail: live }, 'a').detail
|
||||
live.granted = 999
|
||||
assert.equal(carried.granted, 8, 'core must not hold a live reference into a module')
|
||||
})
|
||||
|
||||
test('a module detail reaches the run log as its own line', async () => {
|
||||
register([scriptedAction('test.grant')])
|
||||
scripted['test.grant'] = {
|
||||
calls: [],
|
||||
answer: { ok: true, detail: { granted: 8, missed: 4, why: ['bank full'] } },
|
||||
}
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.grant')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
|
||||
const line = store.log.find((l) => l.runId === id && l.kind === 'step.detail')
|
||||
assert.ok(line, 'the module said something and the run has no record of it')
|
||||
assert.equal(line.detail.action, 'test.grant', "core's own key leads the line")
|
||||
assert.equal(line.detail.granted, 8)
|
||||
assert.equal(line.detail.missed, 4)
|
||||
assert.deepEqual(line.detail.why, ['bank full'])
|
||||
assert.equal(line.stepId, stepsOf(id)[0].id)
|
||||
})
|
||||
|
||||
test('a step that says nothing writes no detail line', async () => {
|
||||
// Its own line rather than a field on `resource.recorded`, so a run whose steps
|
||||
// are all quiet must not gain a row per step saying so.
|
||||
register([scriptedAction('test.quiet')])
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.quiet')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'done')
|
||||
assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
|
||||
})
|
||||
|
||||
test('a failed step reports no detail, however much it says', async () => {
|
||||
// `detail` rides the SUCCESS shapes only. A failure's channel is `error`, and
|
||||
// an action that answered both would otherwise get two bites at the log for a
|
||||
// step that did not happen.
|
||||
register([scriptedAction('test.refuse')])
|
||||
scripted['test.refuse'] = {
|
||||
calls: [],
|
||||
answer: { ok: false, retry: false, error: 'no', detail: { tried: 3 } },
|
||||
}
|
||||
|
||||
const id = seedRun([{ key: 'main', label: 'Main', steps: [step('test.refuse', {}, 'skip')] }])
|
||||
await runner.tick(T0)
|
||||
|
||||
assert.equal(stepsOf(id)[0].status, 'failed')
|
||||
assert.equal(kinds(id).filter((k) => k === 'step.detail').length, 0)
|
||||
})
|
||||
|
||||
test('an action that throws is a transient failure, not a crashed tick', async () => {
|
||||
register([
|
||||
scriptedAction('test.thrower', {
|
||||
|
||||
Reference in New Issue
Block a user