fix(events): carry a module's own account of a successful step #197

Merged
whitlocktech merged 1 commits from fix/events-module-detail into edge 2026-09-09 00:22:39 +00:00
Member

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:

Action Answers Reached
uo.item.grant { granted, missed, why } nothing
uo.world.save { started: true } nothing

The grant is the one that mattered. A grant reaches the players a run's participation ledger holds, and which of them missed out is knowable only to the module — 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. Fixed here by making the member real rather than by deleting the reporting, because §H's sentence was right and only its example was wrong.

What this adds

detail becomes an 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 — not in the dispatcher, not in the runner, not 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 to put it beneath would be core interpreting it after all.
  • 4KB of serialised JSON, dropped rather than truncated. Half a JSON object is not a JSON object, and a console rendering one would be a second bug on top of the first.
  • Unserialisable is dropped (circular, a throwing toJSON). Reaching the runner would make the log INSERT throw instead — 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. Same posture participants takes.

The runner writes a step.detail run-log row — its own kind rather than a field on resource.recorded, because the grant that forced this ledgers nothing (reversible: 'none') and reports no participants, so it would have had nowhere to ride.

The renderer is half the fix

describeLogLine's default returns a kind word, so a step.detail row falling through to it would have rendered as the literal string step.detail — the channel existing and showing nothing, which is 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, which is the thing the module system exists to prevent.

uo.item.grant — granted: 8, missed: 4, why: bank full, offline
uo.world.save — started: true

Lists are capped at five with "and N more" and values at 80 characters, so one row stays one row.

module-uo needs no change

The code it already shipped starts working, unchanged. Verified against the real classifier:

grant, as module-uo answers it     → done      {"granted":8,"missed":4,"why":["bank full"]}
world save                         → done      {"started":true}
a cue that reports                 → parked    {"cued":"Britain"}
a string detail (dropped)          → done      null
oversize (dropped)                 → done      null
a failure carries none             → terminal  null

The failure channel is unchanged and is still error alone.

Checks

  • 2057 server tests pass, 0 fail (89 skipped are the live-DB suites); 400 client tests pass
  • npm run check:modules — core names no module identifier
  • Zero-line route manifest diff; no route added, so no swagger change
  • MODULE_API_VERSION stays 1.10.0, amended in place — still on edge

Pairs with docs#226.

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

`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: | Action | Answers | Reached | | --- | --- | --- | | `uo.item.grant` | `{ granted, missed, why }` | nothing | | `uo.world.save` | `{ started: true }` | nothing | **The grant is the one that mattered.** A grant reaches the players a run's participation ledger holds, and *which of them missed out* is knowable only to the module — 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. Fixed here **by making the member real rather than by deleting the reporting**, because §H's sentence was right and only its example was wrong. ## What this adds `detail` becomes an 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 — not in the dispatcher, not in the runner, not 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 to put it beneath would be core interpreting it after all. - **4KB of serialised JSON, dropped rather than truncated.** Half a JSON object is not a JSON object, and a console rendering one would be a second bug on top of the first. - **Unserialisable is dropped** (circular, a throwing `toJSON`). Reaching the runner would make the log `INSERT` throw instead — 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. Same posture `participants` takes. The runner writes a `step.detail` run-log row — **its own kind** rather than a field on `resource.recorded`, because the grant that forced this ledgers nothing (`reversible: 'none'`) and reports no participants, so it would have had nowhere to ride. ## The renderer is half the fix `describeLogLine`'s default returns a kind **word**, so a `step.detail` row falling through to it would have rendered as the literal string `step.detail` — the channel existing and showing nothing, which is 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, which is the thing the module system exists to prevent. ``` uo.item.grant — granted: 8, missed: 4, why: bank full, offline uo.world.save — started: true ``` Lists are capped at five with "and N more" and values at 80 characters, so one row stays one row. ## `module-uo` needs no change The code it already shipped starts working, unchanged. Verified against the real classifier: ``` grant, as module-uo answers it → done {"granted":8,"missed":4,"why":["bank full"]} world save → done {"started":true} a cue that reports → parked {"cued":"Britain"} a string detail (dropped) → done null oversize (dropped) → done null a failure carries none → terminal null ``` The **failure** channel is unchanged and is still `error` alone. ## Checks - 2057 server tests pass, 0 fail (89 skipped are the live-DB suites); 400 client tests pass - `npm run check:modules` — core names no module identifier - Zero-line route manifest diff; no route added, so no swagger change - `MODULE_API_VERSION` stays **1.10.0, amended in place** — still on `edge` Pairs with **docs#226**. - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-08 23:50:51 +00:00
fix(events): carry a module's own account of a successful step
Some checks failed
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 8m57s
e46842a28c
`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
whitlocktech merged commit af9f4e191c into edge 2026-09-09 00:22:39 +00:00
whitlocktech deleted branch fix/events-module-detail 2026-09-09 00:22:40 +00:00
Sign in to join this conversation.
No description provided.