1 Commits

Author SHA1 Message Date
c26d06a782 docs(events): detail is a real envelope member now
Follows the finding recorded a commit ago: §H told a module the revert contract
accepts a `detail`, `classify()` had never read one, and `module-uo` had been
answering one since Phase 12b — so `uo.item.grant`'s report of which recipients
missed out was written into nothing.

Fixed in `website#197` by making the member real rather than by deleting the
reporting, because §H's sentence was right and only its example was wrong.

  * **§1.1, 1.10.0** gains `detail` as a third envelope member beside Phase
    10's two: optional, on both SUCCESS shapes, carried and never interpreted,
    objects only, 4KB, dropped-and-logged rather than failing the step.
  * **§2.4** gains the contract rule — core reads no key out of it, because a
    switch on known keys anywhere in core would be core learning one module's
    vocabulary.
  * **EVENTS.md §F** records the fix, including the half that is easy to miss:
    the run console's `describeLogLine` default returns a kind WORD, so the new
    line would have rendered as the literal string "step.detail" — the channel
    existing and showing nothing.
  * **§H's wipes row** no longer claims `detail` is unread.

MODULE_API stays 1.10.0, amended in place — still on `edge`. The failure
channel is unchanged and is still `error` alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 18:50:04 -05:00
6 changed files with 8 additions and 410 deletions

View File

@@ -79,8 +79,6 @@ android-app/
│ │ │ │ │ │ └── PushTickle.kt
│ │ │ │ │ ├── result/
│ │ │ │ │ │ └── ApiResult.kt
│ │ │ │ │ ├── time/
│ │ │ │ │ │ └── Instants.kt
│ │ │ │ │ ├── web/
│ │ │ │ │ │ ├── WebHandoff.kt
│ │ │ │ │ │ └── WebsiteUrls.kt
@@ -92,7 +90,6 @@ android-app/
│ │ │ │ │ │ │ ├── AdminDto.kt
│ │ │ │ │ │ │ ├── AuthDto.kt
│ │ │ │ │ │ │ ├── ContactDto.kt
│ │ │ │ │ │ │ ├── EventsDto.kt
│ │ │ │ │ │ │ ├── NotificationsDto.kt
│ │ │ │ │ │ │ ├── PageDto.kt
│ │ │ │ │ │ │ ├── PlayerShardDto.kt
@@ -105,7 +102,6 @@ android-app/
│ │ │ │ │ │ ├── AdminApi.kt
│ │ │ │ │ │ ├── AuthApi.kt
│ │ │ │ │ │ ├── AuthRefreshApi.kt
│ │ │ │ │ │ ├── EventsApi.kt
│ │ │ │ │ │ ├── MeApi.kt
│ │ │ │ │ │ ├── NotificationsApi.kt
│ │ │ │ │ │ ├── PlayerShardApi.kt
@@ -121,13 +117,11 @@ android-app/
│ │ │ │ │ ├── ConnectionRepository.kt
│ │ │ │ │ ├── ContactRepository.kt
│ │ │ │ │ ├── ContentRepository.kt
│ │ │ │ │ ├── EventsRepository.kt
│ │ │ │ │ ├── NotificationsRepository.kt
│ │ │ │ │ ├── PlayerShardRepository.kt
│ │ │ │ │ ├── SettingsRepository.kt
│ │ │ │ │ ├── ShardFeaturesRepository.kt
│ │ │ │ │ ├── ShardRepository.kt
│ │ │ │ │ ├── SiteCapabilitiesRepository.kt
│ │ │ │ │ └── WikiRepository.kt
│ │ │ │ ├── di/
│ │ │ │ │ ├── AppModule.kt
@@ -163,16 +157,6 @@ android-app/
│ │ │ │ │ ├── contact/
│ │ │ │ │ │ ├── ContactScreen.kt
│ │ │ │ │ │ └── ContactViewModel.kt
│ │ │ │ │ ├── events/
│ │ │ │ │ │ ├── EventScreen.kt
│ │ │ │ │ │ ├── EventSeriesScreen.kt
│ │ │ │ │ │ ├── EventSeriesViewModel.kt
│ │ │ │ │ │ ├── EventsScreen.kt
│ │ │ │ │ │ ├── EventsViewModel.kt
│ │ │ │ │ │ ├── EventTimes.kt
│ │ │ │ │ │ ├── EventViewModel.kt
│ │ │ │ │ │ ├── MyEventsScreen.kt
│ │ │ │ │ │ └── MyEventsViewModel.kt
│ │ │ │ │ ├── home/
│ │ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ │ └── HomeViewModel.kt
@@ -351,7 +335,6 @@ android-app/
│ │ │ │ │ └── WikiDtoTest.kt
│ │ │ │ └── fake/
│ │ │ │ ├── FakeAdminApi.kt
│ │ │ │ ├── FakeEventsApi.kt
│ │ │ │ ├── FakeNotificationsApi.kt
│ │ │ │ ├── FakePlayerShardApi.kt
│ │ │ │ ├── FakePublicApi.kt
@@ -362,8 +345,7 @@ android-app/
│ │ │ └── repository/
│ │ │ ├── AccountTrustedDevicesTest.kt
│ │ │ ├── ConnectionVersionGuardTest.kt
│ │ │ ── ShardFeaturesRepositoryTest.kt
│ │ │ └── SiteCapabilitiesRepositoryTest.kt
│ │ │ ── ShardFeaturesRepositoryTest.kt
│ │ ├── ui/
│ │ │ ├── admin/
│ │ │ │ ├── AdminContentViewModelTest.kt
@@ -374,12 +356,8 @@ android-app/
│ │ │ │ └── BrandAssetsTest.kt
│ │ │ ├── contact/
│ │ │ │ └── ContactViewModelTest.kt
│ │ │ ├── events/
│ │ │ │ ├── EventsViewModelsTest.kt
│ │ │ │ └── EventTimesTest.kt
│ │ │ ├── navigation/
│ │ │ │ ├── MenuAccessTest.kt
│ │ │ │ ├── MenuCapabilityGatingTest.kt
│ │ │ │ ├── MenuFeatureGatingTest.kt
│ │ │ │ ├── NavOverridesTest.kt
│ │ │ │ ├── NavPathsTest.kt

View File

@@ -80,31 +80,10 @@ A repeat of a key still in flight is answered **`bridge.busy`**: nothing runs, a
told to come back. It is deliberately not spelled `bridge.busy.error` — nothing is wrong, the work
is happening.
**2. A key that has begun is never released — except on a refusal.** Not when the handler throws.
Releasing it would let a retry re-run a command that may have applied half of itself, which is the
exact failure this file exists to prevent. A handler that throws stores a `bridge.error` reply
instead, so the retry gets a definite answer and the step fails once rather than looping.
**A REFUSAL is the third case**, added by the Phase 16 acceptance walk and amending protocol 7 in
place. A handler that ran to completion and answered `*.error` did not do anything — every refusal
on this plane is a guard: a missing `runId`, an unknown item, a cap, a rate limit, a write that
failed and left the value alone. Remembering it froze the answer for ever, so a refusal that
*waiting fixes* could never be retried past. `uo.world.save` is the case that found it: the shard
saves at most every 300 seconds, the module documents that as "the one refusal on this plane that
waiting fixes", and six attempts over four minutes all replayed one frozen sentence — "the last save
was 227 seconds ago" — because the number was the first reply's, not the clock's. A step's key is
one value for the life of the step, so the operator's retry control could not escape it either.
So a refusal releases the key: nothing happened, and the caller may ask again. The refusal is still
**emitted** to the caller, which is what ends that attempt; it is simply not remembered as the key's
answer. A refusal is recognised by its `kind` ending in `.error`, matched on the suffix so a handler
family added later is covered without extending a list. `bridge.error` is excluded deliberately —
that is the reply the shard writes when a handler THREW, which is the case whose key must be kept.
**This puts a rule on handlers, and it is the rule the release rests on: do not answer `*.error`
after changing the world.** Report a partial change in an `ok` reply, as `item.grant` does with
`granted`/`missed` and `world.despawn` with `removed`/`gone`/`refused`. The shard cannot verify
"nothing happened"; it takes the `.error` kind as the claim.
**2. A key that has begun is never released.** Not even when the handler throws. Releasing it would
let a retry re-run a command that may have applied half of itself, which is the exact failure this
file exists to prevent. A handler that throws stores a `bridge.error` reply instead, so the retry
gets a definite answer and the step fails once rather than looping.
**3. A replay is stamped with the REPEAT's correlation id.** The sidecar's `reqId` is a fresh
per-process counter, so a retry is waiting on an id the first attempt never used. Replaying the
@@ -123,7 +102,7 @@ evicted key's repeat *would* be applied a second time — so an eviction that dr
its TTL prints a console warning naming the count. If the promise is ever actually breached, an
operator reads it here rather than discovering a doubled spawn in the world.
`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated= refused=)`.
`[bridge status` reports `idem(keys= seen= replayed= busy= evicted= uncorrelated=)`.
#### 2.1.1 How the reply is captured

View File

@@ -1847,7 +1847,7 @@ no URL moved.
| `DELETE /admin/events/series/:seriesId` | admin, editor | delete it, detaching its definitions; answers with how many |
| `GET /admin/events/calendar` | staff | the calendar for a window: materialised runs and projected occurrences (Phase 4) |
| `GET/PUT /admin/events/actions` | admin | which actions are enabled on this deployment, and their per-run caps (Phase 6). `admin` on the read as well as the write; the PUT takes one action at a time |
| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. A run is an INTERVAL, not an instant: an entry is in the window when the run OVERLAPS it, so one that began before the window and has not ended is still "what is on" (Phase 16a — reading the start instant alone made this route serve only the first of its three words, while the event's own page said `live`). Defaults to seven days back through 31 days out — the tail is where "recent" lives — and the window may span at most 92; the anonymous surface is the one with no login in front of it. Projections are forecast from NOW, never into the tail, since a slot the runner has already passed did not happen |
| `GET /public/events` | — | **the calendar** (Phase 14a): upcoming, live and recent, by series. Runs and projections interleaved and each saying which it is, ascending by instant. Instants are UTC and every entry carries the EVENT's own zone; the reader's zone places them. Rehearsals and unlisted events are absent. Defaults to now through 31 days out and the window may span at most 92 the anonymous surface is the one with no login in front of it |
| `GET /public/events/:slug` | — | **one event** (Phase 14a): storyline, arc, what is live, what is next, what happened recently, and a results table once one is published. Takes an optional `?run=`, which is what an announcement's link carries, so a mail about last Friday's occurrence does not open next Friday's; a run belonging to some other event is **ignored rather than refused**, because a stale link in a months-old mail should land on the event it was about. A draft, an archived definition and an unlisted one all answer 404 |
| `GET /public/events/series/:slug` | — | **the arc** (Phase 14a). A series with no listed events is a 404, not an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced |
| `GET /player/events/history` | auth | **this account's participation** (Phase 14a) — the run, when it was, the score a module reported, and the rank once results were published (null until then, which is a real state rather than an error). Self-scoped on the session with **no id parameter**, deliberately: a route that took one would be a middleware mistake away from publishing who attended what. Keyset-paged on the participation row's id. It obeys the calendar's two exclusions, so attending an unannounced event does not disclose that it exists |

View File

@@ -15,14 +15,6 @@ UO actions, the integrations, the authoring UI, the public surface — needed no
Those fourteen phases reach the game only to *announce*, over verbs the write plane already carries; nothing in them creates or
changes a thing in the world.
**COMPLETE as of 2026-09-10.** All seventeen phases are built and on `main` in every repository they
touch. P16 ran as three legs — **16a** the acceptance walk from `edge`, **16b** the six-step cutover
and the re-verify against released artefacts, **16c** `runicgateway.com` and `.profile` — and 16c
opened by closing the seventh cutover step 16b had left standing (`docs#232`). The platform the
workstream leaves behind is sidecar **v2.2.0**, overlay **v1.2.0**, bundle **2026.09.10** on
**protocol 7**, `Module-uo` **v1.2.2**, and `MODULE_API_VERSION` **1.10.0**. Each phase's record is
in its own section below; `edge` stays standing, unused, in every repository.
---
## Before anything: three facts about the ground
@@ -1828,280 +1820,12 @@ website, emulator — running a real multi-phase event, including three delibera
Then `edge``main`, in the order every previous cutover used: the protocol side first, the module,
core, docs, then the kit's re-pin and `runicgateway.com`.
> **Split into 16a (the walk) and 16b (the cutover)** (org lead, 2026-09-09), on the same argument
> 12a/12b and 14a/14b were split on. The two sentences above cannot both hold: `link`,
> `servuo-plugins` and `Module-uo` all release on push to **`main`**, so no released artefact
> carrying events can exist until after the cutover. Engagement Phase 13 met the same wall and
> resolved it the other way, cutting over first and walking from `main`. Here the walk goes first
> against artefacts built from `edge` exactly the way a release builds them, because every walk in
> this workstream has found defects and a defect found on `edge` is a reviewed PR rather than a
> hotfix to `main`. **16b re-verifies against the real released bundle** — install, boot, run one
> event — so the delivery path is still proved, just second. A third leg, **16c**, carries
> `runicgateway.com` and `.profile`.
>
> **16a WALKED, and it is four repos** — `Module-uo`, `website`, `servuo-plugins`, `docs`. The whole
> rig: real ServUO 57.4 (208k items, 42k mobiles) → a `cargo --release` sidecar on protocol 7 → core
> with the module installed from a release-shaped bundle → the Android app on an emulator. The
> overlay was deployed from a tarball built the way CI builds one, into a tree with `Scripts/Custom/
> Bridge` and `Saves/Bridge` deleted first, so it was a first install rather than an upgrade.
>
> **All three deliberate failures pass.** (1) A mid-run process kill landed mid-TEARDOWN — sharper
> than mid-step, since a phase executes in about a second — with the run `completed`, cleanup
> `pending`, a lease half-returned and 21 world objects up: teardown resumed on restart and all 15
> steps still read `attempts = 1`, so nothing re-executed. (2) The sidecar killed during a phase gate
> left the run `degraded` rather than failed, `core.lease` retrying with a reason, and the four world
> writes behind it **parked at `attempts = 0`**; the shard reconnected on its own. (3) A cap of 5
> against a step asking for 12 answered `refused` — its own status — with `code: "cap"` and *"asks
> for 12 of `uo.creatures`; 0 of 5 is already spent this run"*, and the dry run had already refused
> it in the author's own words.
>
> **Six defects, all in code already merged to `edge`, and the suites were green on either side of
> every one.** Two were blocking or worse. **The spawn atlas could not import on a stock ServUO
> tree** — a case-sensitive JS dedupe against an `..._ai_ci` PRIMARY KEY, four colliding decoration
> spellings in ServUO's own files, and the whole transaction lost; with no atlas every option source
> answers empty and no world verb can be authored at all. **Teardown of all five world verbs was a
> no-op that reported success** — `revertOwned` sent the despawn under the step's key, which is the
> key the SPAWN used, so the shard replayed the spawn's reply and `OnDespawn` never ran; the ledger
> read `reverted` while the shard held all 21 objects, and the same despawn under a fresh key removed
> every one. Then: **the public calendar served neither live nor recent runs** though §I promises all
> three, so the site said `live` on one page and showed nothing on the other; **a resource left
> `reverting` by a crash was never reclaimed**, and the manual cleanup route answered 200 while doing
> nothing, which stranded a lease and blocked the NEXT run of the same event; **a transient refusal
> under an idempotency key was permanent**, because the shard's store had no case for a handler that
> ran and deliberately did nothing; and **three facts every announcement computes were declared by no
> trigger** and silently dropped.
>
> One reported defect was **withdrawn**: `skip` refusing a `failed` step is not a dead end, because
> `resume` carries a run past any settled step — the route's own docs say so and the rig confirmed
> it. The runner claims only `pending` steps, so `failed` and `refused` are both settled.
>
> Every fix is verified against the rig, not only against tests: the atlas imports 309 decor types
> and 6,455 points; a full four-phase run's teardown leaves the shard owning **0**; a lease stranded
> by a real crash is reclaimed in one sweep and `cleanup_status` reaches `complete`; the same save
> key 25 seconds apart answers "15 seconds ago" then "40 seconds ago"; and `/site/events` shows a
> live run as **Happening now** beside recent ones, in the browser and in the app. Each new test was
> confirmed to FAIL without its fix.
>
> **`Module-uo`'s `revert` no longer forwards core's key at all** — `MODULE_API.md` now says why that
> key identifies a lost dispatch rather than addressing the undo. **Protocol 7 is amended in place**:
> a refusal releases its key, with the rule that pays for it written down — *do not answer `*.error`
> after changing the world*.
> **16b CUT OVER (2026-09-09/10) — six steps, and core before the module.** The protocol pair
> (`link#40` + `servuo-plugins#26`) is ONE step, not two: `bundle.yml`'s Gate 1 reads the protocol
> number out of both released artefacts and refuses a pair that disagrees, so whichever lands first
> leaves a compose that cannot run. Then core (`website#199`), the module carrying its own re-pin
> (`Module-uo#34`), the app (`Android-app#46`), the kit's re-pin (`Integration-kit#11`), and docs.
>
> **Core lands before the module**, which departs from the sentence above and matches what the
> engagement cutover actually did: `Module-uo`'s `ci/core-ref.json` has to name a website `main` sha
> carrying MODULE_API 1.10.0, and that sha does not exist until core has landed. Four decisions, all
> as recommended (org lead, 2026-09-09): that order; the app merges with **no `v*` tag**, so no APK
> was cut; the re-verify walks the whole delivery path; and **`edge` stays standing** in every repo
> rather than being deleted as the module-system cutover deleted its own.
>
> Releases cut: sidecar **v2.2.0**, overlay **v1.2.0**, bundle **2026.09.10 (protocol 7)**. `website`
> never releases. `MODULE_API_VERSION` and `EVENTS.md` are untouched by this leg — the cutover moves
> no contract.
>
> **`Integration-kit#10` had been merged early**, on 2026-09-08, though it was written to be held —
> so the kit's `main` was red on `checkCoreApi` for two days. That is what step 5 closes, and it is
> the reason the re-pin is a repair rather than only a date.
>
> #### The outage, and what it did not break
>
> Gitea was unreachable for about ten minutes in the middle of the window (Cloudflare 1033/530) and
> killed **both** release runs. `link`'s built every binary and wrote `SHA256SUMS`, then died pushing
> the tag: `fatal: unable to access … The requested URL returned error: 530`. `servuo-plugins`' died
> inside `Set up job` after 11m52s with no step ever executing — which is why that job's log route
> answers 500 while its predecessor's serves fine: **there is no log blob, and that absence is
> evidence.** No tag was pushed either time, so the orphaned-tag failure mode did not occur, and
> re-running both by `workflow_dispatch` published them. The first to land left the pair mismatched
> and compose run 102 failed exactly as the PRs predicted; the second dispatched it again and 103
> composed. `link`'s `rust-gates` reds on three earlier PRs were `curl: (6) Could not resolve host:
> sh.rustup.rs` inside the runner — infrastructure, not code, on all four counts.
>
> **A job's log IS readable on this instance, through the web route rather than the API:**
> `/{owner}/{repo}/actions/runs/<n>/jobs/<j>/logs` with an API token, served as `text/plain`; step
> statuses come from the UI's own POST endpoint with a `_csrf` cookie. Every earlier phase diagnosed
> CI by reproducing jobs locally, on the belief that logs were unreachable. They are not, and reading
> one is what turned four red X's into four known causes in about ten minutes.
>
> #### A seventh defect, red on every events PR since Phase 10
>
> `website`'s `server-tests` job had been failing since `#192` — eight PRs, every one reporting
> `# fail 1`, always **the same single test**, so nothing else was ever hiding behind it. The
> workstream merged over it eight times.
>
> `events/announce.js` asked `Intl.DateTimeFormat('en-GB', { …, hour12: true })`, and **that is not
> the same request as a 12-hour clock.** For a locale whose default cycle is h23 — `en-GB` is one —
> Node 20 resolves `hour12: true` to **`h11`**, whose hours run 011, so midnight renders `0:00 am`;
> Node 22 and later resolve it to `h12` and it renders `12:00 am`. **Same ICU (78.2) on both sides**,
> so this is V8's ECMA-402 behaviour and not locale data — no amount of matching the runner's locale
> would have found it.
>
> The image ships `node:20-alpine` and CI runs Node 20, while a dev machine is newer. So the mail
> every real recipient got said **"0:00 am"** beside a schedule editor saying "12:00 AM" — one
> instant, two spellings, the exact contradiction that option was added to prevent — and it rendered
> correctly in front of everyone who reviewed it. Fixed to `hourCycle: 'h12'` (`website#200`), which
> is the form `recurrence.js` had already adopted for the mirror-image case (`h23` **rather than**
> `hour12: false`); `announce.js` was the last `hour12` in either repo.
>
> **The rule: `hour12` is a request about a locale's preference, `hourCycle` is a request about the
> clock. Ask for the clock.** And the test now says so out loud, because it can only fail on Node 20:
> a green run on a dev machine is not evidence, and CI is what holds that line.
>
> #### The re-verify, from artefacts an operator would download
>
> This is the leg 16a could not do — a locally built bundle cannot go through core's module installer,
> which is https-only with a host allowlist.
>
> | | |
> |---|---|
> | installer | released `v0.1.1` binary, checksum matched against the release's own `SHA256SUMS` |
> | bundle | resolved **2026.09.10, protocol 7**; both component checksums verified by the installer |
> | overlay sync | a **first install** into a stock 57.4 tree — `add=30 change=1 unchanged=0` |
> | script build | `0 Warning(s) 0 Error(s)` — the released overlay compiles on a stock tree, which no release had ever been asked to prove |
> | shard boot | `[Bridge] enabled=True … adminWrite=True … events=True`, then `connected to 127.0.0.1:7788` |
> | sidecar | `server.hello` for **208,568 items / 42,871 mobiles**; `x-uolink-version: 7` |
> | event plane | `lease.list.ok` (config **and** targeted property leases), `item.catalog.ok` with its bounds, `GET /world/<run>` an empty list rather than a 404 |
> | core | released `main` on a throwaway database, `capabilities: ["events"]` on `/public/version` |
> | one event | published, run, **`completed` / `health: ok`**, results published |
> | the page | `/site/events` reads *"Everything scheduled, live and recently finished"* and lists a run that finished two minutes earlier |
>
> The last row is 16a's calendar fix holding on `main`: before it, a run that had already started or
> finished was absent and the page rendered `entries: []`.
>
> **A fresh `Bridge.cfg` still ships `EventsEnabled=false` and `AdminWriteEnabled=false`** — the
> operator's real first-boot state, and the released config confirms it rather than a working tree's.
>
> One thing checked and deliberately **not** reported as a defect: a **cancelled** run appears on the
> public calendar. It is meant to. The entry carries its own `status`, and the page renders a past
> cancelled run as **"Did not happen"** — the honest label, not a silent omission.
>
> **The module's own install was walked too**, once step 3 cut `Module-uo` **v1.2.1**. The module
> arrived the way an operator's would: `POST /admin/modules` naming the release's **manifest** (not
> its tarball — core answers a tarball with *"the install manifest is larger than 262144 bytes"*,
> which is the size guard doing its job), core fetched the artifact over https from the allowlisted
> host, verified its `sha256`, and mounted it on the next boot with 12 event actions and 27 triggers.
> Then `PUT /admin/uo-link/config` with the four values the installer printed answered
> **`status: connected`, `pluginConnected: true`, `protocol: 7`** — released core, released module,
> released sidecar, released overlay, all four talking.
>
> On that rig the two Phase 16a fixes were confirmed in the shipped artefacts rather than in a working
> tree: the atlas **imported off a stock tree** (309 decor types, 6,455 points, 800 creatures, 558
> landmarks, 387 regions, 25 champions — the import that used to die at 313), and a world verb ran and
> **tore down for real** — three orcs spawned, ledger `reverted` ×3, `cleanup: complete`, and the shard
> itself answering `world.owned → owned: [], pruned: 0`. That last check is the one 16a's no-op
> teardown hid behind. The enablement gate and the cap behaved as specified on the way past: the dry
> run refused the action before it was enabled, then priced it `uo.creatures 3 of 10`.
>
> **And the leg found two more defects, both in the released bundle and neither visible to any test**
> (`Module-uo#35`).
>
> **The aggregator discarded the `UniqueId`, so no Phase 12b property lease was authorable at all.**
> All 6,455 spawn points imported with `unique_id` NULL; `listSpawners` filters
> `unique_id IS NOT NULL`, so `uo.options.spawners` — the only source those leases have — was an empty
> dropdown with nothing to explain itself. Every part of the path was right except one line: the files
> carry `<UniqueId>`, `parsePoints` returns it, the column exists, the insert passes it. `buildAtlas`
> rebuilds each point from an explicit field list and the field was not on it. **`PARSER_VERSION = 4`'s
> own note says a point keeps its `UniqueId` and names Phase 12b as the reason** — that bump exists to
> re-read trees for this field, and the field was dropped one function later. The intent shipped as a
> comment. Fixing it needs `PARSER_VERSION` 5 as well, because the tree's hashes have not changed —
> only what is kept from them — so nothing would re-read an existing install.
>
> **A landmark option value named 23 places at once.** 558 landmarks, 320 distinct `facet/name`:
> `Trammel/Entrance` is Blighted Grove, Covetous, Deceit, Despise, Destard and 18 more, and
> `landmarkPoint` resolves with `.find()`. So 22 of the 23 were unreachable and an author who picked
> "Entrance — Destard" got Blighted Grove, with a successful run and no warning. **The group was
> already the disambiguator** — shown in the dropdown, left out of the value. Now `facet/group/name`,
> distinct across all 558, with the two-part read kept as a fallback because a published version is
> immutable and those stored values are the authored record. A three-part value whose group is gone
> refuses rather than falling back: it asked for one place.
>
> Both are the same failure shape as 16a's blocking defect and worth naming as a class: **an option
> source that answers empty, or answers with a value that does not identify one thing, disables a
> feature silently.** Nothing errors, the form simply cannot express the thing — and a test that
> checks the parser, or the query, or the column in isolation passes throughout. The atlas fixture had
> no `<UniqueId>` in it at all until this phase, which is why a green suite said nothing for two.
**Two documents that are cutover-window work by construction.**
- **`runicgateway.com`** — `checkFacts` reads `main`, so any claim about events is unverifiable until
the cutover lands. Same 12a/12b split the engagement workstream needed. **16b landed it**, so both
of these are now unblocked: `main` carries the engine, the module and the app, and the bundle triple
the site quotes is sidecar **v2.2.0** / overlay **v1.2.0** / bundle **2026.09.10**.
the cutover lands. Same 12a/12b split the engagement workstream needed.
- **`.profile`** — the org landing page is updated when the *shape* of the project changes, which a new
subsystem is.
> **16c BUILT (2026-09-09/10) — and it began by closing the cutover's missing seventh step.**
> `runicgateway.com#30` and `.profile#6`, both onto `main`; the leg's first act was `docs#232`.
>
> #### The step 16b left on `edge`
>
> Six repositories were cut over and every one of them showed **0 commits on `edge` that are not on
> `main`**. This one showed **46**. Step 6 (`#231`) landed the record *on* `edge` rather than cutting
> `edge` over — an easy thing to miss, because the step's own PR merged green and closed. The
> consequence was quiet and total: `docs` `main` opened `EVENTS.md` with *"revision 5. **No code
> written.** Read against … `MODULE_API_VERSION` 1.9.0 · sidecar protocol 5"* while six repositories
> shipped the engine on protocol 7, and `link/v6.md` and `v7.md` — the specs of record for two
> protocol versions — existed on no default branch anywhere.
>
> **Nothing in this workstream could have caught it.** Every check that guards a contract lives in
> the repository that *depends* on the contract, and a documentation repository has no dependants.
> What found it was the one check that reads `docs` from outside: `runicgateway.com`'s
> `checkReference.mjs` asserts every canonical document it names still exists on `main`, and adding
> the current protocol spec failed with `✗ canonical doc link/v7.md`. **The site is the docs
> repository's only dependant, and 16c is the only phase that would ever have run that check.**
>
> The merge was clean, and both `PROJECT_TREE.md` files stayed on `main`'s newer automated syncs —
> `edge` never edited them, so git kept `main`'s side. `edge` stays standing, per 16b's decision,
> now four generated commits behind.
>
> #### The site
>
> The checks were red before the phase started and named their own answers, which is the whole
> bargain §12 of that repository's plan struck: nine `checkFacts` values (protocol 5 → **7** in all
> three declaration sites, `moduleApi` → **1.10.0**, the bundle triple, `link` **v2.2.0**, `Module-uo`
> **v1.2.2** — a third module release, one past the v1.2.1 the cutover cut), and **twenty-seven
> `Bridge.cfg` keys** the site listed nowhere. Those became five groups rather than an appendix,
> because `EventsEnabled` is a *second consent switch* and belongs beside the ceilings it governs
> rather than filed under `AdminWriteEnabled`.
>
> Two pages, the treatment Teams has: **Scheduled events** under Administration and **Events
> architecture**. Two capability entries, so the homepage, `/features/` and `/modules/` stop omitting
> the subsystem — and the calendar one is deliberately **not** `needsModule`, because a bare core can
> author and run an event and that marker means "present, correct and permanently empty".
>
> **`/privacy` owed a row and had none.** `event_run_participants` is personal data — scores and
> ranks against a module-opaque member key, linked to an account where one is linked, feeding a
> participant's own history. The new `deploy-events` row states the retention exactly, including the
> asymmetry that matters: the diagnostic log is swept after 90 days **and only on terminal runs**,
> while the run, its steps and its participants are never swept, because they are the record of what
> was done to a shared world.
>
> **A naming collision worth fixing while it was cheap.** `reference/event-catalog` is about what a
> shard *emits*; with a scheduled-event system shipped, two things in the documentation were called
> an event catalog. Retitled **"Shard event catalog"**, with the route left alone so nothing outside
> that repository breaks — and the page now opens by saying which of the two it is, since the kinds
> it lists are exactly what a phase can wait for.
>
> **No screenshots**, and stated as a choice: capturing the events surfaces means standing the whole
> rig back up for images no check requires, and the engagement workstream's site leg added none
> either.
>
> #### `.profile`
>
> One bullet, and two stale numbers. The bullet says the posture rather than the feature list — off
> by default, caps in the database, cleanup generated from a ledger, and *an event does not edit the
> world, it holds a lease the game restores on its own deadline*. The numbers are protocol **5 → 7**
> in the four values the installer prints (the block a reader copies into Admin → Shard, where a
> wrong number is a pairing failure with no obvious cause) and **module-uo v1.1.0 → v1.2.2**.
>
> #### What 16c did not need
>
> No `EVENTS.md` change, no `MODULE_API_VERSION` change, no protocol change, no release. The phase
> moves no contract — it makes the ones already moved legible from outside the organisation.
---
## What this plan does not do

View File

@@ -1193,16 +1193,6 @@ rather than implementation and belong here:
with the key and an EMPTY list, meaning *"a command went out under this key and core never learned
what it did"*. Answering that honestly is what makes an unattended world write recoverable; a
module that cannot answer it says so, and the row stays visible to an operator.
- **That key IDENTIFIES a dispatch; it is not a key to send on the undo.** It names the command core
lost the answer to, so the module can ask the game about it. Forwarding it as the outgoing key of
the reverting command is a different thing entirely, and on a game whose at-most-once store keys on
the key alone — as the uo-link shard's does — the undo is then recognised as a repeat of the DO and
answered with the original reply. `module-uo` made exactly this mistake: teardown of all five world
verbs was a no-op that reported success, because every despawn carried the key its spawn had gone
out under. Found by the Phase 16 acceptance walk, with the ledger reading `reverted` and the shard
still holding every object. A command that undoes needs a key of its own or none at all; a repeated
undo is usually harmless by construction ("already gone" is a success), which is what makes *none*
the right answer more often than not.
- **Core owns cleanup, and it is derived rather than authored.** There is no `on_teardown` on an
action and no cleanup phase in a spec: an operator cannot be relied on to write the undo, and an
aborted run never reaches the phase they wrote it in. Cleanup is one sweep over the ledger and it

View File

@@ -169,8 +169,6 @@ website/
│ │ ├── lib/
│ │ │ ├── adminNav.js
│ │ │ ├── engagementRules.js
│ │ │ ├── eventAuthoring.js
│ │ │ ├── eventCalendar.js
│ │ │ ├── format.js
│ │ │ ├── heroLayout.js
│ │ │ ├── moduleAdmin.js
@@ -218,11 +216,6 @@ website/
│ │ │ │ │ ├── EngagementSuppressions.jsx
│ │ │ │ │ ├── EngagementTemplates.jsx
│ │ │ │ │ ├── EngagementTriggers.jsx
│ │ │ │ │ ├── EventActions.jsx
│ │ │ │ │ ├── EventEditor.jsx
│ │ │ │ │ ├── EventRun.jsx
│ │ │ │ │ ├── EventsAdmin.jsx
│ │ │ │ │ ├── EventsCalendar.jsx
│ │ │ │ │ ├── HeroEditor.jsx
│ │ │ │ │ ├── InvitesAdmin.jsx
│ │ │ │ │ ├── Moderation.jsx
@@ -253,7 +246,6 @@ website/
│ │ │ │ ├── ForgotPassword.jsx
│ │ │ │ ├── PlayerAccount.jsx
│ │ │ │ ├── PlayerAppeals.jsx
│ │ │ │ ├── PlayerEvents.jsx
│ │ │ │ ├── PlayerInbox.jsx
│ │ │ │ ├── PlayerLogin.jsx
│ │ │ │ ├── PlayerNotifications.jsx
@@ -266,9 +258,6 @@ website/
│ │ │ ├── public/
│ │ │ │ ├── About.jsx
│ │ │ │ ├── CmsPage.jsx
│ │ │ │ ├── EventPage.jsx
│ │ │ │ ├── Events.jsx
│ │ │ │ ├── EventSeries.jsx
│ │ │ │ ├── FiveOnFriday.jsx
│ │ │ │ ├── Maintenance.jsx
│ │ │ │ ├── News.jsx
@@ -290,8 +279,6 @@ website/
│ │ ├── apiClient.test.js
│ │ ├── emailTemplates.test.js
│ │ ├── engagementRules.test.js
│ │ ├── eventAuthoring.test.js
│ │ ├── eventCalendar.test.js
│ │ ├── featureGate.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
@@ -365,7 +352,6 @@ website/
│ │ │ └── validateBlocks.js
│ │ ├── config/
│ │ │ ├── brand.js
│ │ │ ├── coreEventActions.js
│ │ │ ├── coreStreams.js
│ │ │ ├── coreTriggers.js
│ │ │ ├── csp.js
@@ -407,18 +393,6 @@ website/
│ │ │ ├── suppressions.js
│ │ │ ├── templates.js
│ │ │ └── templateSeeds.js
│ │ ├── events/
│ │ │ ├── announce.js
│ │ │ ├── authorize.js
│ │ │ ├── cleanup.js
│ │ │ ├── dispatch.js
│ │ │ ├── gates.js
│ │ │ ├── ledger.js
│ │ │ ├── participants.js
│ │ │ ├── price.js
│ │ │ ├── recurrence.js
│ │ │ ├── spec.js
│ │ │ └── verify.js
│ │ ├── middleware/
│ │ │ ├── botScore.js
│ │ │ ├── loginProtection.js
@@ -468,25 +442,6 @@ website/
│ │ │ │ ├── engagementSuppressions.db.js
│ │ │ │ ├── engagementTemplates.db.js
│ │ │ │ └── engagementTemplates.model.js
│ │ │ ├── events/
│ │ │ │ ├── eventActionSettings.db.js
│ │ │ │ ├── eventCalendar.model.js
│ │ │ │ ├── eventDefinitions.db.js
│ │ │ │ ├── eventDefinitions.model.js
│ │ │ │ ├── eventJson.js
│ │ │ │ ├── eventPhaseGates.db.js
│ │ │ │ ├── eventPublic.model.js
│ │ │ │ ├── eventRunBudget.db.js
│ │ │ │ ├── eventRunControls.model.js
│ │ │ │ ├── eventRunLog.db.js
│ │ │ │ ├── eventRunParticipants.db.js
│ │ │ │ ├── eventRunResources.db.js
│ │ │ │ ├── eventRuns.db.js
│ │ │ │ ├── eventRuns.model.js
│ │ │ │ ├── eventRunSteps.db.js
│ │ │ │ ├── eventSeries.db.js
│ │ │ │ ├── eventSeries.model.js
│ │ │ │ └── eventVersions.db.js
│ │ │ ├── invites/
│ │ │ │ ├── invites.db.js
│ │ │ │ └── invites.model.js
@@ -604,8 +559,6 @@ website/
│ │ │ │ │ ├── emailConfig.controller.js
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── imageUpload.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── invites.controller.js
@@ -654,8 +607,6 @@ website/
│ │ │ │ ├── player/
│ │ │ │ │ ├── appeals.controller.js
│ │ │ │ │ ├── appeals.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── teamForum.controller.js
│ │ │ │ │ ├── teamForum.router.js
@@ -664,8 +615,6 @@ website/
│ │ │ │ ├── public/
│ │ │ │ │ ├── engagement.controller.js
│ │ │ │ │ ├── engagement.router.js
│ │ │ │ │ ├── events.controller.js
│ │ │ │ │ ├── events.router.js
│ │ │ │ │ ├── index.js
│ │ │ │ │ ├── modules.controller.js
│ │ │ │ │ ├── modules.router.js
@@ -697,7 +646,6 @@ website/
│ │ │ ├── engagementEmit.js
│ │ │ ├── engagementRetentionPrune.js
│ │ │ ├── engagementWorker.js
│ │ │ ├── eventRunner.js
│ │ │ ├── forumHtml.js
│ │ │ ├── htmlShell.js
│ │ │ ├── logger.js
@@ -770,27 +718,6 @@ website/
│ │ ├── engagementRetentionSql.test.js
│ │ ├── engagementTemplatesAdmin.test.js
│ │ ├── engagementTriggers.test.js
│ │ ├── eventActionRegistry.test.js
│ │ ├── eventAnnounce.test.js
│ │ ├── eventAuthorize.test.js
│ │ ├── eventCleanup.test.js
│ │ ├── eventGates.test.js
│ │ ├── eventIntegrations.test.js
│ │ ├── eventLedger.test.js
│ │ ├── eventModuleContract.test.js
│ │ ├── eventParticipants.test.js
│ │ ├── eventPrice.test.js
│ │ ├── eventPublic.test.js
│ │ ├── eventRecurrence.test.js
│ │ ├── eventRunControls.test.js
│ │ ├── eventRunner.test.js
│ │ ├── eventRunnerSql.test.js
│ │ ├── eventsAdmin.test.js
│ │ ├── eventSchedule.test.js
│ │ ├── eventSeries.test.js
│ │ ├── eventSpec.test.js
│ │ ├── eventsRoles.test.js
│ │ ├── eventVerify.test.js
│ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js
│ │ ├── inviteController.test.js