Records Phase 5 of THEMING_AND_NAV.md as landed and documents the new route
and the shell lifecycle in BACKEND_DESIGN.md.
Where the build differed from the design: the upload is one admin-only call
that writes the settings row too (rather than the generic staff upload plus a
PUT, which would leave unreferenced files and let editors change the site's
identity); brand_assets needed a validator of its own because these are the
only settings values written straight into HTML as URLs; the shell cache
carries a TTL as well as explicit invalidation because it is per process; and
the logo went into all six MoonDot surfaces rather than three.
Also notes what was deliberately left alone: the shell's title and description
still come from BRAND_NAME rather than the admin-set site_title, and fixing
that would change the served shell for instances with no brand_assets row —
which is exactly what the phase's acceptance criterion forbids.
Co-Authored-By: Claude <noreply@anthropic.com>
Records where the build diverged from the design and why.
- The presets do not live in theme.css as [data-theme] blocks. Section 6.2 is
marked superseded and a "Phases 3-4 as landed" section explains the inline
--accent precedence problem that forced server-side resolution.
- Section 4.5's accentInt fix is struck through: getPublic() never exposed
accentInt, and Discord embeds are colored by a separate process reading env,
so there was nothing per-request to recompute. Replaced with what was
actually done -- the bot fetching the effective accent.
- Phase 9 re-scoped. Applying Fantasy on a live instance showed the section 4.8
rgba literals carry a hue, not just a light/dark assumption, so the dark
presets need that promotion too.
- BACKEND_DESIGN.md: the new /settings/theme/options route, the `theme` block
on /public/settings, effective values in the brand block, theme_visual
validation on PUT /admin/settings, route count 225 -> 226.
- api-route-inventory.json regenerated from the manifest.
Co-Authored-By: Claude <noreply@anthropic.com>
Matches RunicGateway/website's phases 0-2 of THEMING_AND_NAV.md.
BACKEND_DESIGN.md:
- The new /settings router group and its one route, plus why it is a fifth
group rather than a route on an existing one.
- DELETE /admin/settings/:key in the admin route table, with the allowlist and
why reset deletes instead of writing.
- The five unseeded theming/nav keys under the settings schema: absence of the
row is the "use the default" state, values are TEXT so consumers parse, and
malformed reads as absent.
- Route count 215 -> 225.
THEMING_AND_NAV.md:
- Phases 0-2 marked landed, with an "as landed" section recording the three
things the design left open: where /settings/nav lives, where
parseJsonSetting lives, and the exact 23-declaration radius promotion.
- The nav merge util's ordering rules, settled by the implementation: an
untouched item keeps its index as its sort key, an explicit order wins a tie
against a coincidental index, equal explicit orders keep code order, and
`group` is honored only when it names an existing section.
- All four PR pairs target `edge`; the feature reaches `main` as one merge.
api-route-inventory.json: resynced from server/routes.manifest.json. Picks up
the two new routes plus eight that were already missing from the mirror since
the Protocol 3.0 cutover (shard clilocs, market, points).
Co-Authored-By: Claude <noreply@anthropic.com>
Corrects the design doc against the current codebase and locks the open
decisions, in the same shape as HERO_EDITOR.md (locked decisions ->
corrections to reality -> phased build).
Blocking gaps found in the design doc:
- no delete path exists for a settings row, which every "reset to
defaults" in the feature depends on
- editors/moderators/players have no endpoint to read their own nav
overrides (GET /admin/settings is admin-only)
- renderIndexHtml runs once at boot, not per request
- settings values are JSON strings, not objects
- getPublic().brand is a cross-repo contract the Android app and
Discord embeds theme from; new keys would silently bypass it
Locked: effective values resolved server-side into getPublic().brand;
radius + shadow tokens only (spacing/border cut); radius tokens seeded at
today's real values so the promotion is a no-op; three dark presets in v1
with Parchment deferred; 12-option font shortlist across 8 web families
in one request; PNG-only favicons.
Co-Authored-By: Claude <noreply@anthropic.com>
v0.1.0 shipped on 2026-08-07, so every doc that said "not released yet"
was wrong the moment the cutover merged.
- INSTALL.md: replace the pre-release status banner. The installer is the
path the guide leads with; Appendix A is reframed as supported-not-
deprecated, for hosts that cannot run the binary, operators who want to
place files themselves, and development from a working tree.
- PLAN.md: status is Shipped, both cutover gates recorded as met (incl.
the Windows 1053 handshake bug the real SCM run found), Phase 5 table
and 5.4 closed out.
- README.md: point anyone setting up a shard at INSTALL.md first.
- link/link-README.md: mark the pre-split snapshot as historical, so its
deploy.ps1 instructions stop reading as the setup path.
Co-Authored-By: Claude <noreply@anthropic.com>
PLAN.md §8 recorded that `sc create` against the plain console sidecar worked
and needed no change to `link`. The first Windows install disproved it: 1053,
"a timeout was reached (30000 milliseconds) while waiting for the service to
connect", with SERVICE_EXIT_CODE 0.
The premise was a false symmetry with systemd. systemd supervises any
foreground process; the Windows SCM supervises only one that calls
StartServiceCtrlDispatcher within ~30 seconds. Record the reversal and what it
costs: link gains a Windows service entry point, kept at the edges so the whole
sidecar stays shared and Cargo builds neither Windows crate for Linux.
INSTALL.md:
- Troubleshooting gains a 1053 row naming the real cause (a sidecar older than
v1.2.0) and the two tell-tales that distinguish it from a crash: exit code 0,
and a foreground run of the same binary working fine.
- The existing "stops immediately" row said the same wrong thing; it now covers
the genuine-crash case only, and points at the log file and journalctl.
- §3 and Appendix A4 document the service log, and A4 states the version floor.
- Fixes a literal 0x08 byte in the backups path row, which rendered as
`%ProgramData%\RunicGatewayackups\` — the backslash had been eaten.
Co-Authored-By: Claude <noreply@anthropic.com>
Two corrections to §7.1, both forced by the first compose run that ever
had a bundle to write (org lead, 2026-08-05).
The section said bundles are committed to `main` and that this "needs no
new branch-protection exception: release.yml's version-bump commit
already requires the CI user to be able to push to main". Both halves
were wrong. `main` is protected and declines the push, and release.yml
had never pushed anything: its bump step has never executed in any repo
carrying it, because an empty template expression written literally in
one of its comments makes the runner fail to build the step and skip it
without failing the job. The tags exist because Gitea's release API
creates one when it publishes. The assumption that a working push path
already existed had never been tested by anything.
Bundles now publish to a `bundles` branch at its root, which keeps every
property the original choice was for -- reviewable diff, git history of
the compat matrix, plain anonymous raw URLs, no credentials on the shard
host -- and needs no exception. The release workflows are tag-only for
the same reason, as servuo-plugins has always been: the version is still
written into Cargo.toml before building so a released binary
self-reports correctly, but is not committed back.
Also updated: the raw URLs in INSTALL.md Appendix A1 and in Phase 0.3's
as-built note.
Co-Authored-By: Claude <noreply@anthropic.com>
PLAN.md §5.4. The operator guide is the specification of the run, so
these are part of building the phase rather than a write-up after it.
- The download list gains runicgateway-installer-linux-aarch64, with
`uname -m` as the way to tell, and says plainly that there is no macOS
and no Windows-on-arm build: the shard dials the sidecar out on
loopback, so the two share a host, and no ServUO host is either.
- Appendix A3 names the arm64 sidecar asset for the by-hand path, and
points at the bundle from A1 for the version rather than the one
written in the example.
- §7's `update` says what a backup is, when one is taken and when one is
not, and that restoring is the operator's to do -- the guide already
promised their Bridge.cfg edits survive, and this is the same promise
for the .cs file they edited that gets overwritten by design.
- --no-backup joins the flags table; --purge's row and the uninstall
table now name backups alongside the config, the database and the
cached patch set.
- The paths tables and doctor's sample output gain the backups
directory and its row.
Co-Authored-By: Claude <noreply@anthropic.com>
Two things this section got wrong, both found by implementing it.
§5.3 said a backup is taken for an update and for an install over an
existing record, because "a first install overwrites nothing". That is
not true of a tree deployed by hand per INSTALL.md Appendix A2 -- the
path this project recommends while the binary is unreleased. There the
first install finds .cs files that differ, plans them as Change, and
overwrites them with no prior record anywhere to notice. The rule is
now the direct one: back up whenever the run is about to overwrite
something. A genuine first install onto a clean tree still writes
nothing, because there is nothing to copy.
Also records that sidecar.toml joins a backup rather than causing one
(nothing rewrites it, so triggering on it would leave a dated directory
after every no-op update), and that the directory is created lazily
with the manifest written last, so an interrupted run can neither be
mistaken for a backup nor evict a good one.
§5.2 gains what its cross-builds turned up: neither crate builds with
the arm64 compiler alone. gcc-aarch64-linux-gnu only recommends
libc6-dev-arm64-cross while both release workflows install with
--no-install-recommends, so the C in each crate -- bundled SQLite under
sqlx, ring under ureq's rustls -- fails on a missing libc header while
every Rust dependency compiles fine.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 5 was sketched as four items; two of them are dropped rather than
deferred, because what stops them is an ownership conflict that does not
improve with time (org lead, 2026-08-05).
- No .deb and no MSI (5.1). A .deb under link's release would own the
binary, the systemd unit and the service user -- the three things
service.rs writes, hardens and removes and install.json records, so
uninstall would leave a dpkg-installed-but-broken package and an
apt upgrade would make doctor report drift nobody caused. The
binary-only variant buys apt-managed upgrades of one file, which
update already does from a protocol-checked bundle. An MSI
contradicts "the installer does not install itself" and adds a second
uninstall path beside the verb that owns install.json, the cached
patch set and the ServUO-tree report.
- Linux aarch64 for both components (5.2), in the order the bundle CI
forces: it hard-fails on an unrecognized link asset name and asserts
the platform keys present, so the name is taught on main first, link
publishes, the key becomes required, and only then does the crate on
edge learn it. bundle.yml is never edited on edge, so the cutover
merge has nothing to conflict over.
- Backup before overwrite (5.3), scoped by what cannot be fetched
again: not the binary or the overlay files, and not the database
(store.rs is CREATE TABLE IF NOT EXISTS over shard state the sweeps
repopulate -- a cache with a schema), but an operator's edits to a
deployed .cs file, which Phase 1 overwrites by design, and
sidecar.toml, whose token the website already holds.
- The docs a first release invalidates (5.4), including the repo README
still announcing Phase 1 four phases later.
Also records that the Windows SCM smoke was attempted on 2026-08-05 and
stopped at its first check on an unelevated shell, so that half remains
entirely unexecuted.
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit 8818c06f1d)
Two decisions from the org lead, recorded in the design of record.
Phase 5 (packaging polish) now runs BEFORE the edge -> main cutover
rather than after it. The original order assumed the cutover would cut a
v1 and packaging would follow as a v1.x, but this phase changes the
release layout itself: shipping first would mean a first release that is
immediately superseded, and operators who downloaded a bare binary being
told to re-download a package. Deferring costs nothing — nothing is
published from `edge`, and INSTALL.md's Appendix A is the supported path
meanwhile.
The cutover therefore has two entry criteria, stated in the status
header and at Phase 5: packaging polish, and the Windows SCM half being
verified on a real host. The second is called out explicitly because
`sc create`, the virtual service account, the failure actions and the
token-file ACL have still never been executed anywhere — and running the
systemd half for real is precisely what turned up a bug no unit test
had. Nothing should be released while the only untested code is the half
that registers a service.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 shipped service registration that had never been executed: a
relocated test run deliberately skips it, `sc create` needs elevation,
and systemd needs a Linux host. It has now been run for real on a
privileged Debian 12 container with systemd as PID 1 — unit written and
enabled, service up as the unprivileged runicgateway user, sidecar.toml
600 and owned by it, database under /var/lib (so the UOLINK_DB_PATH pin
works), /health answering protocol 3, and uninstall taking the service,
unit, binary and account away while leaving the config, the database and
the whole ServUO tree alone.
That surfaced one bug only a real service host could show — user_created
was recorded per-run rather than as state, so an identical re-run
rewrote install.json and uninstall silently left behind the account the
installer had created (installer#8). Recorded here with the reason it is
invisible on Windows.
Also notes what is still unverified: the Windows SCM half, which needs an
elevated shell this machine's automation does not have.
Co-Authored-By: Claude <noreply@anthropic.com>
The installer crate now implements the whole command surface INSTALL.md
published before the binary existed, so this records what Phase 4 turned
out to be and corrects two places where the plan and the guide had drifted
apart.
PLAN.md
- Status header: Phases 1–4 are on `edge`; the edge → main cutover now
cuts a binary that does everything INSTALL.md describes, with Phase 5
being packaging polish rather than capability.
- A Phase 4 "as built" section: why `update` is the install pipeline in
a different mode rather than a second implementation, why it neither
reprints the token nor stays quiet about a protocol change, the tier's
scope under `update` (re-resolve what was applied, without re-asking;
name what is new), how `doctor` asks the binary the way the service
does, the exit-code rule and why a stopped shard is a ⚠ while a
running one that has not dialed in is a ✗.
- §5's uninstall table: the cached patch set and patches/originals/ move
from "removed" to "kept". The report that command prints tells the
operator to diff against those originals — deleting them made the
advice impossible to follow within one command's output. `--purge`
removes them.
INSTALL.md
- §2: exit codes stated (`doctor` and `uninstall` use 1 for a completed
run that found something wrong), `--patches` now applies to `update`,
`--yes` means yes on `uninstall`, `--purge` covers the patch cache.
- §7 doctor: the real row set, what ✓/⚠/✗ mean, that it writes nothing
and is safe to run with the shard up.
- §7 update: it updates the tree install.json names, needs the shard
stopped, does not reprint the token, calls out a protocol change, and
what it does and does not do with the patch tier.
- §7 uninstall: what survives, that edited files are flagged in the
listing, the confirmation's default, and where the report file lands.
Co-Authored-By: Claude <noreply@anthropic.com>
PLAN.md gains a "Phase 3 as built" section covering the decisions the plan had
left open, and §2.2, §2.2.1 and §7.0 are brought in line with what shipped:
* The engine is fully native. §2.2.1 wrote rung 1 as "apply verbatim with
git apply", but §1 chose the release tarball so there would be no git on the
shard host, and rung 2 needs a native applier anyway. Rung 1 keeps its
stronger verdict and shares rung 2's write path. On the real files this is
not academic — the shipped patches are CRLF and two of their three targets
are LF, so git apply refuses patches the installer places correctly.
* §7.0 documents `patch_tier` in the overlay manifest. Which patches form one
unit, which companion follows which, whether a core rebuild is needed and
what declining costs are not derivable from a diff, so the release declares
them and adding a patch regenerates metadata rather than an installer.
* §2.2 gains the pre-image cache and the widened patch cache, and §2.2.1 gains
the second, per-feature level of the all-or-nothing rule.
INSTALL.md's illustrated tier output is replaced with the real thing, the
status banner now says `install` is complete, §3's path tables list
patches/originals/, and Appendix A2 names the line-ending trap that makes
git apply refuse a patch whose region is visibly untouched.
Refs: RunicGateway/installer#6, RunicGateway/servuo-plugins#10
Co-Authored-By: Claude <noreply@anthropic.com>
Phases 1 and 2 now live on the installer repo's `edge` branch, so PLAN.md's
status, the config-path section, and the operator guide all move with them.
PLAN.md
- Status: phases 1 and 2 built. The `edge -> main` cutover now follows Phase 3
rather than Phase 2, because INSTALL.md §4 describes the patch tier as part of
the run and a release that answers "not implemented" to all of it is the same
half-capable binary that kept Phase 1 off `main`.
- §2.3: the service definition always pins the config path, but only Linux pins
the database. On Windows config and data share a directory, so the sidecar's
own anchoring rule already lands it correctly — and `sc.exe` offers no
per-service environment, only a machine-wide one that every process inherits
and that outlives an uninstall.
- New "Phase 2 as built" section: the virtual service account, the config
lockdown and why its two halves straddle registration, `--verify` running no
part of the sidecar half, the protocol check against the installed binary,
`RUNICGATEWAY_STATE_DIR` relocating the binary and suppressing service
registration, degrading to a printed recipe with no root/LocalSystem fallback,
and the token never entering install.json.
- §8 question 1 (Windows service mechanism) resolved: `sc create`, as
recommended — plus the service identity the recommendation did not anticipate.
INSTALL.md
- Status banner: what is built, and that the patch tier is the remaining gap.
- §2: the illustrated run matches the sidecar block the binary actually prints.
- §3: a table of how each platform pins config and database, the dedicated
service account on both, and the fact that sidecar.toml's permissions are
restricted because it holds the auth token.
- Appendix A4: the Windows recipe now matches what the installer does —
`--config` in binPath (single-quoted so PowerShell keeps the inner quotes),
`obj=` for the virtual account, the icacls lockdown before and grants after,
and no machine-wide environment variables.
- Troubleshooting: a row for a run that could not register a service, and one
for a service that starts and immediately stops.
Co-Authored-By: Claude <noreply@anthropic.com>
PLAN.md moves from "Phase 0 complete, no code exists" to "Phase 1 built, on
edge", and the Phase 1 section gains an "As built" block in the same shape as
the Phase 0 entries — covering the decisions that were not already settled by
the design: why the crate lands on `edge` instead of `main`, why the library
target is not named after the binary (Windows UAC installer detection makes
`cargo test` unrunnable under that name), the dependency choices that follow
from the MinGW cross-build, path-based rather than name-based shard-running
detection, reading ServUO's version from Server/AssemblyInfo.cs, and the two
rules the smoke test corrected — install.json recording a state rather than the
run's verb, and the Bridge.cfg keep comparing against the last hash deployed
rather than the last hash seen.
INSTALL.md gains the same status note and one troubleshooting row: Windows
elevates the binary on launch because its file name contains "install", which
is expected and needs no action beyond running from an elevated shell.
Co-Authored-By: Claude <noreply@anthropic.com>
The patch tier refused on a whole-file hash mismatch, which is the wrong
question: the three patches touch three small regions of three large files, so
an operator who edited Logging.cs somewhere else entirely was handed a manual
patch job they did not need. Hand-modified shards are the norm, so that refusal
covered most of the audience.
Replace the single hash test with a four-rung ladder (PLAN §2.2.1), cheapest and
safest first:
0 post-patch text already present -> no-op, keeps re-runs idempotent
1 whole file matches the pre-image -> apply verbatim
2 file differs, patched region is still byte-identical -> apply at the
matched offset
3 anything else -> do not touch the file; print the hunk to apply by hand
Rung 2 needs no new metadata: a unified diff already carries the stock text of
the region it edits (context lines plus the '-' lines). Guardrails keep it from
becoming a fuzzy apply -- exact match with only CRLF/trailing-whitespace
normalisation, exactly one occurrence or it fails, line numbers advisory only,
and all-or-nothing per patch file so a half-patched EventSink.cs cannot happen.
install.json records which rung applied each patch, and doctor and uninstall
report it.
This retires the blanket 57.4-only version gate, so PLAN gains §2.2.2 to draw
the line the ladder does not: content matching is a mechanical guarantee about
where text lands, not a support commitment. 57.4 stays the only supported
version. A non-57.4 tree may attempt the tier, but unsupported, untested and not
guaranteed -- behind a loud banner, a prompt defaulted to no, and its own
--patches-unsupported-servuo flag, because a bare --patches can be hit by
accident in a copied script. The unsupported marker persists into install.json,
every later doctor run, and the uninstall report.
INSTALL.md gets the operator-facing half: a block-quoted warning naming the
silent-script-build failure mode, the updated prerequisite row, prompts and flag
table, and a sample run showing all three outcomes.
Co-Authored-By: Claude <noreply@anthropic.com>
docs#87 landed; the row it added still said 'in review'. The status table is
the first thing anyone reads in this file, so a stale row there is worse than
no row.
Closes the last Phase 0 item. INSTALL.md is written before the installer
binary on purpose: everything it installs is already released (0.1-0.3), so
the guide is not speculation about a tool that might exist - it is the
specification of what the run asks, where it writes, what it prints, and what
the operator does next.
It is useful today. Appendix A is the same deployment done by hand - bundle
fetch, tarball verify and overlay copy, the optional patch tier,
--print-config provisioning, systemd unit / sc create - composed from the
released artifacts' actual contents and the sidecar's config and CLI source.
That appendix doubles as Phase 1's acceptance test.
Writing it settled four things the plan had left implicit, now recorded in
PLAN.md:
- The installer does not install itself; day-two commands run from the
downloaded binary.
- The flag surface: --servuo, --patches/--no-patches, --host, --site-url and
--yes join the --verify/--bundle/--purge the plan already named, so every
prompt has a non-interactive equivalent.
- A modified Config/Bridge.cfg is reported, not overwritten - one deliberate
deviation from deploy.ps1, whose overwrite-on-hash-differs rule is right for
a developer and would silently revert an operator's whole shard config on
update. install.json's recorded hashes are what make the distinction
possible.
- Remote-website deployments: widen [web] bind, firewall it to the site's
address, front it with TLS or a VPN off a trusted network. [shard] bind
stays on loopback because that socket carries commands into the game.
Also adds the missing installer/ section to the docs index, and refreshes two
stale examples in PLAN.md (overlay file count, sidecar version).
Co-Authored-By: Claude <noreply@anthropic.com>
Documentation half of installer Phase 0.3. Code PRs: RunicGateway/installer#3,
RunicGateway/link#25, RunicGateway/servuo-plugins#9.
Phase 0 item 3 asked for a compose job, a nightly cron, and a dispatch step in
each component. All three landed, and building them settled questions §7 had
left open — so those sections are corrected rather than appended to.
Status: Phase 0 is now all but complete. 0.2 is merged and released as link
v1.1.0; 0.3 is in review; 0.4 (INSTALL.md) is the remaining item, and the shape
it was waiting on has settled.
Phase 0 item 3 gains an "As built" subsection matching items 1 and 2, recording
what was chosen rather than inherited: why gate 1 reads the sidecar's protocol
from source at the release tag instead of asking the binary via --print-config
(it only answers for v1.1.0+, and --bundle <tag> must be able to recompose an
older pair); why gate 2 records the hash CI computed itself and separately
checks for assets absent from SHA256SUMS; why release metadata is read
anonymously; why an unrecognized asset name is a hard failure; and why a run
that changes nothing writes nothing.
§7.1 is corrected in two places. The manifest shape now shows link.assets as a
map keyed by platform — the single sha256 this section sketched could only ever
have described one of the two binaries link publishes — plus schema/generated
and the servuo block. And it gains a subsection naming where bundles are
published: committed under bundles/ in the installer repo, NOT one Gitea release
per bundle, because that repo's own releases are the installer binaries and
/releases/latest returns whichever release is newest regardless of kind.
§7.2 records that the dispatch steps now exist, and that a failed dispatch is a
warning rather than a failed release — which is what keeps the installer repo's
token out of the components' hard requirements.
§7.3 records that the releasable-commit rule must also exclude merge commits,
whose subject quotes the real one: without that, merging a docs: branch whose
title mentions a fix: would re-dispatch a declining release workflow nightly.
Co-Authored-By: Claude <noreply@anthropic.com>