Commit Graph

189 Commits

Author SHA1 Message Date
0a61915e50 docs(android): the app's events screens, and the module rows that were never gated (Phase 14b)
Events Phase 14b, docs half. `docs/android/PLAN.md` gains **M13** — written before
the code, as the plan requires — and `EVENTS_PLAN.md` and `EVENTS.md` record 14b
as built.

M13 records the six org-lead decisions, the two defects the milestone fixes, and
the three the emulator walk found.

The prerequisite fix is the larger half of the milestone: the app could not tell a
module that is not installed from a lookup that failed, so on a site with no `uo`
module every shard row rendered and every one of them 404'd. **Absence of an
answer is not an answer of absence** — a successful module list that omits the
string hides the rows, a failed read keeps the last answer the host gave, and a
host that has never answered leaves the gate open. Capability and feature are two
gates that compose and answer different questions.

A second, older defect is recorded and fixed: the website path → route table had
been wrong since the module-system cutover on 2026-08-12, because core's `NAV` is
eight rows rather than sixteen and the nine shard rows moved to `/uo/*`. Two tests
in the app's suite had been passing vacuously since that day.

§5 gains the capability gate beside the feature gate, with the two failure
directions spelled out; §6.1 gains the three public event reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 13:09:02 -05:00
ed311a2e8d docs(website): the three defects the walk found, and the mount that answered one
EVENTS.md §I records that participation history is mounted twice and why --
the third screen to need the notificationPaths.js mapping, for the reason
the first two did -- and the three defects the live walk found with both
suites green on either side of it. EVENTS_PLAN.md says the same in one
paragraph under Phase 14a's verify line.

Two of the three were one mistake in two files: a split reading a STATUS
where it should read a clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 11:59:23 -05:00
9aee5920af docs(website): the public surface, and the flag that decides what reaches it (Phase 14a)
EVENTS.md gains what the phase settled -- a new §I section on the public
surface, the four API surface rows filled in, `listed` in §D, and the §J
rows for player profiles and mobile. EVENTS_PLAN.md records the 14a/14b
split and 14a as built. BACKEND_DESIGN.md points at the two tiers the
Phase 14a reads live on. MODULE_API.md records core's own capability list
beside a module's -- the same word, a separate list, and why.

Two things this phase corrected in the document rather than in code.

"Venue" was never a field. §I's screens table and the API surface table had
both described one since the first revision; there has never been a column,
a spec key, an input on Phase 13's form, or a string anywhere in either
repo. Rather than add a field on the way past to a public page, both
descriptions dropped it.

And the six public triggers' missing url variable, which this document has
carried as a promise since Phase 10, is now kept: `eventUrl`, carrying
`?run=`, arriving with the page it points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:55 -05:00
303a52a2a0 docs(website): the authoring UI proper, and the one route it needed (Phase 13)
EVENTS.md I gains what the phase settled -- the rule by which a form gives way to
the JSON box, why the condition builder is the engagement one rather than a
second one shaped like it, and the live cap meter as a fifth affordance beside
the dry run it must not be mistaken for. The screens table's two unfinished rows
are marked built; rehearsal is marked reachable, which it had not been since
Phase 10 shipped it.

The API surface table gains POST /admin/events/price, and a paragraph on why it
is a route rather than arithmetic in the browser: cost() is a server function of
params, and the dry run both dispatches and records, neither of which belongs
behind a keystroke.

EVENTS_PLAN.md carries Phase 13 as built, with the four decisions, the two
defects it closes in already-merged code, and the six things the build settled.

BACKEND_DESIGN.md's /events/* rows are Phase 1's surface and have not moved
since; a note says so and points at EVENTS.md as canonical rather than
re-listing eleven routes that file already owns.

Checks: docs has no CI. Every relative link and in-page anchor in the new
sections was resolved by hand; the files are CRLF in the working tree and each
diff is content-sized (8/0, 64/5, 67/0), so none carries the \r\r\n full-file
rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 16:35:04 -05:00
4169a0df83 docs(link): protocol 7 part b -- what an event borrows, and the one-shots
`link/v7.md` gains §11-§14: the two targeted lease planes, the two one-shots, the
routes, and what the build found in already-merged code. `EVENTS.md` §G's five
part-b rows are marked built, three of them carrying a correction. `MODULE_API.md`
records the three contract members 12b amends into 1.10.0. `EVENTS_PLAN.md` has
Phase 12b as built.

THE DESIGN POINT, WRITTEN DOWN

A borrowed value whose home is the world save does not come back on a restart the
way a config value does. 11b's fail-safe -- a lease that never reaches disk makes
a restart a free restore -- depends entirely on the leased value being
memory-only too, and for a spawner property or a seasonal status it is not: a
restart preserves the CHANGE and destroys only the timer that would have undone
it. So those two planes' holds are persisted and their deadlines re-armed, and
the config plane's still are not. The same argument, applied where its premise is
false.

FIVE CORRECTIONS TO EVENTS.md

- `Spawner.Amount` does not exist. The property is `MaxCount`, and
  `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries seconds.
- The seasonal toggle is not "small and safe". Safe, yes -- ServUO does it to
  itself from a staff gump -- but `OnStatusChange()` generates or removes world
  content for six of the eight permitted types.
- It is a THREE-value enum over nine named events, not a nine-value enum.
  (Caught in 12a's survey; the row is corrected here now it is built.)
- `TreasuresOfTokuno` is excluded, because `IsActive()` reads its own `DropEra`
  rather than `Status`. A lease on it applies cleanly, reads back, restores
  cleanly and changes nothing -- §N10's "capability that lies", and the one
  instance no runtime probe can catch.
- The grant row said failure aborts rather than retries. Protocol 6 changed that:
  an idempotency key means a repeat is answered by the original reply, so a
  retried grant cannot be one winner receiving two.

And the config-lease row is closed at one key. Counted on ServUO 57.4: 156
non-Bridge `Config.Get` call sites, 82 outside a field declaration, all but four
of those inside a `Configure()` or static constructor and cached at boot anyway.
The self-check ships regardless -- it exists for the operator whose OWN scripts
read config live.

MODULE_API 1.10.0, AMENDED IN PLACE

`target` on a lease declaration, `values` on a string lease, and `searchable` +
`{ q }` on option sources. Amended rather than bumped for the reason every phase
since P10 has: 1.10.0 has never reached `main`, so no deployment can tell the
difference, and the cutover is what publishes the whole of it.

The `target` entry records why this extends `core.lease` rather than giving the
module a lease verb of its own: §F settled that in Phase 8, and half its
objection no longer holds (the two-events-one-target refusal comes from the
ledger's unique index whichever verb reserves the row) while the other half --
`maxDurationMs` re-implemented per module -- still does.

§14 records the protocol-pin defect 11a and 12a both shipped, and why the test
that guards it passed anyway: it asserts the three declarations agree with each
other, which all three being equally stale satisfies.

CHECKS

`docs` has no CI. Every relative link in the new sections was resolved by hand;
the files are CRLF in the working tree and each diff is content-sized
(`--numstat` matches the real change), so nothing carries the `\r\r\n` full-file
rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:08:35 -05:00
c53cf9fce5 docs(link): protocol 7 -- what an event owns, and the split that carries it
`link/v7.md` is the spec of record: one command family for five verbs, the
persisted ownership registry and why it is forced rather than chosen, the oracle
that reuses ServUO's own dialogue vocabulary without its command-scripting
field, and the decoration index.

`EVENTS_PLAN.md` splits Phase 12 into 12a and 12b (org lead, 2026-09-07), on the
line section G already draws between what an event OWNS and what it BORROWS. Ten
verbs, four repos and a protocol bump is one review and one walk too many, and
the halves prove different things. One protocol version across both, the 11a/11b
shape.

Three corrections the survey forced on `EVENTS.md`:

The config lease catalog does NOT grow in 12b, and the plan's promise of "the
rest of the allowlist" was written before anyone counted. Measured on ServUO
57.4: 156 non-Bridge `Config.Get` call sites; 82 sit outside a field
declaration, but all but four of those are inside a `Configure()` or a static
constructor and are cached at boot exactly as the field initialisers are. The
live, event-useful reads are `PlayerCaps.SkillCap` (11b shipped it) and vendor
bribe decay. So 12b's lease work is object-property leases.

Section G calls the seasonal toggle "a nine-value enum". `EventStatus` has THREE
values; it is `EventType` that has nine entries. The verb is a three-state
toggle over nine named events, which is a different form to author and to cap.

Section G costed the reopening at one protocol bump. It is two: the idempotency
guarantee had to land before the verbs that depend on it, which is v6's own
argument.

`README.md` gains rows for v6 and v7. v6's was missing -- the index has been one
protocol behind since 11a.

Refs: EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:52:34 -05:00
eabaf2635f docs(link): protocol 6 part b -- leases, participation, and what the walk found
Six files. `v6.md` gains sections 7-10: one version with two halves, the lease
plane, the participation ledger, and 11b's verification.

Three corrections to what the plan and the design of record assumed.

Phase 11b is FIVE repos, not four, and in the opposite direction from 11a's
correction: a lease's ledger row had no reconcile path anywhere, because the step
that made it names `core.lease` and that is core's own action. `website` joins.

EVENTS.md's §D frames the 258 `Config.Get` call sites as splitting into two
patterns. Measured on 57.4: of the 158 non-Bridge sites in `Scripts/`, roughly
eight are read live. The allowlist is not a curated subset of a large pool, it is
nearly the whole of what exists. And `Config.Set` has exactly one caller in the
entire tree, so on a stock shard a GM cannot drift a configuration lease even
deliberately -- which is why proving `drifted` needed a scaffolding verb.

§G's "participation attribution is now the largest piece of new UO work" closes,
and the live-config-lease row goes to built-with-one-key.

§10.1 records the defect the phase's own deferral found in 11a's shipped code:
`bridge.busy` answered 200 instead of 425 because the frame carried two `kind`
fields and parsers take the last. Unreachable in 11a by construction; produced on
the first collision here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:32:11 -05:00
8b4096dd18 docs(link): protocol 6 as built — a guarantee, and the kind that pays for it
`link/v6.md` is the spec of record. It is the first bump that adds a promise
rather than data: a command carrying an `idempotencyKey` is executed at most once,
and a repeat is answered with the original reply.

Also in this PR:

- `INTEGRATION.md` — §2 gains v6, the write plane gains a "retrying a command
  safely" section with the four rules a caller needs, §7 gains 425 and the
  paragraph on why it is not 409, and the event catalog gains `champ.boss.killed`.
- `PLAN.md` — §5.5 records that a per-KILL damage table needs no override even
  though a per-HIT event does, which is the observation the new kind rests on;
  §7 documents the key on the inbound frames.
- `EVENTS.md` — §A row 7 and two §G capability rows close, and the failure table's
  "an action succeeds but the ack is lost" loses its "does not exist today".
- `EVENTS_PLAN.md` — Phase 11 splits into 11a and 11b, and `installer` leaves the
  phase: PLAN.md §7.4 made sure no protocol version is hardcoded there, so the
  bundle gate is version-agnostic and needed no change for 5 either.

The live walk's defect is written up in v6.md §6.1 because the naive check
confirms it: an active ChampionSpawn registers an unnamed region over its own
spawn area, so the innermost region containing a champion boss is guaranteed to
have no name — and region registration is deferred, so a lookup at spawn time
answers "Britain" while one at the kill does not.

CRLF preserved throughout; links and anchors checked by hand, this repo has no CI.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:57:42 -05:00
e12cc0dbb1 docs(events): Phase 10 as built — the integrations, and a ceiling per firing
Five files. `EVENTS.md` §F gains "What Phase 10 settled" and §D/§J are corrected
to what was built; `EVENTS_PLAN.md` records the phase as built with its six
decisions, the live walk and the three defects it found; `MODULE_API.md` folds
two envelope members into 1.10.0 in place; `ENGAGEMENT.md` §8.5's "come back for
X" row is resolved; `BACKEND_DESIGN.md` gains the eleventh event table and the
two columns Phase 10 put on tables that already existed.

The four contract findings, three of which are rules rather than facts about
events:

- a firing may carry a ceiling of its own and it may only ever NARROW, applied
  at the send-time G24 gate as meet(declared, emitted). §I promised a rehearsal
  "ceilinged to staff" and nothing implemented it;
- two incomparable ceilings refuse rather than resolve, which is §5.1a rule 3
  reused rather than re-argued;
- a module reports participants on the success envelope and there is no other
  door;
- a trigger that is not also a STREAM has no push and says nothing about it, so
  every stream must be a trigger while a trigger need not be a stream.

And a trigger's `description` is read by a recipient, not only by an operator:
it is the structural projection's `intro` fallback, so a line naming its own
audience ends up in that audience's inbox.

- [x] AI-assisted: Claude Code (Opus 5).

Code: RunicGateway/website#TBD.

Co-Authored-By: Claude <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-04 13:06:06 -05:00
9cca021c1f docs(events): Phase 9 as built — UO wave 1, and a budgetMs rule the phase found
`EVENTS_PLAN.md` Phase 9 as built, plus the two findings that are contract rather
than facts about UO.

- **MODULE_API.md §2.4: `budgetMs` must exceed the timeout of whatever the action
  talks to.** The dispatcher classifies a budget timeout as `retry`
  unconditionally and cannot ask the action, so an action whose own client gives
  up after core's deadline never classifies its own failure and `retry: false` is
  unreachable. The default (10s) is shorter than module-uo's sidecar client (12s),
  the wrong way round.
- **EVENTS.md §F gains "What Phase 9 settled, as the contract's first module
  registrant"**: the rule above; that `on_failure` is not a retry policy; that
  `reconcile()` can answer from a stamp rather than a query; and that a module's
  reconcile trigger must fire on a live signal, never a replay.

The plan block records the four org-lead decisions, the live walk, the three
defects it found — two of them data loss — and the Phase 8 defect in core it
reproduced and deliberately did not fix here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 07:38:50 -05:00
07512d846d docs(events): Phase 8 as built -- the resource ledger, leases and cleanup
The docs half of Event System Phase 8. Code: RunicGateway/website#NNN.

EVENTS.md

Section D's ledger row is corrected in two places and section L gains a new
subsection recording what the build settled:

- Rule 1 needed a mechanism, because a spawn's ref does not exist until the
  module answers. What goes in before the dispatch is a PLACEHOLDER keyed by the
  step's idempotency key, and that is why revert() takes the key at all.
- A LEASE does not use it and gets rule 1 in a stronger form: its target is the
  lease id the step already names, so `core.lease` writes the real row first --
  the only moment the two-events-one-target refusal can happen before the world
  has been written to.
- The unique key is held by three statuses and released by three (amended
  2026-09-03). "Among non-reverted rows" was written before the six statuses had
  their meanings; taken literally it makes `drifted` and `orphaned` hold a target
  for ever, so one bad night would disable a lease permanently with no control
  able to clear it.
- MariaDB has no partial index, so the encoding is a STORED generated column
  reading `status` ALONE -- TEAMS.md 2.5's correction, because MariaDB refuses
  ON DELETE SET NULL on a foreign key whose column is a base column of one.
- Cleanup is one sweep, not synthetic step rows, and it runs from one place.
- What that sweep SELECTS cost two live-walk defects in opposite directions, and
  the pair is the finding worth keeping: deriving "is there anything to do" from
  a summary column instead of from the rows stranded a lease outright, and then
  made the retry bound mean one attempt. Both are recorded with the reasoning.

Section F gains the lease's verb and the reconcile bullet; section I's "cancel
takes { reason } and not { cleanup }" becomes what shipped; section K's role
table puts re-running cleanup in the admin-only row; section L's cancel and
cleanup-fails rows are updated; Observability names the six new log kinds; and
the API surface table's absent-routes list is now empty.

EVENTS_PLAN.md

Phase 8 marked complete in the shape Phases 0-7 use: the four org-lead
decisions, the four things the build settled, the verified numbers, and the live
walk -- including the three defects only it could find and the two properties
that needed the process to die.

MODULE_API.md

1.10.0 gains `reconcile()` and `ctx.events.reconcile()` IN PLACE rather than a
new version. A protocol owes a bump once it has landed on `main`; while it is on
`edge` it is amended in place, which is the rule the Teams workstream arrived
at, applied to a module API for the first time. The rule list grows from six to
ten: the lease's verb is core's, `until` goes down the wire, revert is
idempotent and reverting something that does not exist is a success, revert is
also called with the key and an empty list, and reconcile is optional where
revert is required.

Checked by hand

`docs` has no CI, so: every anchor in EVENTS.md resolves (checked by generating
the heading slugs and diffing), and every route, identifier, env var and file
path named here exists on the website branch -- `core.lease`,
`core.options.leases`, `ctx.events.reconcile`, `EVENT_REVERT_MAX_ATTEMPTS`,
`event_run_resources`, `live_marker`, `uq_evres_target`, the six log kinds, and
the route in both routes.manifest.json and the OpenAPI spec. Diffs are 144/21,
139/16 and 67/9 with NO CRLF artefact, verified by comparing --numstat against
the real content diff rather than trusting it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-03 21:20:04 -05:00
426e9bfbfb docs(events): Phase 7 as built -- the module contract at MODULE_API 1.10.0
Code: RunicGateway/website#189.

MODULE_API.md gains 1.10.0 in three places: the number itself, the entry in
Part 1 with the four call shapes and the six rules that come with them, and
the four names in 2.4's call list with the contract-rather-than-
implementation notes beside them.

EVENTS.md F is marked built, with a new subsection recording what the build
settled -- the open unit vocabulary, the undeclared-dimension refusal and
why it has its own code, why example-pricing survives the arrival of a
budget registry, why restore cannot be read, and why an option source that
refuses answers 200. K's Phase 7 note becomes what shipped. The API surface
table's option-source row is filled in, and the absent-routes list is down
to cleanup.

EVENTS_PLAN.md: Phase 7 complete, in the shape phases 0-6 use -- the four
org-lead decisions, the three things the build settled, the throwaway-module
proof, the React defect only the browser could find, and the route the docs
caught the code building one segment shallower than this file specifies.

Checked by hand (docs has no CI): every anchor resolves, and every route,
identifier and file path named here exists on website#189.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 14:16:19 -05:00
aa8752ef65 docs(events): Phase 6 as built — enablement, caps and mayInvoke
EVENTS.md

§D — the two tables move from "arrive with a later phase" to built, with the
two facts that are not obvious from the column list: a missing settings row is
the risk-class default rather than "disabled", and a NULL cap is uncapped and
still a row, so a MISSING row keeps its one meaning. event_versions gains
verified_at/verified_by, and the note on why two mutable columns do not break
an immutable table.

§E — a new subsection, "Enablement and caps, in front of every dispatch": where
the check sits and why, the retry that does not pay twice (and the failure that
gets no refund), the partial spend that is unwound, the budget seeded from every
phase at creation, the tightest cap, and the one place a demoted user's access
deliberately is not re-checked.

§K — the amendment. "Nothing above notify" becomes "nothing that changes the
world", with the reason: read literally it shipped core.wait disabled. The same
line is the role floor. Plus mayInvoke as built — why user and run may be null,
why the cap check can write, why the role floor is in the model rather than in
middleware, and the example-pricing stand-in until registerEventBudgets lands.

§I, §L, § API surface and § Observability — the switchboard and the dry run
marked built; the two refusal rows and the held-occurrence row; the three new
log kinds and the run's fourth derived field; why the whole-plan cost check is
the one finding no other path makes.

EVENTS_PLAN.md — Phase 6 complete in the shape Phases 0–5 use: the four org-lead
decisions, the two derived calls flagged for review, the three things the build
settled, the one thing this phase deliberately cannot demonstrate with a module
(registerEventActions is Phase 7's seam), the two defects the live walk found,
the verified numbers and the walk itself.

Checked by hand — docs has no CI: every anchor resolves, and every route,
column, env var and file path named here exists on website#XXX. Diff is
151/23 and 118/2 with no CRLF artefact, verified against the real content diff
rather than --numstat alone.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 05:51:19 -05:00
957d662a2f docs(events): Phase 5 as built - conditions, phase advancement and the panel
EVENTS.md gains the eleventh table, a new SS-E subsection on advance conditions
and the two writers a gate has, the diagnosis panel in SS-I, the "a condition
never fires" row in SS-L, and the observability rows for the gate table and its
three new log kinds. The `advance` route leaves the not-built list; the catalog
route now serves triggers.

The SS-D count said "nine" over a list of ten from the revision that added
`event_series`; with the gate table it is eleven, and it now says so.

EVENTS_PLAN.md marks Phase 5 complete in the shape Phases 0-4 use: the four
org-lead decisions, the three things the build settled, the MariaDB
left-to-right SET evaluation defect that only a real database found, the live
walk, and the three defects that walk turned up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 22:11:30 -05:00
9410181560 docs(events): Phase 4 as built - schedule, recurrence and the calendar
EVENTS.md: the materialise leg as two halves and why the horizon is what makes
the missed sweep meaningful; the recurrence shapes as built, incl. the two DST
rules and why nth has no fifth; publishing as the schedule switch and the
re-pin; the calendar's run-vs-projection distinction and the
reader's-zone/event's-zone split; the four new routes; P1-P4 ticked.

EVENTS_PLAN.md: Phase 4 marked complete with the five org-lead decisions, the
three the build settled, the test-harness defect the phase introduced and
fixed, and the verified numbers.

Website: RunicGateway/website#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 16:10:50 -05:00
261cce6423 docs(events): Phase 3 as built — the minimal admin surface
EVENTS.md: the six controls that exist and the two that do not, and why;
retry as one control with its two guards; what a cancel does to a parked cue;
the run status re-read between steps; the two derived response fields.

EVENTS_PLAN.md: Phase 3 complete, the four org-lead decisions, and the three
things the build settled — including the mid-batch pause defect and the retry
guard that was reading the wrong end of the phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-02 08:39:52 -05:00
76b2276d36 docs(events): Phase 2 as built - the runner
The docs half of Event System Phase 2. Pairs with `RunicGateway/website`
`feat/events-phase-2`.

EVENTS.md gains what the runner settled: the parked step (`running` with a NULL
lease), the two success-envelope members `await: 'human'` and `holdFor`, the
answer for a run whose concurrency key is held, `n` in §L's `retry(n)`, the rule
that all three `on_failure` dispositions write the step `failed`, and the health
transition on the first retry rather than the eventual failure.

EVENTS_PLAN.md marks Phase 2 complete and records the four org-lead decisions
and the three things the build settled on its own.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 06:32:15 -05:00
9ba119dc5a docs(events): Phase 1 as built — schema, CRUD and the core action registry
The docs half of RunicGateway/website#<n>. Three files.

**BACKEND_DESIGN.md** gains the six event tables, column by column, and the
eleven admin routes. Written where the other table groups are, in the same
shape, because the argument for a column belongs beside the column.

**EVENTS.md** records four things the build settled that §D and §F had left
open:

  - `event_definitions.spec`, the working copy. §D's column list does not name
    one because §D describes what a PUBLISHED event is made of — but "editing a
    draft is free; no version exists yet" means the draft has to live somewhere,
    and it cannot be an `event_versions` row: that table is immutable and a run
    pins one, so a mutable unpublished row in it would be exactly what
    versioning exists to prevent.
  - A param's `example` is REQUIRED, on optional params too, the same rule
    `registerEventTriggers` makes of a variable's example and for the same
    reason: it is the authoring form's placeholder, one word at declaration
    time and unreconstructable afterwards.
  - The authoring side of dormancy. §F said what happens at DISPATCH; the save
    path draws the same line one step earlier, in the shape `engagement_rules`
    established — a saved step may keep an unregistered action, a new step may
    not add one, and a dormant step blocks the publish rather than the save.
  - Publish re-validates against the registries as they stand at that moment,
    not from the save that wrote the spec.

Plus two routes the § API surface table did not name — `GET /admin/events/:id`
(the list serves a summary; the editor needs the tree) and `GET
/admin/events/series` (a form cannot offer a value it cannot enumerate) — and a
note stating which of that table's rows Phase 1 deliberately did not build.

**EVENTS_PLAN.md** marks Phase 1 complete, names those four settlements, and
states the two deliberate absences so a reviewer does not read them as gaps:
the live run controls are not stubbed, and core's three `perform()` bodies
answer `{ ok: false }` rather than `{ ok: true }` — `ok: true` on an action
that did nothing is a recorded world change that did not occur.

`api-route-inventory.json` is NOT resynced here. It has been stale since
engagement Phase 2 and is 47 routes behind; catching it up in this PR would
bury a 13-route change under an unrelated 47.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 23:28:26 -05:00
cc6775e20b docs(events): Phase 0 — the eleven decisions, and the two documents they reach into
Closes the second half of EVENTS_PLAN.md Phase 0. All of EVENTS.md §N1–N11 were
answered by the org lead on 2026-09-01, before any code, which lifts the ⚠ gate
from P11 and P12 and unblocks P1.

Nine went the way §N recommended. Two did not, and each changes a phase:

- N2 — publishing a version and starting a run are `admin` only; a moderator
  keeps live control of a run already in flight (cancel, abort) and nothing
  more. Start and stop are deliberately not the same gate: starting commits the
  deployment to everything a definition contains, unattended, while cancelling
  is incident response. §K's table and the API surface table are updated, and
  P3 and P6 carry it.
- N4 — this deployment is single-instance and not planned to change, so P2 does
  not build the `--scale app=2` test. Every claim path in §E is built exactly as
  specified regardless — the unique index and the CAS equally protect a tick
  that overruns into the next one, and the lease and its reclaim recover a step
  whose process died mid-dispatch. §E records what to build first if this
  deployment is ever scaled.

N1 was taken in full, item grants included, which is a reversal of two separate
lines in ADMIN_CONTROLS.md §8 rather than one. §8's cut list names "item/gold
grants" explicitly and separately from world building, so the new §8a amendment
says both out loud rather than letting the grant ride along on the creation
reopening. §8's bullet is left standing verbatim — it is the decision that was
actually taken — with a pointer to the amendment, and §1's thesis paragraph gets
the same pointer. Loot-table changes and arbitrary [set / [get / [add stay
permanently excluded, along with the rest of the Tier-H moderation verbs.

N3 gives ENGAGEMENT.md §7.1 Q6's "no evaluator yet" an owner: the Event System
runner becomes the `kind: 'scheduled'` evaluator, built in that workstream's P2.
Nothing in the engagement contract changes.

One consequence was derived rather than decided and is flagged for review:
POST /admin/events/:id/verify moves to `admin, editor` rather than staying with
`start`. A dry run dispatches nothing, and the author who wrote the definition
is who should price it against the caps before asking an admin to publish.

Verified: every relative link and anchor in the four touched files resolves,
including the renamed §N anchor (#n--decisions) and the new #8a. The docs repo
has no CI workflow, so the plan's "docs CI link check" was run by hand.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 20:52:37 -05:00
cda5325ab4 Merge pull request 'docs(events): the Event System design of record and its phased plan' (#207) from docs/events-system into main
Reviewed-on: #207
2026-09-02 01:32:50 +00:00
0aeb54d9a0 docs(events): the Event System design of record and its phased plan
Two documents for a game-agnostic Event System: an engine for scheduled,
bounded, audited changes to a live game world, driven by the staff who
already run the site.

EVENTS.md is the design of record. It surveys what the eight repos already
provide, records what they do not, and proposes the architecture: core owns
the engine, a module owns the meaning, and the seam is declaration plus
dispatch rather than a string core interprets. Two findings shape it. An
event does not edit the world, it holds a LEASE with a game-side deadline
and a compare-and-set restore, so baseline returns even if the website never
comes home. And a reward is an ordinary module action, optional per module,
whose reversibility is the module's business.

EVENTS_PLAN.md decides order: seventeen phases, what each ships on its own
merit, how each is proved, and the traps in each. Fourteen of them reach the
game only to announce, over verbs the write plane already carries, and need
no decision beyond P0; only P11 and P12 are gated on the ADMIN_CONTROLS.md
Section 8 amendment.

This discharges the first half of P0. The decisions -- N1 through N11, and
the Section 8 amendment itself -- remain, and they are the part that gates
the two world-changing phases.

Every codebase claim was read from the working trees on 2026-09-01. Where a
document and the code disagreed, both are recorded rather than quietly
resolved: ARCHITECTURE.md places the SSE fan-out in core when it is entirely
module-uo's, and rust-dryrun.md asserts the Android app feature-detects on
/public/modules when it hardcodes a module path instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-01 20:29:04 -05:00
runic-docs-bot
17b3f424f8 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@6331b36 [skip ci] 2026-09-01 20:57:26 +00:00
5069218c14 docs(engagement): name Phase 14's three PRs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 15:42:58 -05:00
fdd5bf36f7 docs(engagement): Phase 14 as built — retention
The last phase of the workstream. ENGAGEMENT.md gets Phase 14's as-built
(the six org-lead decisions, three findings, the acceptance line discharged
and one rig trap), and its header now reads COMPLETE rather than "Phase 14
is scoped and not started".

BACKEND_DESIGN.md gets the reference: a new "Engagement retention" section
with the horizon table, why the outbox sweep is terminal-only, why each
floor is a correctness bound rather than a UI nicety, why suppressions do
not expire, and why this one got a screen when team_activity and
user_notifications did not. Plus the three new routes in the admin table and
the address_hash reversal on GET /suppressions.

Three findings worth not re-deriving:

  * the per-row purge could not be built without reversing Phase 9's
    decision to strip address_hash — the row had no handle, and the existing
    lift asks for an address the screen has never shown.
  * reclaimStale could not give up, so an interrupted send never became
    terminal and no retention sweep could ever have bounded the outbox.
  * the cooldown warning is unreachable through the UI by construction
    (MAX_COOLDOWN_SECONDS is smaller than the horizon's floor). It is not
    dead: it catches a hand-edited row, and a future raise of that ceiling.

And one the phase text got wrong in the other direction: the Play Data
Safety answers correctly did NOT move, because deploy-engagement is
deployment-scoped and PLAY_DATA_SAFETY.md is generated from the app-scoped
entries only.

Code: website#TBD.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 15:41:13 -05:00
b3dd2c7cbb docs(engagement): the two walk defects, fixed -- and what digest is
Follows docs#203, which recorded the two defects Phase 13's acceptance walk found
and left them for placing. They are fixed in website#181, so Phase 13's as-built
says so and says what the fix was rather than leaving a reader to find the PR.

Two other files were describing a contract the code did not honour, which is why
the second defect could exist at all:

  * BACKEND_DESIGN's `template_keys` column row said only that keys are
    shape-checked. It now says the one thing that was actually load-bearing --
    `digest` is a key that is NOT a channel, because it names a MODE's body --
    and that the rules validator rejected it until website#181, which made every
    rule shipping one unsaveable from the Rules screen, core's own included.
  * MODULE_API 2.4 already told modules they may point `template_keys` at
    `notify.digest` and never said what `digest` was. A module author reading it
    would reasonably have expected it to be a channel. One bullet, next to the
    generic-bodies one that sends them there.

No contract changed; the docs were right and the code was wrong. No
MODULE_API_VERSION bump for the same reason.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 14:31:00 -05:00
cff73ad863 docs(engagement): the Phase 13 acceptance walk, and the two defects it found
The walk ran and PASSED. Released artefacts only: the released installer,
checksum-verified, resolving bundle 2026.09.01; the overlay compiled against
stock ServUO 57.4 with zero errors; a fresh `main` clone of website with
module-uo v1.1.0 installed from its release manifest rather than copied in.
Forcing a house to Greatly produced exactly one email and one in-app item to the
linked owner and nothing to anyone else -- every row in every delivery table
names that one user.

Two defects, both in shipped core, neither visible to any test:

  * `seedRuleGroup()` reads its settings stamp, inserts the whole group, and
    writes the stamp AFTER the loop. Two processes booting in the same moment
    both read "not seeded" and both insert -- 52 UO rules where the module ships
    26, under one stamp. The function's own comment names the cost: a duplicate
    rule is two mails per event. A `--scale app=2` or a rolling restart does this
    on purpose.
  * `checkSeedRule` permits a `digest` template key in as many words, and
    sixteen in-universe UO rules ship one; `engagementRules.model.js` then
    rejects any templateKeys entry that is not one of the rule's channels. So an
    operator who opens "House - decay warning" and presses Save gets a 400
    naming a key they never typed, and the only way to save is to delete the
    digest body.

Neither is fixed here -- this PR documents a cutover, and a core change belongs
in its own.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 13:47:46 -05:00
5f5ca41d9e docs(engagement): Phase 13 as built, and Phase 14 -- retention
Phase 13 was the only phase in the workstream with no as-built section, which
6.0b makes a phase's own deliverable. It has one now, and it has something to
record beyond "it merged":

  * the window missed steps 5 and 7 (Integration-kit, runicgateway.com); they
    landed afterwards, with a third PR clearing a leftover from step 4;
  * Gitea's `raw` API route is CDN-cached for six hours, and a stale read fails
    BOTH ways -- it failed a correct site AND reported an already-republished
    bundle as current. All three cross-repo checks read `contents` now;
  * the Integration-kit equality check never goes red on its own. It clones the
    ref the kit itself pins, so the kit sat three minor versions behind, green,
    for the whole workstream. Step 5 is the only thing that makes anyone look;
  * `registerEngagementSeeds` does not validate the body it seeds, and the kit's
    own example was malformed. Only a run through core's REAL registry found it,
    which the kit's CI does not do and cannot;
  * every `edge` was deleted rather than fast-forwarded. Recorded as the
    convention, because a branch that does not exist cannot be branched from
    stale -- which was 6.0a's actual failure.

Phase 12's second finding becomes **Phase 14**, scoped and not started: four
tables with no retention sweep, and they are not one problem with one horizon.
`engagementCooldowns.db.prune()` already exists with no caller; the send log has
two live readers (the hourly cap's index and the operator screen) that bound how
short its horizon may be; and suppressions probably should NOT expire at all,
because ageing out a bounce re-mails an address that already hard-bounced. The
phase owes `/privacy` and the Play answers, which today say "nothing here expires
on its own" -- true now, false the moment it lands.

Also: ticks on the phase headings that were done and unmarked (4, 9, 10, 11, 12,
13), a status header that still said everything from Phase 10 on was design, and
three status lines that outlived the cutover -- `link/v5.md` and this README's
index row both said protocol 5 was built but unreleased, and INTEGRATION.md named
the v2.0.0/v1.0.0 pairing as the shipped one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 13:17:08 -05:00
runic-docs-bot
4f164449e4 docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@66bb3b9 [skip ci] 2026-09-01 13:58:11 +00:00
c57c0e6b37 docs(website): the engagement release note -- three things that arrive switched off
The cutover's operator-facing note, in the home Phase 1 created. Phase 13 owes it, and
two earlier phases named it as their own obligation: Phase 6 for the Team rules
(decision 3) and Phase 11 for news.post (7.1 Q9). Both stop mail or push that is
sending today, and neither says anything when it does.

Three required actions, ordered, because the order is load-bearing:

  1. Update the shard. The protocol pin lives in the UO module's schema, so it is the
     MODULE update -- not the core one -- that moves uo_link_config.protocol to 5 and
     starts the site sending X-UOLink-Version: 5. A shard still on 4 answers 409 from
     that boot on. Named explicitly, along with the out: update the site and leave the
     module until the shard host can be reached.
  2. Team email arrives disabled -- four rules, the screen's own banner, and the fact
     that only email moved (the tickle and the Discord bridge are untouched).
  3. News push stops until the seeded rule is switched on.

Four behaviour changes that need no action: the unsubscribe link now narrows to one
channel rather than muting outright, email and push are opt-in while the in-app inbox
is on, a rule is checked against its trigger's audience ceiling, and a bounce now
suppresses an address with Suppressions the only screen that lifts one.

Two claims were checked against the tree rather than the plan, and one of them was
wrong first time: coreApi is enforced at LOAD, not at install, so an old core does not
refuse the 0.5.0 install -- it accepts it and then fails to load the module. The note
says that, and says the restart is required, because modules are read from disk at boot.

The Data section carries Phase 12's finding 2 rather than burying it: nothing in this
schema expires on its own.

AI-assisted: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 08:35:38 -05:00
e5c0a0c64f docs(engagement): Phase 12 as built -- the 12a/12b split and three findings
Records Phase 12 against runicgateway.com#26 (12a, mergeable) and #27 (12b, a draft held
for the cutover window).

The split is the org lead's decision, and building the phase sharpened the reason the
timing note only gestured at: checkFacts.mjs fetches from `main`, so the moment
platform.json says protocol 5 the repo is red for the whole edge window -- and three of
its values cannot be written today at all, because bundle.tag/sidecar/overlay name an
artefact that does not exist until Phase 13 republishes it.

Phase 13's order gains the fill-in step, written to be mechanical rather than remembered:
the check names its own expected values, so it is "run checkFacts and copy the says
column", plus the two pages that quote the bundle to re-read first.

Two of the phase's own statements were already stale. moduleApi is 1.9.0, not 1.7.0
(Phase 11 moved it twice after that sentence was written), and Phase 7 had already fixed
the capabilities.mjs "Web" claim. Phase 13's step 3 corrected to 1.9.0 as well.

Three findings, none of them in the phase's text:

  1. /privacy had been publishing a false sentence since Phase 8 -- app-content claimed
     nothing is cached offline, and Phase 8 shipped a DataStore snapshot of the inbox.
     That row is an INPUT to the generated Play Data Safety answers, so it is a store
     review matter rather than a doc nit.
  2. Nothing in the engagement schema has a retention sweep. cooldowns' prune() has no
     caller, finished outbox rows are never deleted, and the send log and suppression
     list grow forever. Recorded rather than fixed -- a sweep is a website change and
     outside this phase.
  3. The one page whose whole subject is the protocol number was the one page that
     hardcoded it, against platform.json's own rule. Plus the trap for whoever does this
     again: MDX does not evaluate an expression inside backticks.

AI-assisted: written with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 08:16:51 -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
dc7d7c9a40 Merge pull request 'docs(engagement): scope Phase 11 — six decisions, three carve-outs, the 11a/11b split' (#194) from docs/engagement-phase-11-decisions into edge
Reviewed-on: #194
2026-09-01 05:05:39 +00: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
77c615bc69 docs(engagement): scope Phase 11 — six decisions, three carve-outs, the 11a/11b split
Phase 11 was widened on 2026-08-31 to ship every  row of §8.6 rather than a
single rule, and the phase text asked for its 11a/11b split to be confirmed with
the org lead before any code. Confirming it turned up five further questions the
plan had not asked, three of which change what ships — so this records all six
before implementation starts, on Phase 2's precedent.

The three carve-outs all came from checking §8.6's rows against what the WEBSITE
can address rather than against what the wire carries:

  • uo.guild.joined duplicates core's team.member.joined — a UO guild IS a Team
    and teamSync already emits on every roster reconcile, so an operator who
    enables the obvious two rules gets two mails for one join. `left` and
    `disbanded` still ship; core has no equivalent for either.
  • uo.link.requested has no addressable recipient by construction (the account
    is not yet linked) and a ~5-minute TTL no channel can beat.
  • uo.points.rank_changed ships `subscribers` only: points.board's top[] names
    a mobile serial and shard_account_links is keyed by account, so the personal
    half would reach some players and silently not others.

The other three:

  • The lattice gains `admin`, as a child of `staff` — the operator-facing family
    is described as admin-audience everywhere and `staff` is admin/editor/
    moderator, so ceilinging it there would mail the staff audit digest to every
    editor. It is the only genuine refinement in the tree, so permits/meet/
    meetAll are untouched. MODULE_API_VERSION → 1.8.0.
  • §7.1 Q9 (news.post continuity): the emit REPLACES the raw tickle and core
    seeds the rule disabled — the Phase 6 precedent. News push stops on upgrade;
    the admin banner and the release note are the mitigations, and both are named
    as phase deliverables.
  • The split is confirmed and specified: 11a is declarations/mappings/ceilings,
    server only, no rule or template seeded; 11b is the seeded set and the live
    walk.

Also records the threshold-trigger mechanism uo.vendor.expiring and
uo.economy.milestone need — rule conditions compare against literals only, so
"within 24 hours of dismissal" is not expressible as a condition and the crossing
is tracked module-side — and names module-uo's first three registered audiences.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:47:05 -05:00
6304c42dfb docs(link): protocol 5 — the decay schedule, vendor fee state and account.login.result
Design of record for the bump, plus the reference updates it obliges.
Engagement Phase 10, as built.

link/v5.md is new. Three enrichments in one bump, because a bump costs a sidecar
release, a republished bundle and an operator update on every shard — a field left
out costs a whole second round of that, not a follow-up commit.

  a. house.decay gains ownerName and a nested `schedule`. estimatedCollapse is
     present ONLY where it is exact: under dynamic decay each stage's duration is
     drawn at random on entry, so collapse is knowable only once the house is
     already at IDOC; under static decay it is exact at every stage. Omitted
     rather than approximated — an absent field is honest where a wrong date
     becomes a dated promise in a player's mail.
  b. vendor.listing gains ownerAcct — a character name identifies nobody, only the
     game account joins to shard_account_links — and a `fees` block resolving
     PlayerVendor.PayTimer's dismissal rule into a dismissalAt instant. There is
     deliberately no daysRemaining: a pay period is a UO day on an old-system
     shard, so the name would be wrong by a factor of twelve.
  c. account.login.result is a NEW kind. account.login.attempt fires from a veto
     hook that runs before the auth decision and constructs with Accepted = true,
     so it fires on successful logins too and cannot carry a verdict.

INTEGRATION.md: the version banner to 5, the Housing and Market field tables and
examples, and the new kind beside the attempt — with the attempt's own row
corrected to say why it is not a failure signal.

PLAN.md: 5.1's "failed-login signals" row was wrong about what AccountLogin can
tell you; 5.3 and 5.4 gain the two new signals; 5.4's dynamic-decay model gets the
consequence for publishing a collapse time, and the CanDecay rule gets its testing
trap. 7's frames are marked as 1.0's design sketch rather than the shipped wire,
which they have not been for four versions.

ENGAGEMENT.md: Phase 10 as built. Two decisions taken during the build (both field
groups nested, so one admin rule governs each and a future field inherits the gate;
no daysRemaining), the three visibility classifications, and the pin improvement —
the schema test now asserts the declarations AGREE rather than that they all say a
literal, which is the protocol-4 bug seen from the other side.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:19:09 -05:00
efce1d88aa docs(engagement): widen Phase 10 to three wire enrichments and Phase 11 to the full trigger catalogue
Two scope decisions taken by the org lead on 2026-08-31, before any Phase 10 code,
plus the three factual corrections that finding them out produced.

Phase 10 — the protocol bump now carries three enrichments, not one. The argument is
specific to this phase: a bump costs a sidecar release, a republished bundle and an
operator update on every shard, so a field left out does not cost a follow-up commit,
it costs a second bump with the same lead time and a split operator population. The
two additions:

  * player-vendor fee state on vendor.listing (ownerAcct, holdGold, chargePerDay,
    daysRemaining), because uo.vendor.expiring is the same "owned asset at risk with
    a deadline" shape as the flagship and today has neither an address nor a deadline;
  * a post-decision account.login.result, because EventSink.AccountLogin is a veto
    hook that fires BEFORE the auth decision.

Moving vendor.sale out of the opt-in patch tier was explicitly declined.

Phase 11 — ships every checked row of 8.6 rather than a single rule, carving out
uo.market.item_listed (a saved search; no per-user query store exists). ~23 triggers
grouped by the audience kind each family exercises, since exercising the ceiling
lattice at scale is the point rather than volume of mail. Every rule still ships
enabled = 0 per Q3. Notes that the phase will likely want an 11a/11b split on the
4a/4b precedent, to confirm at its start.

Three corrections to 8.6, each verified against the emitters rather than the table:

  * uo.vendor.sale is real and does carry ownerAcct, but lives in servuo-plugins/
    patches/ (opt-in, verified only against ServUO 57.4) — dormant, not broken, on a
    shard that declined the tier;
  * uo.vendor.expiring had no data at all, not merely no mapper — vendor.listing
    carries ownerSerial/ownerName and nothing carries held gold or daily charge;
  * uo.account.login_attempt could not have been built as described — it would have
    mailed "someone tried to log into your account" on every successful login.
    Renamed uo.account.login_failed so the id cannot be misread again.

Also amended: the status header, scope decision 3, and 6.0b's documentation
assignment for rows 10 and 11 (v5.md now earned; the patch-tier caveat is an
operator-facing doc obligation; runicgateway.com's capability claim changes when
"one rule" becomes "the catalogue").

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 11:06:34 -05:00
221c5a9c9e docs(engagement): Phase 9 as built — deliverability, suppression and bounces
Records what Phase 9 shipped (website#176) and the four decisions the org lead
settled before any of it: mechanism plus SMTP's own synchronous refusal rather
than an API transport; suppression scoped to engagement rules only; an
`address_masked` column added to §4.5's DDL; and the verification gate applied at
enqueue rather than at delivery.

The correction the phase's own text needed: "SMTP has none" is too strong. SMTP
has no asynchronous bounce feed, but a single-recipient send refused at RCPT TO
throws synchronously with the reply code intact, and mailer.js was already
catching that and discarding it.

The defect worth not repeating: `PERMANENT_CODES` is not a bounce classifier. It
answers "is retrying pointless?" and contains EAUTH, so suppressing on it would
have emptied the mailing list the first time an SMTP password expired.

Also records the one thing only the live rig could find — `engagement_sends`
has carried a `bounced` status since §4.5 and nothing had ever written it, so the
Send Log's Bounced filter matched nothing — and answers §7.1 Q1's narrower half.

- ENGAGEMENT.md: Phase 9 "As built", the §4.5 DDL, Q1's narrower half, status header
- BACKEND_DESIGN.md: §3 `engagement_suppressions`, and §7's Deliverability section

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 10:49:04 -05:00
b1851ad8c1 docs(engagement): the in-app channel on Android, as built (Phase 8)
Records Phase 8 and answers §7.1 Q8, leaving Q9 as the only open question.

ENGAGEMENT.md gains the phase's as-built section: the four decisions settled
first, the relative-url finding the live rig caught (Phase 7's contract is
relative-only, and the client half of that contract was never written down), the
ref-not-stream routing rule, the (base URL, user id) snapshot scoping that is the
actual security property, and the on-device walk. Its Status paragraph was six
phases stale and now names every phase that has landed.

android/PLAN.md §7's "no offline caching, no Room in v1" decision STANDS and now
names its one exception, with the limit it comes with: the snapshot serves a
running app, not a cold start, because the shell gates the whole app on loading
the site's appearance. Widening Phase 8 into the shell's startup model is the
org lead's call, so it is written up rather than done. §11 gains the inbox and
the per-channel screen as built.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 09:28:07 -05:00
66257ebcb5 docs(website): §7.1 Q9 — core's own news.post emitter
`news.post` is a declared trigger with no caller: `coreTriggers.js` says so in
as many words, and Phase 6 migrated only the four `team.*` ones. A rule naming
it can never fire, so on a real deployment the only in-app or email items the
engine can produce today come from Teams. Phase 7 flagged it in passing; this
writes it down properly as an open question.

The substance is not the call — it is the three other things a news publish
already fires, and which of them the engine has any business replacing:

- the announce leg (`announce_job_legs`, `module-uo` owns `towncrier`) — a
  one-shot delivery to a channel of the deployment, with retry. NOT the
  engine's.
- a module's post hook (`registerPostHook`) — idempotent state mirroring that
  also runs on delete and refreshes on a silent edit. NOT the engine's.
- the raw `pushDispatch.publish('news.post', …)` — a per-person notification.
  THIS is the one that becomes an emit.

So modules keep both doors onto a news publish and neither changes. What a
module does not get is the ability to fire `news.post` itself — the id's owner
is core, `emit` binds the owner at the call, and §7.2's one namespace gives an
id exactly one owner across both facets. A module wanting its own person-facing
news notification declares its own trigger.

Three things to settle first, recorded rather than decided: continuity (the
emit replacing the tickle stops push silently until a rule is enabled — G22's
shape, and Phase 6 decision 3's), reusing the job-id transition signal rather
than re-deriving it, and which phase owns it. Recommended home: Phase 11, whose
title understates it — a pointer and an extra acceptance line land there too.

Code: RunicGateway/website#175

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 2fd5d065b7)
2026-08-31 02:28:20 -05:00
d9abe3d941 docs(website): the in-app channel as built (engagement Phase 7)
ENGAGEMENT.md gains the Phase 7 as-built: the four decisions the org lead
settled before any code (in-app defaults to `instant`; the phase takes push's
`deliver` and the web preferences screen; the inbox takes `/notifications` and
the settings move under it; `ctx.inbox.push` respects a preference where one
exists), the block-role mapping that turns a template into a row, and five
things the tree contradicted or the build found — including the one only the
live rig could see, that staff had no reachable inbox at all.

Two earlier passages amended where the phase made them false: Phase 2's
"`ctx.inbox.push` throws until Phase 7" and Phase 3's "`inapp` is declared
`off`". Both kept as history with the correction beside them.

BACKEND_DESIGN.md gains the four inbox routes in the `/auth/me` table and a
`user_notifications` entry in the table inventory — the user-scoped dedupe
index, why `body` is text rather than the email HTML, and the retention policy.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 02:11:46 -05:00
c93151b352 docs(website): the email channel and the Teams migration as built (engagement Phase 6)
ENGAGEMENT.md gains the Phase 6 as-built: the seven decisions settled up front,
the audience problem that shaped the phase, why scope_key is not subject_key, the
one place decision 4 as phrased could not ship, the projection's rule and what it
refuses to guess, the digest correcting Phase 4a rather than only implementing
§4.2b, and the three defects the build found. §4.2b and §6.0b updated with it.

TEAMS.md §6.4 rewritten: the pipeline it described no longer exists as its own
thing. It now says which of the three sinks moved and which did not, where each
of the four properties it always claimed lives now, that Team email is OFF until
an operator turns a rule on, and what the generalized unsubscribe token does.
§6.3's `last_digest_at` is marked as no longer read.

BACKEND_DESIGN.md: engagement_digest_state, engagement_outbox.scope_key, and the
unsubscribe routes — the canonical /public/engagement pair plus the /public/teams
path kept permanently because mail is not editable once sent.

Code: RunicGateway/website#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 20:12:15 -05:00
556124562b docs(website): the template editor as built (engagement Phase 5b)
ENGAGEMENT.md gains an "As built - 5b" section and BACKEND_DESIGN.md the eight
routes under /admin/engagement.

The scope decision is recorded first because the plan contradicted itself: the
Phase 5 body names only the editor, while Q4's answer and 6.2 both promise
"Triggers, Templates and the send log" in Phase 5. All three shipped - leaving
either out would have left the nav group half-built and G15 open with the rows
already on disk.

Five more decisions, each because the tree said something the plan did not: the
preview is rendered server-side and framed; `status` is now enforced by
renderByKey; a test send is logged under a synthetic trigger rather than making
the column nullable; a template a rule uses refuses deletion with a 409; and
duplicate is the only creation path.

Also records the correction that changed the most code - 4.6.2 says duplicate is
how a protected template is customized, the schema says "Editable, NOT deletable",
and the org lead's ruling is the schema's - and the three things only building it
found, including the Phase 4a template-key pattern that could not match any key
this system uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 18:14:12 -05:00
aa5c1d63b0 docs(website): the seeded template set as built (engagement Phase 5a)
Companion to website#TBD. §6.0b assigns this phase two documents; both land here.

ENGAGEMENT.md gains an "As built — 5a" section: the three decisions the survey
forced, the argument for two registries rather than one, why the ternaries stayed
at the call site, and the two behaviour changes an operator will notice.

BACKEND_DESIGN.md gains the `engagement_templates` table, the two-block-registry
split, the token grammar, and the §7 note that mail is now multipart.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 13:07:50 -05:00
73e1669a5c docs(website): the browser pass on the engagement screens (Phase 4b)
Six defects that only driving the two screens in a browser could find, plus two
environment facts worth not re-deriving:

  - rebuilding client/dist while the server is running blanks the whole SPA. The
    HTML shell resolves core's hashed bundle filename at boot, so after a
    rebuild it points at a file that no longer exists; core's bundle 404s,
    window.__rg is never published, and modules/uo/entry.js throws MODULE_API.md
    §3.1's "core did not publish its shared dependencies" into a blank page. The
    error names core, and core is not at fault. Restart after every build.
  - a window.confirm blocks CDP entirely, so the two destructive actions cannot
    be driven from a script and a session that opens one is stuck until a human
    dismisses it. Exercise those paths over the API.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:26:41 -05:00
97b652fed3 docs(website): the engagement admin surface as built (Phase 4b)
Companion to website#171.

ENGAGEMENT.md gains an "As built - 4b" section: the four decisions the org lead
settled, the correctness argument behind the standalone enable route, the three
honesty fields on the reach preview, and the two defects the live walk found in
Phase 4a's own code (a rule pointing at a dormant segment reading as healthy,
and the delete refusal's "1 rule still use"). It also records the throwaway
module rig that made the whole segment half walkable at all - core declares no
audiences, so on a stock local stack none of the §5.1a arithmetic can be
exercised without one.

§5.1a gains the composition UI it was owed: its own nav entry rather than a tab,
"exclude" offered only under "all of", the stored ceiling displayed and never
chosen, and a tree nested deeper than the composer renders shown read-only
rather than flattened.

BACKEND_DESIGN.md's route table gains the twelve routes, including why the
enable switch is a PATCH of one column and why deleting a segment in use is a
409 rather than a cascade.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:18 -05:00
deaf491dc5 docs(website): the engagement engine as built (Phase 4a)
Companion to website#170. Section 6.0b's assignment for Phase 4 - ENGAGEMENT.md's
as-built and BACKEND_DESIGN.md's table inventory - plus the two corrections
building it forced on this document's own design sections.

ENGAGEMENT.md
  - Phase 4 is split 4a / 4b, with what each owes.
  - Section 7.1 Q2 and Q4 answered, so seven of eight are settled and only Q8
    (android CI) is open.
  - The as-built: the gate order and why two of its placements are load-bearing,
    the segment rule the design never stated (not is legal only inside an and,
    and contributes no ceiling), dormancy three ways and why audience_segment_id
    has no foreign key, the conditions grammar's two fail-closed properties, and
    why emit does not await the engine.
  - Section 4.1's cooldown statement and section 4.2a's dedupe index are
    corrected in place, so the design sections stop teaching the two defects.

BACKEND_DESIGN.md
  - Five new tables in the schema inventory, each with the reasoning a reader
    would otherwise have to reconstruct: why subject_key is in the primary key,
    why the dedupe index is scoped, why the send log survives an account
    deletion and is not a second address book, and why a rule points at a
    segment without a foreign key doing it.

No route table changes - Phase 4a adds no routes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 08:07:42 -05:00
7b7a26f0ac docs(website): per-channel notification preferences (engagement Phase 3)
The documentation §6.0b assigns Phase 3 — `BACKEND_DESIGN.md`'s route table and
`android/PLAN.md` §11 — plus the phase's as-built section and one correction the
build forced.

`ENGAGEMENT.md`:
- Phase 3's as-built: the three decisions settled before any code, why the
  channel registry could not wait for Phase 6, what the sparse PUT buys, the
  projection stated as an invariant, and the staff-ceiling filter.
- **The phase's own acceptance line was wrong and is struck through.** "A fresh
  user's … push defaults `instant`" reads naturally beside §3.1's "email opt-IN,
  push opt-OUT", but §3.1 borrowed that from `team_notification_prefs`, where no
  row genuinely does mean notified. Push STREAM subscriptions have never worked
  that way — `notification_subscriptions` holds a row only on opt-in — so
  `instant` would have projected the entire catalog into the legacy GET for
  every existing user. §3.1's comment is corrected in the same pass.

`BACKEND_DESIGN.md`: the `/me/notifications/channels` row, the
`notification_channel_prefs` table entry (including that absence means the
channel's default rather than `off`, and that all three agreeing on `off` today
is a fact about the declarations and not about the table), and a note on
`notification_subscriptions` that it is now the push projection.

`android/PLAN.md` §11: nothing above it changed — the shipped APK keeps working
and the `{"streams":[]}` gotcha still applies to that endpoint. The new section
documents the superset endpoint for whenever the app adopts it: render toggles
from each item's `channels` rather than a hardcoded three, `modes` is the
effective mode and the client must not re-implement the defaulting, the PUT is
sparse so the empty-array gotcha does NOT apply here, and one id may be missing
that the app expects (a `staff`-ceilinged trigger is not offered to a non-staff
caller).

Companion to website#169.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 07:09:16 -05:00
58c0797fc2 docs(website): event triggers, audiences and the ceiling lattice (engagement Phase 2)
MODULE_API.md gets a real 1.7.0 — and §1.1's "1.6.0 has only ever been on
`edge`" paragraphs are now marked historical rather than current, which is the
correction §0.5 asked for: 1.6.0 reached `main` with the Teams cutover, so the
in-place-amendment rule those paragraphs invoke no longer applies and every
addition from here takes a version of its own.

§2.3 gains `ctx.events.emit` and `ctx.inbox.push` (present and throwing until
Phase 7, with the reason stated). §2.4 gains `registerEventTriggers` and
`registerAudiences`. A new §6.8 carries the forward-compat note ENGAGEMENT.md
§7.3 asked this document to hold: a rule, a template and an audience outlive
the module that declared them, so `trigger_id` is a plain VARCHAR with no
cascade and an unregistered id shows DORMANT rather than erroring or being
auto-deleted. The failure that prevents is specific — an id that stops
resolving must never silently become a send to a different set of people.

ENGAGEMENT.md records Phase 2 as built, and three things it did not previously
say:

§5.1a now DEFINES the ceiling lattice. The document named "narrowest" and
"tightest" throughout and never said what narrower meant, and the obvious
reading is a security defect: under a flat total order a `staff`-ceilinged
trigger permits an `owner` audience, i.e. a rule that mails cheat detection to
the player it detected. It is containment, not size, and incomparable ceilings
have no bound at all.

§7.2 is answered — ONE namespace, against the recommendation in its own text —
with the two knock-on effects that only appeared once it was built (a relaxed
id grammar, a shared legacy allowlist) and the risk that did not materialise
(the push catalog is untouched, so the shipped app sees no change).

§7.1 Q6 is answered: declare `kind: 'scheduled'` now, build the evaluator after
Phase 9.

§4.3 is kept true to what shipped: `audience` and `ceiling` are two fields
rather than one, `'computed'` is gone from the audience vocabulary, and
`example` is enforced rather than encouraged.

§6.0b's Phase 2 row is corrected. Its two "other repos" cells are cutover-window
work, not this window: the integration kit pins a website `main` sha and
runicgateway.com FETCHES its facts from `main`, so doing either now would turn a
green repo red for the whole edge period — for the kit, against the explicit
rule in its own pin ("written against what shipped, never what is in flight").

BACKEND_DESIGN.md gains the two admin routes, the router-tree entry and the
adminOnly line; api-route-inventory.json regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 06:40:48 -05:00
84481ee6b1 docs(website): unique, changeable, verifiable email addresses (engagement Phase 1b)
Companion to website#<pr>. Records Phase 1b as built, and corrects two things
the plan got wrong before anyone builds on them.

ENGAGEMENT.md
  - Phase 1b step 2 said to pin the index to a case-insensitive collation. Every
    _ci collation MariaDB offers here is also accent-insensitive, so that index
    would refuse jose@x.com once josé@x.com existed and the de-duplication would
    have cleared a legitimate account's address. The as-built block records the
    generated-column design that shipped instead, and the second-order version of
    the same bug that a seeded fixture caught in the de-dupe query itself.
  - §0.6 named two callers of isDuplicateUsername(). There are five, and the
    three it omits fail worse than the two it names.

BACKEND_DESIGN.md — the users table (already stale: it predated the player
account work), plus email_verifications and email_dedupe_report, and the five
new routes.

UPGRADE_NOTES.md — an operator entry, because the de-duplication is the kind of
quiet change this file exists for: nothing breaks, and the affected users find
out the next time they try to reset a password.

api-route-inventory.json — regenerated from the manifest; still ungated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 01:54:05 -05:00