docs(modules): second contract pass over MODULE_API.md — restores a dropped reconcile, reprices R9 #250

Merged
whitlocktech merged 1 commits from docs/rust-plan-contract-pass2 into main 2026-09-15 17:49:31 +00:00
Member

What & why

Follow-up to #249. §7 of the plan audited it against the Integration Kit and the template; this pass reads MODULE_API.md itself, enumerating every member rather than grepping for registration names.

It found four things. Adds §11 and wires the consequences into phases 1, 13 and 14.

1. A regression: reconcile had been dropped

ctx.events.reconcile() and an action's reconcile() appear nowhere in the merged plan. The twelve-phase first draft had them — "phase 6: reconcile, and the boot-id watch" — and the rewrite to twenty phases lost them. That is a regression in the plan, not a decision.

It matters more for Rust than for the game the contract was written against. The kit rates reconcile the one omission that is "merely a lower standard rather than a broken promise" — but that assumes a world which persists. Rust wipes monthly and a wipe invalidates every ledgered resource for that server at once, and core cannot tell a wedged sidecar from a game that rebooted and lost everything: it sees { ok: false, retry: true } either way. It asks once at its own boot and otherwise waits to be told. ctx.events.reconcile() is being told, and its trigger is a watch on the game's boot id changing — which is also the only way to tell a game restart from a sidecar reconnect.

Restored to phase 13.

2. R9 was mispriced — the visibility framework is module-uo's, not core's

R9 says the map's per-layer switches work "through the existing visibility framework", which reads as reuse. It is not reuse. §6.3 records shardVisibility as module-owned, and the tree confirms it — the util, both models, the admin controller and its tests all live under module-uo/server/, and there is nothing by that name left in website/server.

§2.7 forbids a module requiring anything outside its own directory, so module-rust cannot import a line of it and builds its own. Not a reason to change the decision — per-layer switches are still right — but phase 14 now carries a visibility layer of its own rather than a configuration of somebody else's.

3. A missing declaration: extensions

R13 claims two extension slots and never says where they are declared. module.json has an extensions array (§2.1), and the dry run's own manifest carried "extensions": ["admin.users.detail"]. Like mounts, it is a statement of surface the loader holds against reality. Phase 1 adds it.

4. The ctx members the plan had never named

ctx has 29 members (§2.3); the plan named a handful. The ones that change work:

Member Phase Why
ctx.secretBox 1 Each configured server's sidecar token is a secret at rest, and core already has the posture — AES-256-GCM, write-only, never returned to a client
ctx.middleware.rateLimit 6 R1 requires the link code be rate-limited; this is the mechanism it asked for without naming
ctx.uploads 14 Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes
ctx.activity.log 7, 7b R2's permission changes and R18's config writes both owe an audit trail; neither needed inventing one
ctx.teams.publish / .activity.push / .reconcile 9 Teams is more than the provider — these are how a module pushes rather than answers
ctx.posts 10 The CMS surface behind registerAnnounceLeg and registerPostHook

Also recorded: §6.8, a constraint on phase 10 and on purge. engagement_rules.trigger_id is a plain VARCHAR with no FK and no cascade, so a module can be removed and reinstalled without destroying an operator's rules — and a rule whose trigger or audience is unregistered shows dormant, never an error and never auto-deleted. An audience resolving to the empty set is not the same answer as "resolved to nobody". The failure that prevents is exact: an id that stops resolving must never silently become a send to a different set of people.

Plus the two client lists in full (registry and ui), since §7 said "UI kit discipline" without saying what is in it; and one confirmation for R10 — /api/v1/public/modules returns only started modules with no state and no failure reason, so a Rust module that failed to boot makes the app render a site without those screens rather than one advertising screens that 503.

How it was tested

Documentation only. Every claim checked against a source in this pass:

  • ctx's 29 members, module.json's 11 fields, the registry and ui member lists, and §2.9's response shape — all read out of MODULE_API.md §2.1, §2.3, §2.9, §3.3 and §3.4.
  • shardVisibility's location — find over both trees: six files under module-uo/server/, none under website/server/.
  • The absence of ctx.events.reconcile in the merged plan — grep over PLAN.md, which is how the regression was found.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand
    every change, and take responsibility for it. AI-authored commits are
    marked with a Co-Authored-By / Assisted-By trailer.

License

  • I agree that my contribution is licensed under this project's license
    (GNU GPL v3.0 or later), and I have the right to contribute it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

## What & why Follow-up to #249. §7 of the plan audited it against the **Integration Kit** and the **template**; this pass reads [`MODULE_API.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md) itself, enumerating every member rather than grepping for registration names. It found four things. Adds §11 and wires the consequences into phases 1, 13 and 14. ### 1. A regression: `reconcile` had been dropped **`ctx.events.reconcile()` and an action's `reconcile()` appear nowhere in the merged plan.** The twelve-phase first draft had them — *"phase 6: reconcile, and the boot-id watch"* — and the rewrite to twenty phases lost them. That is a regression in the plan, not a decision. It matters **more** for Rust than for the game the contract was written against. The kit rates `reconcile` the one omission that is *"merely a lower standard rather than a broken promise"* — but that assumes a world which persists. **Rust wipes monthly and a wipe invalidates every ledgered resource for that server at once**, and core cannot tell a wedged sidecar from a game that rebooted and lost everything: it sees `{ ok: false, retry: true }` either way. It asks once at its own boot and otherwise waits to be told. `ctx.events.reconcile()` is being told, and its trigger is a watch on the game's **boot id** changing — which is also the only way to tell a game restart from a sidecar reconnect. Restored to phase 13. ### 2. R9 was mispriced — the visibility framework is `module-uo`'s, not core's R9 says the map's per-layer switches work *"through the existing visibility framework"*, which reads as reuse. **It is not reuse.** §6.3 records `shardVisibility` as module-owned, and the tree confirms it — the util, both models, the admin controller and its tests all live under `module-uo/server/`, and there is **nothing** by that name left in `website/server`. §2.7 forbids a module requiring anything outside its own directory, so `module-rust` cannot import a line of it and builds its own. Not a reason to change the decision — per-layer switches are still right — but phase 14 now carries a visibility layer of its own rather than a configuration of somebody else's. ### 3. A missing declaration: `extensions` R13 claims two extension slots and never says where they are declared. **`module.json` has an `extensions` array** (§2.1), and the dry run's own manifest carried `"extensions": ["admin.users.detail"]`. Like `mounts`, it is a statement of surface the loader holds against reality. Phase 1 adds it. ### 4. The `ctx` members the plan had never named `ctx` has **29 members** (§2.3); the plan named a handful. The ones that change work: | Member | Phase | Why | |---|---|---| | `ctx.secretBox` | 1 | Each configured server's sidecar token is a secret at rest, and core already has the posture — AES-256-GCM, write-only, never returned to a client | | `ctx.middleware.rateLimit` | 6 | R1 requires the link code be rate-limited; this is the mechanism it asked for without naming | | `ctx.uploads` | 14 | Where R9's map image actually lands. The plan described fetching it over the bridge and never said where it goes | | `ctx.activity.log` | 7, 7b | R2's permission changes and R18's config writes both owe an audit trail; neither needed inventing one | | `ctx.teams.publish` / `.activity.push` / `.reconcile` | 9 | Teams is more than the provider — these are how a module *pushes* rather than answers | | `ctx.posts` | 10 | The CMS surface behind `registerAnnounceLeg` and `registerPostHook` | Also recorded: **§6.8**, a constraint on phase 10 and on purge. `engagement_rules.trigger_id` is a plain `VARCHAR` with no FK and no cascade, so a module can be removed and reinstalled without destroying an operator's rules — and a rule whose trigger *or audience* is unregistered shows **dormant**, never an error and never auto-deleted. An audience resolving to the empty set is **not the same answer** as "resolved to nobody". The failure that prevents is exact: *an id that stops resolving must never silently become a send to a different set of people.* Plus the two client lists in full (`registry` and `ui`), since §7 said "UI kit discipline" without saying what is in it; and one confirmation for R10 — `/api/v1/public/modules` returns only `started` modules with no state and no failure reason, so a Rust module that failed to boot makes the app render a site *without* those screens rather than one advertising screens that `503`. ## How it was tested Documentation only. Every claim checked against a source in this pass: - `ctx`'s 29 members, `module.json`'s 11 fields, the `registry` and `ui` member lists, and §2.9's response shape — all read out of `MODULE_API.md` §2.1, §2.3, §2.9, §3.3 and §3.4. - `shardVisibility`'s location — `find` over both trees: six files under `module-uo/server/`, none under `website/server/`. - The absence of `ctx.events.reconcile` in the merged plan — `grep` over `PLAN.md`, which is how the regression was found. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` / `Assisted-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-15 17:47:11 +00:00
Section 7 audited the plan against the Integration Kit and the template. This
pass reads MODULE_API.md itself and enumerates every member rather than grepping
for registration names. It found one regression, one mispriced decision, one
missing declaration, and a set of ctx members the plan had never mentioned.

THE REGRESSION. ctx.events.reconcile() and an action's reconcile() appear
nowhere in the document. The twelve-phase first draft had them - phase 6,
reconcile and the boot-id watch - and the rewrite to twenty phases lost them.
That is a regression in the plan, not a decision. It matters MORE for Rust than
for the game the contract was written against: the kit rates reconcile the one
omission that is merely a lower standard rather than a broken promise, but that
assumes a world which persists. Rust wipes monthly and a wipe invalidates every
ledgered resource for that server at once, and core cannot tell a wedged sidecar
from a game that rebooted and lost everything - it sees ok:false retry:true
either way. It asks once at its own boot and otherwise waits to be told.
ctx.events.reconcile() is being told, and the trigger is a watch on the game's
boot id changing, which is also the only way to tell a game restart from a
sidecar reconnect. Restored to phase 13.

R9 WAS MISPRICED. It says the map's per-layer switches work through the existing
visibility framework, which reads as reuse. It is not reuse: section 6.3 records
shardVisibility as module-owned, and the tree confirms it - the util, both
models, the admin controller and its tests all live under module-uo/server/ and
there is nothing by that name left in website/server. Section 2.7 forbids a
module requiring outside its own directory, so module-rust cannot import a line
of it and builds its own. Not a reason to change the decision; phase 14 now
carries a visibility layer of its own rather than a configuration of somebody
else's.

A MISSING DECLARATION. R13 claims two extension slots and never says where they
are declared. module.json has an extensions array and the dry run's own manifest
carried it. Like mounts it is a statement of surface the loader holds against
reality, so phase 1 adds it.

THE CTX MEMBERS. ctx has 29 members and the plan named a handful. The ones that
change work: secretBox, because each configured server's sidecar token is a
secret at rest and core already has the posture for it; middleware.rateLimit,
which is the mechanism R1 asked for without naming; uploads, which is where R9's
map image actually lands - the plan described fetching it and never said where
it goes; activity.log, which both R2 and R18 owe an audit trail to; the three
ctx.teams calls, since Teams is more than the provider; ctx.posts behind the
announce leg and post hook; and the three send paths section 10's catalogue
implies.

Also records section 6.8, a constraint on phase 10 and on purge: a trigger, a
rule and an audience outlive the module that declared them.
engagement_rules.trigger_id is a plain VARCHAR with no foreign key and no
cascade, so a module can be removed and reinstalled without destroying an
operator's rules - and a rule whose trigger or audience is unregistered shows
DORMANT, never an error and never auto-deleted. An audience resolving to the
empty set is not the same answer as resolved to nobody and must not be rendered
as if it were. The failure that prevents is exact: an id that stops resolving
must never silently become a send to a different set of people.

And the two client lists in full, since section 7 said UI kit discipline without
saying what is in it; plus one confirmation for R10, that
/api/v1/public/modules returns only started modules with no state and no failure
reason, so a Rust module that failed to boot makes the app render a site without
those screens rather than one advertising screens that 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit cbf322b4d6 into main 2026-09-15 17:49:31 +00:00
whitlocktech deleted branch docs/rust-plan-contract-pass2 2026-09-15 17:49:31 +00:00
Sign in to join this conversation.
No description provided.