Commit Graph

27 Commits

Author SHA1 Message Date
601ee4e05f docs(modules): the event catalogue and the engagement catalogue
Phases 12-13 described the event MECHANISM and never the CATALOGUE - one budget
and one lease as a proof of life, which is a skeleton rather than a product.
Same omission for engagement: R7 settled that the set ships and nothing said
what goes in it. Two new sections fix both.

EVENTS.md section H is a Rust/Oxide compatibility section that already sketched
the event half, and it should have been read before the phase list was written.
Its thesis is the one to design around: the lease is the primitive that travels,
not the spawn. Double gather rate for the weekend is the canonical Rust
community event and it is exactly lease-with-expiry. It also rates Rust the
EASIER case than UO, because Oxide's convars are live by default where ServUO's
are mostly cached at boot - an argument for expecting them to work, never a
substitute for verifying each key live.

Section 9 declares four budget dimensions, five option sources, seven leases and
four actions, taking section H's ids rather than inventing a parallel set. Two
things in it are load-bearing.

Caps are PER RUN, and R8 makes that matter: run.scope is part of a run's unique
key, so one definition fanning out to six servers is six separate budgets rather
than one shared pool. An operator setting a cap of 30 is setting it per server.

And rust.group.membership is the lease section H names that R16 did not - the
pair is the whole reward design. A permanent earned entitlement is an ACTION
with reversible: ledger (R16). A time-limited group is genuinely core.lease,
held with a deadline the game enforces on its own. Same permission mirror,
two shapes, and choosing wrong is the mistake: a weekend VIP implemented as a
grant is a VIP for ever if the website goes away.

Section 10 is the engagement catalogue - eleven triggers with their ceilings,
three audiences, and the seed grouping. The ceiling lattice is containment and
not size, so every ceiling is chosen against that rather than against a ladder.

rust.base.destroyed is the one that matters most and is most likely to be got
wrong. The offline raid alert is the single most-wanted notification in Rust and
its ceiling is OWNER - the player whose base it was. Ceilinged staff it is
useless to the person who needs it; ceilinged everyone it broadcasts base
locations to the server. Exactly the case the lattice exists for.

Three hooks carry data that must never widen: CanUserLogin and OnUserApproved
carry IP addresses, OnPlayerReported carries player reports. README.md section 5
already flags these as admin-channel-only on the live feed and the same
judgement binds their triggers.

One design note flagged rather than decided: PopupNotifications gives the module
an IN-GAME alert surface, which is not one of core's channels. An in-game popup
is the module publishing to its own surface off its own trigger, not a fourth
channel core learns about. A raid alert that reaches a phone and pops on screen
next login is two mechanisms and only one of them is core's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:17:39 -05:00
43bf73bae5 docs(modules): R15 the optional tier with BetterChat, R16 rewards grant the right to redeem
R16 changes a design rather than adding to it. The reward action no longer calls
GiveKit; it grants the permission that GATES a kit, and the player redeems it
themselves in game. Kits already has exactly this model built in - every kit
carries a RequiredPermission, GiveKit's own path checks it, the in-game menu
renders an ungated kit as locked rather than hiding it, and GetKitInfo returns
the permission so the module can read which kits are gated.

What that removes is most of the hard part.

The offline-grant problem disappears. GiveKit needed a connected BasePlayer, so
an event firing at 2am rewarded only whoever was online; an entitlement waits.
That CLOSES the open question section 3 carried - no pending-grant queue, no
second at-most-once store.

It is the same machinery as R2 rather than a second mechanism: a reward becomes
a permission grant authored by the site and mirrored into Oxide, which phase 7
already builds. One permission authority, one drift story, one audit trail.

reversible: 'ledger' becomes honest where a direct grant could only ever be
'none'. revert revokes the permission, removing one that is not there is a
success, and it is idempotent by construction. A player who redeemed before the
revert keeps the items, and that is correct: the ledgered resource is the GRANT,
so reverting withdraws the entitlement rather than the consumption.

cost() counts grants and is exactly knowable before dispatch, which removes the
whole declare-the-maximum-because-you-cannot-know class of problem from chapter
5 section 4. And the idempotency key largely stops mattering: chapter 5 draws
the line itself - a key is for a write whose repetition would be a second
EFFECT, and a permission grant is a SET.

The earlier section arguing a kit grant can only be reversible: none is
rewritten rather than deleted, with the correction stated: it was right about a
direct grant, and the reusable part is that the action had been declared around
the wrong noun. What the event makes is not loot, it is an entitlement.

One design note carried into the option source: a kit with an EMPTY
RequiredPermission is open to everybody, so granting a permission for it rewards
nobody. The dropdown must surface which kits are gated and refuse or warn on one
that is not.

R15 opens an optional-integration tier, with BetterChat (LaserHydra, 5.2.15,
MIT, Universal) as the first member, for leaderboard-earned chat titles. Its
integration point is a PULL - API_RegisterThirdPartyTitle registers a callback
BetterChat invokes per player - so a title is a pure function of state we
already hold, with nothing written into it and no drift to reconcile. The trap
is chapter 4's rule applied to somebody else's callback: that getter runs
synchronously on the chat path and must be a cheap in-memory lookup, never a
socket call. Its API_AddGroup and API_SetGroupField pair naturally with R2's
site-authored groups, but that direction is a push and would need R2's drift
posture, so it is a phase 17 decision rather than a given.

Section 3 is now empty. Clans-in-the-base-set was confirmed complementary, and
the offline-grant question was dissolved rather than answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 12:11:11 -05:00
5dcb8088ad docs(modules): R7-R14 close the audit, and the phase list roughly doubles
Every question the contract audit raised was answered the same day. Eight new
decisions of record, and the schedule goes from twelve phases to twenty.

R7 the notifications and engagement set ships in v1 - streams, triggers,
audiences, seeds, announce leg, post hook - as ONE phase because they are a
matched set in the template rather than three independent gaps. The three rules
it lives on: ceiling is required and is not a ladder (a staff ceiling does not
permit owner, because one person for a cheat-detection event is the player it
was detected on); subjectKey must name a declared variable or every subject
shares undefined; and a rule group is offered once, so a rule appended to an
existing group reaches fresh installs only.

R8 multi-server from the start - the dry run's finding 2 taken at face value.
R12 per-wipe detail plus all-time rollups, with the truncation as a runtime
route and never a schema statement, since the fragment replays every boot.
R14 /rust on all three tiers, chosen deliberately because prefixes share one
namespace with core's and the collision probe cannot see core's root-mounted
endpoints. R13 two extension slots. R11 a small read-only slash command set,
with ephemerality fixed at the deferral so a refusal must defer ephemeral.

R9 is the one that split in two. The map IMAGE is static content on the game
host regenerated only on a wipe - the ch.3 2b case, so request/reply, one in
flight, two stages, its own derivation version, and no import on boot - while
everything moving on it is live state down the ordinary read path. Every layer
is an operator switch, and that is a security boundary rather than a preference:
public player positions in Rust locate players and let anyone infer base
positions. Default is monuments and world events public, players and bases
admin-only. This is the ONLY asset-bridge work in scope; item icons and the
2,590 skin ids stay out of v1.

R10 the Android app is in this workstream, deciding its screens from
/api/v1/public/modules capabilities, with each leg trailing the website surface
it consumes by one phase. Records the two endpoints that are not this and are
easy to confuse with it: /api/v1/public/status is site mode plus a version
block, and /api/health is an internal liveness probe.

Section 7 now carries the phase each previously-unplanned element lands in, and
the honest cost: twelve phases, from reading the chapters that describe the game
bridge and treating the module as the thin part when the kit says in its first
paragraph that the module is most of the work.
2026-09-15 12:01:44 -05:00
a4544ba5f2 docs(modules): audit the plan against the whole contract, not just the game-facing chapters
The first draft of the phase list was built from chapters 3-5 and under-planned
the website module by a wide margin. The template registers eight of the ten
non-event registrations; the plan covered three.

Unplanned and now recorded: registerExtension, registerNotificationStreams,
registerEventTriggers, registerAudiences, registerEngagementSeeds,
registerAnnounceLeg, registerPostHook, registerSlashCommands - with triggers,
audiences and seeds being a matched set rather than three independent gaps.
Also unplanned: purge.sql, mount prefix choice, the swagger fragment and its
staleness check, the vite alias/shim mechanism the kit calls the highest-risk
mechanical detail in the system, checkImports, and the entire packaging and
release path - there was no packaging phase at all.

On the client: declareModuleSlot, which the kit says is needed the moment a game
has anything like a guild, so it belongs in the Teams phase; registerFeatureProvider;
and the UI kit discipline that has caught this project twice already (PublicLayout
needs shell, PageHeader silently drops unknown props).

Beyond the module: the asset bridge, which Rust's item icons and 2,590 skin ids
fit exactly; the .profile landing page, owed whenever repos are added and three
just were; runicgateway.com, for which a second game is a headline change; and
the Android app, which feature-detects capabilities and must render a site whose
module it has never heard of.

Section 8 carries the eight questions the plan cannot answer for itself.
2026-09-15 11:45:04 -05:00
839e5d05a8 docs(modules): R6 names the base mod set, and reading Clans settles R5 the other way
The three repos exist and are named: Module-Rust, Rust-Link, Rust-Plugins. Notes
that a repository name is not a module id - Module-uo ships id `uo`, this ships
`rust`, and 2.1 requires id to equal the directory core loads it from.

R6 - the required base set is Kits 4.4.9, Clans 0.2.10 and PopupNotifications
0.2.1, all k1lly0u, all MIT, each with a direct .cs download, so phase 0's
install step is three curls rather than manual retrieval. Clans is listed as a
Universal plugin, written against Covalence, which is why its API takes IPlayer
rather than BasePlayer.

Kits is a good dependency: 23 HookMethods, including GiveKit for the reward
action, GetKitNames/GetAllKits for the authoring form's OPTION SOURCE so an
operator picks a kit from the live server instead of typing an identifier, and
per-player uses and cooldown for eligibility. It also raises OnKitRedeemed,
which the bridge can report as an ordinary event whoever triggered it.

Two traps in GiveKit, both found by reading it rather than reasoning about it.
It returns null on a failure path - `if (!player) return null` - and null is
Oxide's idiom for "no opinion", so the reflex of treating null as fine reports a
reward as delivered when there was no player to deliver it to. Success is the
literal true and a refusal is a message string, which drops straight into
chapter 5's `error`. And GiveKit takes a BasePlayer, so there is no offline
grant: an event rewarding participants at 2am rewards only whoever is online.
Phase 8 chooses between accepting online-only and keeping a persisted
pending-grant queue, which is a second at-most-once store and is not free.

R5 is settled far more firmly, and the other way round from how it was argued.
For Teams the Clans plugin is WORSE than first-party, not richer: it publishes
fifteen HookMethods and every one is a mutation, with no read API at all - no
GetClan, no GetClanMembers, no GetAllClans - and it raises exactly three hooks,
none of them a membership transition. It cannot answer any of core's three
provider questions from its published surface, while first-party clans answer
all three. So first-party is PERMANENT for the provider, not a first step. What
the plugin actually adds is alliances and clan/alliance chat - richer in
features, not in roster data - which is what phase 9 surfaces beside the
provider rather than under it. Reading its own data files is recorded as
deliberately not taken: that is another plugin's private storage, not an API.

Two questions left open in 3, neither blocking: whether Clans being in the base
set while the provider reads first-party is the intended reading (an
interpretation, not something stated), and the offline-grant choice, deferred to
phase 8 on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:40:10 -05:00
7d589ff5f2 docs(modules): R4 the installer grows a game dimension, R5 Teams come from first-party clans
R4 - Rust reaches an operator through the shipped installer behind
--game servuo|rust, not a second binary and not a shared-core refactor. The
shape of the work is set by where the coupling already is: service.rs and
main.rs mention ServUO zero times, while install.rs, doctor.rs, overlay.rs and
tier.rs carry nearly all of it, plus the bundle manifest where OverlayComponent
and ServUoCompat name the game in the schema itself. So the game payload becomes
a variant and ServUoCompat a per-game compat block - a schema change on the
published bundles branch, which is the part to design before touching code. The
Rust payload is much simpler than the UO one (no source tree, no patches, no
patch tier - one .cs into oxide/plugins) and what it gains instead is an Oxide
prerequisite check, which is doctor's shape rather than a new concept.

R5 - Teams come from Rust's FIRST-PARTY clans; the uMod Clans plugin is the
richer optional tier in phase 9. Three distinctions that are easy to collapse
and expensive to get wrong: the seven first-party clan hooks are all "no return
behavior", which is exactly what a read-only bridge wants; Rust's first-party
TEAMS are a different system entirely (twelve mostly-vetoable hooks, the
transient squad rather than the persistent organisation) and are not what core's
Team provider should be fed; and the uMod Clans plugin's API is not in our
mirror at all, since HOOKS.md is the game's 477 hooks and a plugin's API is its
own documentation.

The gap R5 has to design around: the seven hooks carry created, disbanded,
added, kicked and left, but no promote or leader-changed event. So
getTeamLeaders reads leadership off LocalClan at snapshot time, which makes
phase 6 partly snapshot-driven where the dry run predicted fully event-driven.
Recorded as a correction to that document rather than silently.

Also records the start.bat fix on the rig: it now updates D:\rust rather than
C:\rustserver, the original is kept at start.bat.bak, and the appmanifest
already present at the same buildid means the first corrected run is a delta
rather than a 5.9 GB re-download.

Phases are now 0-11; the installer is phase 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:32:43 -05:00
a5881d5a55 docs(modules): the module-rust plan, its decisions of record and its rig
The dry run designed this module on paper and deliberately did not build it.
This is the document that builds it: eleven phases, three new repos, and the
three decisions the org lead settled on 2026-09-15.

R1 identity is an in-game link code for v1. There is still no
registerAuthProvider at MODULE_API_VERSION 1.10.0, so "Sign in with Steam" is
not reachable from a module. What changed since the dry run is the stakes, not
the options: the dry run rated this survivable because the module only read,
and R2/R3 make the site the author of who may do what and the thing that hands
out loot. A weak link is now a privilege-escalation path.

R2 site-authored permissions mirror into Oxide's own permission store, so every
third-party plugin honours them with no adapter and a wipe stops being a
data-loss event for permissions. This is a direction the Integration Kit has no
chapter for - not the read path, not a ledgered one-shot, but continuously
reconciled state where the website is authoritative. Its nearest relative is the
Team provider inverted. Whether that deserves a sixth chapter is phase 10's
question.

R3 the Kits reward action always registers and refuses with a reason in `error`,
rather than vanishing from the form or refusing to boot. Phase 8 must declare
reversible: 'none' honestly - there is no way to un-grant loot a player has
spent - and count cost() per kit actually granted.

The rig is D:\rust, which has been booted and carries a matched Oxide 2.0.7585.
Two traps recorded: its start.bat updates C:\rustserver and launches D:\rust, so
the server that boots has never been updated by its own script; and
C:\oxide_files is a 2025-04-23 Oxide whose bundled Assembly-CSharp.dll would
downgrade a real install.

One question left open: which plugins besides Kits are in the required base set.
oxide/plugins/ is empty, so all of it is a fresh install either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 11:12:20 -05:00
4e2c2dc439 docs(modules): mirror the rest of the Oxide ecosystem, and add a machine-readable set
Completes the uMod mirror beyond the Rust hook table, and adds agent/ — the
same facts in TSV/JSONL at ~46% of the tokens.

New prose:
- OXIDE_API.md: the 19 developer pages under umod.org/documentation/api/ —
  plugin structure, hooks, commands, IPlayer, permissions, config, data files,
  database, localization, timers, web requests, dependencies, integration,
  preprocessor directives, security, style guide, CI, review. This is the
  framework our plugin is a guest in, where HOOKS.md is what the game says.
- OPERATING.md: the 6 operator pages — installing Oxide on a server, then
  installing, configuring and permissioning plugins.

New machine-readable set (agent/):
- hooks.tsv    477 rows, ~31% of HOOKS.md
- items.tsv    678 rows, ~80% of DEFINITIONS.md's item table
- skins.tsv    104 rows covering 2,590 skins, ~77%
- api.jsonl    150 code examples, ~47% of the two prose docs
Generated in the same pass as the markdown, so the two cannot drift.

HOOKS.md gains the universal-hook split: 34 of the 477 are uMod's own
Covalence hooks, raised identically on every game uMod supports. Verified
against /documentation/games/universal in the same capture - all 34 are in
the Rust set and the Rust page adds none of its own, so the overlap is exact.
The distinction is architectural: a universal hook is the portable part of
the surface.

Two things worth recording from building it:
- The first skins.tsv was one row per skin and came out 11% LARGER than the
  markdown it replaces. Grouping it one row per item is what made it a
  saving. The token win is real for prose (3.2x on hooks) and small for
  tables that were already dense - agent/README.md says so plainly rather
  than claiming a flat number.
- Signature extraction by brace depth silently captured body lines (a nested
  '}' in OnUserConnected's example ended the block early). It now matches on
  the hook's own name; all 477 rows verified to carry a real signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 10:01:11 -05:00
8d31e497d2 docs(modules): add the Rust Oxide/uMod API reference for module-rust
Mirrors uMod's Rust game API into docs/modules/rust/ so the upcoming
module-rust can be designed against it without a round trip upstream.

- HOOKS.md: all 477 hooks in 20 categories, scraped verbatim from
  umod.org/documentation/games/rust. Each entry keeps its description,
  its return contract, its tags and every C# overload uMod publishes.
  Opens with a category table and a full alphabetical index
  (name -> category -> return contract).
- DEFINITIONS.md: 678 items (short name, item id, display name) and
  2,590 workshop skin ids across 104 items.
- README.md: why the reference exists, how an Oxide hook actually binds
  (by name and arity, by reflection - a typo is silently never called),
  the four return contracts, and how the bridge invariants restate
  against Oxide. Records provenance and the exact refresh procedure:
  the hooks JSON endpoint is not Cloudflare-gated even though the HTML
  page is, and the definitions page has to come out of a browser.

The mirror is reference only - uMod stays upstream and normative, and
nothing here is a Runic Gateway contract. Rust wipes monthly, so both
files carry their capture date (2026-09-15) and a re-capture note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-15 09:38:43 -05:00
fc2d4872f6 docs(link): the panel, and the two deletions it could see and no test could (Phase 8)
v8.md section 12.2 records the admin surface as built: one page, Admin -> Client
Files, over the creature catalogue, item and land art, and the cliloc table --
one page because they come off one client install and change on one event. The
four decisions are written down, including the one that kept the phase to a
single repo: section 14's "activity view" is the last import's own summary
rather than a filtered feed, which would have needed a core activity filter, a
`ctx.activity.list` helper and a MODULE_API bump to re-read rows the panel can
keep itself.

The part worth reading twice is what the walk found. Two of the three defects
were DELETIONS, both predate phase 8, and neither was reachable by a test that
did not already suspect them:

  * the body import diffing its manifest against every family's rows, so 313
    item pictures were staged for deletion under a sentence that was wrong about
    what had happened;
  * an approved vanish unlinking the sprite and keeping the row, so the key came
    back for review forever and the count never moved.

The rule they share is now stated: after phase 5, `shard_assets` is three
collections in one table, and every read of it that feeds a decision has to say
which one it means. Two of the three that did not were deletions.

CLILOCS.md and SPAWN_ATLAS.md now name the button that exists (Admin -> Client
Files) instead of the one they promised, and modules/uo/API.md records that a
`needsReview` reply carries each vanished key's picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 08:10:49 -05:00
b84a73d0e7 docs(link): the phase the measurement cancelled, and the 452 wrong pictures it found on the way (Phase 6)
§16 planned deep animation keys and a bulk-fill switch. Measuring first changed
the phase: a complete one-direction animation set is 174,453 frames / 281.5 MB
(not the ~119,000 / ~117 MB §11 estimated), every frame carries its own centre
and 23,818 of 26,274 actions have frames of differing size — and the site
displays still pictures. So none of it was built (§11.2, org lead 2026-09-11);
`body/<id>/a<n>/f<k>` stays named in §5 and refused in practice.

§4.10 is what the walk found instead. A body's actions are a contiguous band and
the next body's begin immediately after, so one action past the ceiling is a real
record of another creature: 643 of 795 legacy bodies pass every check there and
452 are byte-identical to body+1's action 0. `Animations.GetAnimLength` is not
the ceiling — it disagrees with the index arithmetic on one body of this client,
by nine actions.

What shipped: the 73 bodies with no art at action 0 (a horse at `body/820/a23`),
the catalogue key carrying its action, the atlas join that reads it instead of
hardcoding `a0`, and the ceiling that makes the fallback walk safe. Catalogue
1,022 -> 1,095; EXTRACTOR_VERSION 2 -> 3; protocol stays 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 01:10:01 -05:00
a4b63d87d2 docs(link): the hue belongs where the files are, and the cache poisons it (Phase 5)
§11.1 is new and carries what phase 5 measured: 49,152 addressable static ids
(not the 81,884 `artidx.mul` declares -- `FileIndex` sizes its table from its
length ARGUMENT), 39,189 with art, 4,244 land tiles, 9,963 + 12,140 empty index
slots, and the whole set at 81 MB decoding in 34 s. That last number reopens the
bulk question and the answer is still no: 108 MB of base64 through a 512 KB
single-slot channel to store 43,433 pictures a shard displays a few hundred of.

Two traps, both §4.5's failure mode -- a confident, plausible, wrong picture:

- `Art.GetStatic` hands back the SAME cached Bitmap and `Hue.ApplyTo` repaints in
  place, so hueing edits the library's own copy: the plain key comes back hued
  from then on, and the next hue stacks. `Files.CacheData` off process-wide fixes
  it and also stops a game server retaining 74 MB of Bitmap. Copying instead does
  not solve the retention, and `new Bitmap(src)` throws on ARGB1555 anyway.

- `PartialHue` (13,259 of 65,536 ids) decides whether a hue repaints every pixel
  or only the grey ones, from a file only the shard has. Item 597 is a wooden
  screen with painted flowers; one mode reddens the flowers, the other the whole
  screen, and both decode. Hence land takes no hue segment and `h0` is not a key.

Plus the namespace trap that compiled: unqualified `TileData` binds to ServUO's
own `Server.TileData`, because the enclosing namespace beats `using Ultima;`.

§14 records what the wire gained -- the `static` and `land` families, `families`
on `assets.sources`, and `assets.fetch` becoming shared plumbing whose family is
DERIVED from the keys (§5 made the key the address; a request naming its family
too would have two places to be wrong and one of them silent). Additive, so the
protocol stays 8 and EXTRACTOR_VERSION stays 2. §15 records that `link` needed
nothing in phases 4 or 5: it forwards verbatim in both directions.

§17.10 is the four org-lead decisions. §12 and modules/uo/SCHEMA.md carry the
website side: `uploads/items/`, per-row `catalog` staleness, and why a key with
no art writes no row at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 06:16:15 -05:00
6ef4b06c76 docs(link): two of the eight player bodies existed, and 233 nobody asked about (Phase 4)
Phase 4 built 4.3's UOP animation reader. What it found first changed what the phase
was worth, so the plan is corrected rather than merely annotated.

  - New 4.9: what phase 4 measured. Of the EIGHT player bodies 4.8 assigned this
    phase, two are in the client at all -- gargoyles 666/667, in AnimationFrame3.uop.
    The six ghost bodies are in no package, and that is established rather than
    unfound: the five packages hold 10,724 entries and the
    build/animationlegacyframe/%06d/%02d.bin scheme claims every one, leaving no room
    for another naming. Also the format as read (AMOU, a per-frame ARGB1555 palette,
    direction as a slice of the frame table), the nine bodies whose frame count is not
    a multiple of five, the validate-as-we-go bounds and the measurement that says
    they refuse nothing real, and the live rig.
  - 5.2 rewritten: the player-body set is the LIVING pair per race, six ids not
    twelve. Ghost ids left it because no client has art for any of them. Still asked
    of the shard, never hardcoded -- only the question changed. And with phase 4 in,
    all six have art for the first time.
  - 4.3 rewritten against what was measured, including why searching five UOP packages
    for one body is NOT the never-sweep rule being broken: a legacy index is addressed
    by position, a UOP entry by the hash of a name carrying the body id, which the
    payload then declares again.
  - 11 sizing: the catalogue is 1,022, not 787. The mix is recorded because "add every
    body" sounds like it changes what a catalogue is, and it does not -- the legacy
    787 was already 366 equipment bodies.
  - 14: manifest and fetch rows carry `source` (legacy/uop). Additive, so protocol
    stays 8; EXTRACTOR_VERSION 1 -> 2 is the change consumers actually see.
  - 16 phase 4 marked DONE; 17.9 records the four org-lead decisions (fallback applies
    to every body; ghost ids leave the set; own PNG encoder; NO_IMAGING stays flat).
  - 4.8 and 8.1 keep their numbers as the record of what those phases measured, with a
    pointer to where the answer landed.

Two consumer docs repeated the ghost claim as fact and are corrected:
website/SPAWN_ATLAS.md (787 -> 1,022, and "two thirds of the playable ghost and
gargoyle bodies have no art" -> about half the addressable body range) and
modules/uo/API.md (same sentence).

Code: servuo-plugins#31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-11 04:59:55 -05:00
d07772a3d8 docs(modules): the cliloc conversion step IS avoidable now (phase 2 debt)
SCHEMA.md's cliloc section still described the pre-protocol-8 world, two
phases after phase 2 changed it. Three sentences said the same false thing,
so fixing only the flagged one would have left the section arguing with
itself:

- "Sourced from files the operator supplies" -- the BASE comes from the shard
  on any install with uo-link configured; only the overlays are the
  filesystem's, and that asymmetry has a reason worth stating (ServUO has no
  server-side notion of a custom cliloc, so there is nothing to ask for).
- "A base (the converted client table)" -- not converted any more.
- "The conversion step is not avoidable ... so the shard cannot supply names
  on our behalf" -- it does supply them. Phase 2 ported UOFiddler's Mythic
  decompressor into the overlay precisely so nobody converts anything.

Rewritten to say what is true and why the file path still exists (deprecated,
not removed, so an existing install keeps working), plus the two things a
reader of this table actually needs: `shard_cliloc_meta.payload` keeps the
base's fingerprint under `base` SEPARATELY from the overlay hashes -- because
on the bridge the old `clilocs.plain` label is supposed to disappear and one
flat hash map would read that upgrade as a vanished source -- and boot does
not import on the bridge path at all.

Each claim checked against the code rather than from memory:
shardClilocs.model.js:307 (`base: fingerprint`), :438-440 (refreshOnBoot
returns `skipped` on the bridge), clilocSource.js:333 (`missingOverlays`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:50:45 -05:00
1a7481e9f4 docs(link): the catalogue is real, and UOFiddler's last job is gone (Phase 3)
Phase 3 is built and walked on a live shard. What the walk measured, and the
two places the design of record needed correcting:

§8.1, new: the catalogue is 787 exactly as §4.8 predicted, and the whole scan
of bodies 1-2047 takes 734 ms cold -- so the wall-clock paging §11 designed
never fires on this client. Every §4.8/§5.2 prediction held when the bytes were
rendered and LOOKED at: 320, 607, 608 and 666 come back absent rather than as
another creature's picture, and the direction split is 783 at index 1 against 4
at index 0 -- four player bodies, not six.

44 of the 787 hashes are shared by two or three bodies, which is the exact
signature of the wrong-picture bug, so it was chased rather than assumed. It is
the client's own Body.def aliasing (83 {1}, 84 {1}, 106 {12, 59}), and the check
that settles it is at the source: Translate(ref body, ref hue) rewrites `body`
only when bit 31 is set, unlike the one-argument overload -- and ResolveAnimation
calls that same two-argument overload, so validator and decoder resolve the
identical record.

§12.1, new: **§12 is right about the outcome and wrong about the mechanism.**
`shard_spawn_creatures` is emptied and refilled by every atlas refresh, and a
refresh runs on every boot -- so an imported filename written to that row is
destroyed by an ordinary re-parse of the ServUO tree, and the next Update finds
the client files unchanged and never restores it. Three tables outside that
blast radius, and the atlas import re-derives `art` on the way past.

§14: **§16 listed phase 3 as servuo-plugins + module-uo and that was wrong.**
web.rs routes every command explicitly, so `link` is in the phase. Corrected in
both places.

UOFIDDLER.md is DELETED, two phases earlier than §9.1 predicted -- creature art
was the only thing still on it. SPAWN_ATLAS.md §Artwork is rewritten around the
import, keeping the operator's own map as the thing that wins; the module's
SCHEMA.md gains the three tables and API.md the two admin routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:41:08 -05:00
bbd69a8e2e docs(link): clilocs come over the bridge now, and UOFiddler's first job is gone
Phase 2 of the Asset Bridge is built, so the documentation stops telling an
operator to install a GUI tool.

`v8.md` gains §9.1 and §9.2 — what the port cost, what it measured, and where the
base table comes from now. The measurement worth keeping: **67,496 rows in
290 ms**, which is exactly what UOFiddler's own `Ultima.dll` produced from this
same client through the converter this phase deletes. An independent
implementation agreeing to the row is the strongest check available that a format
decoder is correct, and it is not something a subtly-wrong one produces.

§17 records the four shapes the org lead settled before any of it was written.
Two departed from the recommendation: **the bridge always wins** (no source
setting — there is no version of that question an operator benefits from
answering) and **import is admin-triggered** (boot does not call the shard at
all).

`CLILOCS.md` is rewritten around that: where the table comes from, what arrives
and in how many pieces, the refusals — including the two the file pipeline had no
equivalent of (a client patched mid-import, and the base's exemption from the
vanished-source rule, which exists so an upgraded install is not asked to approve
a change the upgrade itself made).

`UOFIDDLER.md` loses Part 1 entirely rather than having it rewritten. What is
left is creature art, which phase 5 takes, after which the page goes away. `v3.md`
§8.6 keeps its reasoning with a note saying what superseded it, because the
argument for why the manual step existed is still the argument for why this was
worth building.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:56 -05:00
cd0d22a225 docs(engagement): Phase 11b's live walk — three decisions and six defects
The walk is the last piece of 11b and it was not a formality. It found six
defects, four of them in code shipped by earlier phases, and none of the six was
visible in a unit test: each is a disagreement between two things that agree with
each other in a fixture.

Three of the fixes were decisions rather than repairs, all settled by the org
lead before any code:

  11  `uo.house.idoc_warning` ships delay_seconds: 900 and nothing could cancel
      it  ->  add `uo.house.refreshed`, a 26th trigger with a body and a rule
  12  a rule with a cooldown delivered on exactly ONE of its channels
          ->  `channel` joins the cooldown key; a cooldown is per DELIVERY
  13  `uo.vendor.expiring` could not fire, because the market sweep does not
      diff fees  ->  widen BridgeMarket.Signature() with exempt +
      periodsRemaining

Files:

  website/ENGAGEMENT.md   the three decisions, the four repairs, and what the
                          walk proved rung by rung; the 11b bullet and the §8.6
                          family table now read 26 triggers / 34 bodies
  website/BACKEND_DESIGN.md  engagement_cooldowns gains `channel` in its PRIMARY
                          KEY, with the migration's information_schema guard and
                          why MariaDB forces one
  link/v5.md              the sweep has to DIFF the fees or the frame never
                          comes -- stated as the general rule for the next
                          enrichment, since it is emit cadence and not shape
  modules/uo/API.md       §5.7a the cancel-shaped trigger and the Ageless-vs-
                          LikeNew ServUO fact; §5.7b every link comes from
                          config/clientPaths.js, and the two mistakes that made
                          every call-to-action a dead link

Pairs with website#<core>, Module-uo#<uo> and servuo-plugins#<plugin>.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 07:13:03 -05:00
9d50e5cacf docs(engagement): Phase 11b — the seed API, the in-universe voice, the governor's letter
Four decisions settled by the org lead before any 11b code, plus MODULE_API
1.9.0 and what building it found.

DECISION 7 is the one 11a could not have discovered, because 11a seeded nothing.
The plan's own sentence — "24 rules, all enabled = 0; bespoke template bodies" —
described work with NO MECHANISM to land in: templateSeeds.js and coreRules.js
are core files with core arrays, and registries.js has no registerTemplates.
Adds api.registerEngagementSeeds({ templates, ruleGroups }). Templates re-ensure
every boot; rule groups are one-shot per named group, which is 11a's seed-key
finding stated as an API rather than as a warning.

DECISION 8: the game-powered families read in-universe, with a per-family sender
rather than one voice. The crown writes about the crown's business and nothing
else. This is also why the bodies had to be the module's: §5.2 says core stays
ignorant of a module's domain, and check:modules reads identifiers, never prose —
so the boundary is honoured deliberately here rather than leaned on.

DECISION 9: nine bodies stay plain. A failed-login notice written as a letter is
indistinguishable in register from the phishing mail it warns about.

DECISION 10 corrects a fact the plan had wrong by omission. §8.6 records that
uo.points.rank_changed cannot address a person, and the same reasoning was
silently assumed to cover the governor. city.update's `governor` is written by
BridgeJson.Actor(), which emits acct and webId — the new governor is addressable
today, no protocol change. uo.governor.appointed is the 25th trigger.

Also records what building it found: the no-conditionals/empty-string collision
that the presentational fragments exist for, the render sweep that found three
defects no check could see (an optional in a subject line, multi-optional detail
lines, a trailing fragment used as a leading one), the trigger-bound template
that could not reference its own unsubscribe link, and the trigger-id/template-key
grammar mismatch.

docs/modules/uo/API.md gains §5.7 and §5.8.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 01:14:53 -05:00
561e5308e4 docs(engagement): MODULE_API 1.8.0, the UO trigger catalogue, the news path
The documentation half of Phase 11a (website#177, Module-uo#22). Three files,
each carrying one obligation from 6.0b's Phase 11a row.

**MODULE_API.md -> 1.8.0.** A seventh audience ceiling, `admin`, as a child of
`staff`. One addition, no removal, so minor -- every declaration valid under
1.7.0 is valid now and no stored value changes. 2.4's ceiling bullet now names
seven values rather than six, and says the thing a module author actually needs:
`admin` is the ONLY pair in the whole lattice with real containment, so
`permits('staff','admin')` holds and it is the only place `permits` is true
between two values below `authenticated`.

Also corrects two 2.3 rows that were stale rather than wrong: `ctx.inbox.push`
has not thrown since Phase 7, and both members now name the consumer that
actually calls them.

**modules/uo/API.md gains 5**, the full trigger catalogue -- which 6.0b asked for
in as many words ("the full trigger catalogue, its audiences and its ceilings,
not one entry"). The 24 triggers grouped by ceiling with the reason for each
ceiling; the four rows that deliberately do NOT ship with their recorded reasons;
the two that need a patch tier or a v5 overlay; the three things a rule cannot
express and are therefore done in the mapper; the three registered audiences; and
the ingest ORDERING, which is load-bearing rather than incidental.

**BACKEND_DESIGN.md -- the news publish path.** It described a direct
`pushDispatch.publish('news.post', ...)` from the create/publish-post path. That
call is gone: it is an emit through the engagement engine now, so news push rides
a rule, and core seeds that rule disabled. The note says so as an operator-visible
upgrade step, names the separate one-shot settings key and states what did NOT
change -- the announce legs and the post hooks still fire exactly as they did.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:34:31 -05:00
11d20a9a17 docs(modules): name Oxide, and pair one sidecar to one server
Two corrections from the org lead on the transport rewrite.

It is an OXIDE plugin, not "a mod loaded by the server's mod framework". Oxide is
what modded Rust servers run, and naming it is the difference between a design a
reader can start from and one they have to go and pick a framework for.

And the architecture is ONE SERVER, ONE SIDECAR - not one sidecar fronting a
community's several servers, which is the arrangement a UO-shaped reading reaches
for and which this document had. Rust servers in practice sit on separate VMs, so
a shared sidecar would have to be reached across a network by plugins that are
supposed to talk to it over loopback: it trades the invariant that makes the
design safe for a saving in process count.

The cost lands on the module, which is the right place for it - it holds one
client per configured server rather than one client to an aggregator - and it
makes the Team provider's `complete` answerable rather than vague, since "every
team there is" now means every team on THIS server. Five of six sidecars
reachable is `complete` left off, and core adds and updates without archiving.

Recorded as a reevaluable assumption rather than a principle, because that is
what it is. Nothing in the contract objects either way: core is not in this
conversation at all, which finding 2 now says.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:16:06 -05:00
4e98ed6bd5 docs(modules): the Rust dry run reaches the game through a MOD, not RCON
Overruled by the org lead: RCON is not used. Rust gets the same three-part shape
UO has - a plugin inside the game that dials out, a sidecar that persists before
it forwards, a module that talks only to the sidecar - and the plugin is a mod
loaded by the server's mod framework, exposing data through hooks.

The document had RCON as its premise, so the correction reaches further than the
transport paragraph:

- The reason Rust is a good second game changes. It was "its server speaks a
  protocol nobody has to write". It is now "its server is a BINARY" - the
  opposite of ServUO, which is source a shard owner compiles - so the way in is
  a published mod API and the shard-dials-out invariant has to survive that
  change of footing. It does, unchanged, which is a stronger result than the
  one the document originally claimed.
- The announce leg sends a command down the socket the mod already holds,
  rather than calling rcon.say.
- The provider refuses when no mod is connected, not when RCON is unreachable.
- Two hooks answer questions UO had to work for: a wipe arrives as an event, and
  membership is real-time - so this module's Team provider is event-driven with
  a baseline on connect rather than sweep-driven. The provider contract does not
  change by a line, which is the part worth keeping: core never needed to know
  how the data arrives.

The 2026-08-12 correction block stays and a second one is added beside it rather
than editing the history out - this document's own convention, and the thing that
makes it worth reading twice. It also records what the correction COSTS: this
project no longer has a worked example of "a game that already speaks a
remote-control protocol, so its sidecar is thin".

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 04:09:41 -05:00
7e08a55f76 docs(modules): the Rust dry run, revisited for Teams
The kit's README sends a reader here FIRST - it is the shortest honest picture of
the whole job - and it predated Teams, so it taught a second game to build its
teams as private module data and never mentioned the provider. The two places the
contract changed since it was written are now in it, and nothing else moved:
all four findings stand, including the identity gap, which is still the one a
real second module hits first.

What Rust adds that UO does not, and why it was worth revisiting rather than
noting:

- externalId must survive a rename and a Rust team HAS no name - it is a numeric
  team id in the save. The right answer, and the one a designer is least likely
  to reach for.
- `complete` is per SERVER, not per community. Six servers are six team spaces,
  so a provider that can reach five must leave `complete` off or core archives
  every team on the sixth.
- A wipe empties every team, so { ok: true, complete: true, teams: [] } is TRUE
  once a month and core archiving all of them is correct - which is exactly why
  an unreachable RCON must answer { ok: false } instead. The two states are one
  API call apart and only the module can tell them apart.
- The team route carries a server id as well as a team id, so the external id is
  <serverId>:<teamId>. Core stores that and never parses it; an external id is
  opaque to core by design, and this is the case that shows why.

Also: rust_teams stays the module's table and core's teams stays core's, which is
the boundary worth stating in the one document where both appear; and the kit is
nine members now, not seven.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 03:51:07 -05:00
c33a8a5509 docs(modules): correct F3 in the acceptance record
Only the Gitea release workflow carries the publishing placeholders. The report
said both flavours; the GitHub one uses GITHUB_REPOSITORY and friends and has
nothing to rename. Found while fixing it in the kit (Integration-kit#4), which is
where the single checklist row landed.

Also records what that implies about a run like this: every finding was
re-derived before being acted on, and it was not ceremony — this one was wrong in
a detail and F5 understated its problem in a way that changed the fix. A cold
agent's report is evidence, not a verdict, which is the standard the rest of the
plan already applies to a passing suite.

Separate PR because #147 was already merged; a push to a merged branch is silent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 17:41:37 -05:00
3116e7bbf6 docs(modules): close Phase 5 — the acceptance run, and the page shell it found
Slice 3 of Phase 5 (MODULE_SYSTEM.md 2.11.1), and the phase's last slice.

docs/modules/kit-acceptance.md is decision 5's deliverable: a cold agent given the
Integration Kit and the documents it links to — never core's source, never
module-uo — built a working module for a second game, which was then installed
into a real core and taken through MODULE_API.md 7.7's browser smoke. Verdict
recorded whichever way it went, and it went **yes, with caveats**: one pass, no
core source, and three of the four normative documents never opened.

The finding that justifies the two-stage shape is the one the agent structurally
could not reach, because it had no core to render against. A module page built
exactly as the kit teaches renders OUTSIDE the site: PublicLayout supplies the
chrome and not the body, and the `shell-... page-body` wrapper every core public
page writes for itself is two class names that appear in no contract. That is
3.4's own stated failure — "a module page that does not look like the site it is
installed in" — reached by following 3.4.

Fixed in core rather than documented at the reader, so the class names stay
core's private business and the theming workstream keeps its freedom to rename
them: PublicLayout takes an opt-in `shell` width, MODULE_API_VERSION 1.5.0
(website#148, merges first).

- MODULE_API.md 1.1: 1.5.0's entry, and a new bump-table row — adding an
  OPTIONAL prop or argument is minor. "A member's signature changes" is major
  because a call already written changes meaning, and an optional prop changes
  none; the table now says what it means rather than leaving it to be argued.
- MODULE_API.md 3.4: the shell prop, why a module names a width and never a
  class, and the eight-vs-seven miscount the run also turned up — the kit had
  faithfully carried it out of the contract into the template, which is the
  never-re-specify rule working exactly as designed on a wrong input.
- rust-dryrun.md: coreApi ^1.3.0 -> ^1.5.0, as a dated correction per decision 33.
  It is the only complete module.json in the kit's reading path and nothing
  checks a JSON block inside a Markdown file, which is the reusable half.
- MODULE_SYSTEM.md 2.11.1: slice 3 recorded, plus the third finding worth
  generalising — a check whose failure message asserts a diagnosis has to be
  right about it. `check:swagger` failed on a pristine template on Windows
  (CRLF) while blaming the routes, green on the Linux runner forever.
- Decision 34: core owns the page body as well as the chrome.

The banner does not come off. Decision 32 makes that a person's to remove, this
run exercised the website-module half only (the module has no sidecar, so
chapters 3 and 4 were never tested), and an agent does not skim or give up.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 14:28:26 -05:00
89aff5c26b docs(modules): settle Phase 5 — the Integration Kit, and the sidecar rule
Phase 4 closed; this is the plan for Phase 5, the Integration Kit, settled with
the org lead on six decisions.

MODULE_SYSTEM.md
- §2.11.1 (new): Phase 5's shape — measured starting state, the six decisions,
  the four slices, the acceptance mechanism.
- §2.11: layer 2 rewritten. The sidecar is the default and only path to a game,
  and it is a non-blocking dumb forwarder that persists before it forwards. A
  game that already ships a remote-control surface gets a thin sidecar, not none.
- §2.10: a MODULE_API_VERSION bump now carries a pass over the kit.
- Part 3: decisions 28–33.
- The Phase 5 line in Part 2's phase list: STARTED 2026-08-12.

MODULE_API.md
- §2.7 gains one prohibition: a module does not open a connection to a game
  server from the website process — a game socket, RCON, a query port, an
  engine admin API — with the three reasons it exists (the website is the
  internet-facing process, the sidecar owns the durable copy, neither side can
  stall the other). Normative prose with no CI behind it, stated so that every
  second module does not re-decide it.
- §1.1: the 1.4.0 entry. No member changed; minor deliberately.

modules/rust-dryrun.md
- §2 "Talking to the game" rewritten from "No sidecar" to a thin RCON sidecar,
  with a dated Correction note recording what the exercise originally concluded
  and why it was overruled. Corrected in view rather than silently rewritten.

Pairs with website#… (the 1.4.0 bump, into edge); either order.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:38:07 -05:00
42867d7ef8 docs(modules): close Phase 4 — de-UO the backend reference, record the acceptance run
Phase 4 slice 4. BACKEND_DESIGN.md had never been de-UO'd: Phase 3 rewrote core's
code and README, but §5.2's identifier check reads code, not prose, so nothing
ever looked at the design document. It still described 27 shard_* tables, 20 UO
route rows and the shard visibility ladder as core's, a phase after core stopped
being able to serve any of them.

Moved, text unchanged:

  BACKEND_DESIGN §3   six shard_* schema sections (226 lines) → modules/uo/SCHEMA.md
  BACKEND_DESIGN §4   13 public + 7 admin UO route rows       → modules/uo/API.md
  BACKEND_DESIGN §6.5 the audience ladder (70 lines)          → modules/uo/API.md §4

Core keeps the seam and gains the eight /admin/modules routes it had never
documented. §6.5 becomes "Module-owned audience boundaries": core's security
boundary ends at authentication, roles and the session, and a module that serves
game data brings its own.

Also fixed on the way: users.router.js was still listed as 15 routes (it is 9 —
six went to the extension slot), and the push section still promised
config/shardStreams.js "moves out with it" four slices after it left.

The acceptance table now carries results. Criterion 2 was proved for real against
module-uo v0.3.0 on an empty database, which turned up the uninstall ordering
defect fixed in website#146.

AI disclosure: this contribution was AI-assisted (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 09:16:29 -05:00
872352389d docs(website): close phase 3 — slice 5, the fragment obligation, and the rust dry run
Records the slice that closes the extraction, and adds the two documents phase 3
owed: docs/modules/uo/ and the module-rust dry run.

**The slice found an obligation neither repo had built.** MODULE_API.md §2.8 and
§6.1a settle the OpenAPI fragment in detail — a module ships one, core merges the
fragments of started modules into /api/docs.json. Neither half existed, so the 72
URLs module-uo serves were in no spec at all. §2.8 and §6.1a now record what was
built, including the four things settled while building it: the filename is fixed
rather than declared, a module namespaces what it DEFINES and references core's
shared schemas by core's name, the generator derives its prefixes from the
module's own register() call, and swagger-autogen's diagnostics have to be
captured because it reports a broken annotation and then prints Success.

**§5.3 gains the design decision the frozen manifest actually made:** it is a
SUBTRACTION, not a prefix filter. Generating the manifest without the module and
then with it answers "what does the module serve" AND "did core lose anything",
and the second is the one §1.2 promises to the shipped Android app. A module that
shadowed a core route cannot appear as an addition anywhere.

**BACKEND_DESIGN.md §4.0.1** is new: /api/docs.json is assembled per request, the
two generated artifacts are core's alone, and the route count was still 228.

**docs/modules/** is new, per §2.10 (module documentation aggregates here, not in
module repos): docs/modules/uo/README.md orients a reader on what module-uo
serves, owns and needs from an operator, and links out to the feature docs that
already existed rather than restating them.

**docs/modules/rust-dryrun.md** is phase 3's fourth acceptance criterion. A
written, deliberately unimplemented module for Rust — chosen because it wipes
monthly, runs several servers rather than one shard, identifies by Steam, and
ships RCON so there is no sidecar to write. The contract generalises: same
manifest, same seven registration calls, same schema rules, and six of the UI
kit's seven members wanted by a game with nothing in common with the one the kit
was curated from.

It found one real gap — **a module cannot register an identity provider**, and
"Sign in with Steam" is what a Rust community expects. Recorded as the first
candidate for a future MODULE_API_VERSION bump rather than bolted on: an identity
provider participates in session creation, and §2.7's link-only SSO policy has to
survive it.

Also: website-README.md refreshed from the repo (it was several changes stale),
and three settled decisions added (18-20).

Pairs with Module-uo#6 and website#141.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 23:05:56 -05:00