# Runic Gateway Installer — plan

Status: **Shipped.** All five phases are built, the `edge → main` cutover merged on 2026-08-07
([installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17)), and it cut the
first release —
[`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases/tag/v0.1.0), publishing
`linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` plus `SHA256SUMS`. The binary does
everything [`INSTALL.md`](INSTALL.md) describes: the installer core (bundle resolution, ServUO
detection and validation, the overlay sync, `install.json` — [Phase 1 as
built](#phase-1--installer-core)), the sidecar half (binary, config, service, token handoff —
[Phase 2 as built](#phase-2--uo-link-install-and-service)), the patch tier (the rung ladder, the
unsupported-version path, the cached patch set — [Phase 3 as built](#phase-3--patch-tier-opt-in)),
the day-two commands `doctor`, `update` and `uninstall` ([Phase 4 as
built](#phase-4--diagnostics-and-updates)), and the packaging polish that gated the cutover
([Phase 5](#phase-5--packaging-polish)).

**The installer is now the supported way to set a shard up**, and the operator guide leads with it.
`INSTALL.md`'s [Appendix A](INSTALL.md#appendix-a--installing-by-hand) remains supported rather than
deprecated — it is the path for a host that cannot run the binary, for an operator who wants to
place files themselves, and for developing on the bridge from a working tree.

**Phase 5 came before the cutover, not after it** (org lead, 2026-08-05). The earlier order — cut
the release, then polish — would have published a first release immediately superseded by the next,
and the release layout is exactly what Phase 5 changed. Both cutover gates were met before the
merge:

1. **Phase 5, packaging polish** (§5) — scope settled and built: **no `.deb` and no MSI** (§5.1 —
   both would give the service, its unit and its user a second owner), **Linux `aarch64` for both
   components** (§5.2), **a backup of what an upgrade overwrites** (§5.3), and the docs a first
   release invalidates (§5.4).
2. **The Windows SCM half verified on a real host**, 2026-08-07. It was worth insisting on: the
   first real `sc start` failed with **1053**, because the SCM waits ~30s for a handshake a plain
   console program cannot perform. Fixed in the sidecar
   ([link#29](https://gitea.whitlocktech.com/RunicGateway/link/pulls/29), v1.2.0) and verified end
   to end against a live service — 13/13 checks, start in 1s, `RUNNING`, `/health` served, service
   log written, clean stop —
   with [installer#16](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/16) making the
   installer diagnose 1053 as a handshake rather than blaming the config. systemd registration was
   verified separately on 2026-08-05 against a real privileged systemd container, and that run
   likewise found a bug no unit test had.

This document is the design of record; it supersedes the informal overview it grew out of, which
described a ServUO integration that does not match how `servuo-plugins` actually ships (see
[Corrections](#corrections-to-the-original-overview)).

| Phase 0 item | State |
|---|---|
| 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) |
| 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) |
| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) |
| 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run |
| — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) |

---

## 1. Purpose

Take a stock ServUO installation and configure it for Runic Gateway with minimal manual steps, while
keeping the components separated and independently maintainable.

The installer handles environment detection, ServUO overlay deployment, the optional stock-file
patch tier, uo-link installation and service registration, version tracking, diagnostics, and
updates from Gitea releases.

**It is a deployment tool, not a hosted bootstrapper.** There is no `curl | bash`, no installer
service, and no hosted bootstrap script. Artifacts are downloaded from a Gitea release page and run.

**It does not replace ServUO startup behavior.** ServUO keeps running through its existing
release/start scripts. The installer never writes a launcher.

### Decisions locked

| Question | Decision |
|---|---|
| Audience | **Public** — any ServUO operator, not just shards we run |
| Code signing | **Unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen/Gatekeeper warnings are expected and documented, as with most self-hosted tooling |
| Language | **Rust** — single static binary per OS, reuses the cross-compile pattern already proven in `link/.gitea/workflows/release.yml` |
| Plugin source | **Release tarball artifact** — no git and no Gitea credentials on the shard host |
| Composition | **Published bundle manifest** (§7.1). CI names an exact, protocol-checked combination of component versions; the installer fetches it at run time and `--bundle <tag>` pins one. Component releases regenerate JSON, not the installer binary |
| Token handoff | **Print token + prefilled admin URL** at the end of the run |
| Repo | **New repo**, `RunicGateway/installer`. It deploys *both* other components, so living inside `link/` would invert the dependency |
| ServUO version | **57.4 is the only supported version.** The patch tier's gate is content, not a version string: a patch applies where the lines it edits are still stock and is handed to the operator where they are not (§2.2.1). Forks and hand-edited trees are the norm in a public audience, so a non-57.4 tree is still *allowed* to attempt the tier — but **unsupported, untested and not guaranteed**, behind a loud banner, a defaulted-to-no prompt and its own opt-in flag (§2.2.2) |
| Uninstall | **Never touches the ServUO tree.** Removes uo-link and its service entry, then *prints* the overlay files to delete and the patch hunks to revert. Reverting is the operator's call |

---

## 2. Corrections to the original overview

These are not wording nits — each one changes what the installer has to do.

### 2.1 There is no `RunicGateway.dll` and no `Plugins/` directory

The plugin ships as **C# source** and ServUO compiles it at boot. The real deployable is
`servuo-plugins/overlay/`, which mirrors the server root:

```
overlay/
├── Config/Bridge.cfg
└── Scripts/
    ├── Scripts.csproj                 # Phase 0 — whole-file overwrite of a stock file
    └── Custom/Bridge/*.cs             # 22 files
```

So the plugin step is a hash-compare file sync, not a DLL drop — mechanically easier than the
overview assumed. The sting is that **a successful copy does not mean a working bridge.** Per
`link/SHARD_PREREQS.md`, `ScriptCompiler.Compile()` shells out to `dotnet build`, prints the output,
**ignores the exit code**, and reloads the existing `Scripts.dll`. A broken script build is
invisible: the shard boots clean on stale code. Diagnostics must therefore verify *post-boot* state,
never treat "files copied" as success.

### 2.2 Stock ServUO files *are* modified — by an optional tier

`servuo-plugins/patches/` holds unified diffs against stock ServUO 57.4, plus two `.cs` files that
can only be copied *after* their patch lands (they reference symbols the patch introduces):

| Patch | Target | Companion file | Rebuild required |
|---|---|---|---|
| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | `BridgeVendorSale.cs` | **Core** — `dotnet build ServUO.sln`; the dynamic script build is not enough |
| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | (same unit as above) | script build |
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | `BridgeModerationAudit.cs` | script build |

Plus `overlay/Scripts/Scripts.csproj`, which overwrites a stock file (Phase 0 — it fixes the silent
ServUO build bug above).

This is the hardest part of the installer. `git apply` against a hand-modified shard will fail, and
most real shards are hand-modified. Therefore:

- The patch tier is **opt-in and skippable**. The base install must complete without it.
- Always dry-run (`git apply --check`) before applying, and report per-patch.
- When skipped or failed, say plainly what is lost: **no `vendor.sale` events, no in-game moderation
  audit forwarding**.
- The `EventSink.cs` patch must warn loudly that a **core solution rebuild** is required, not just a
  shard restart.
- Record applied patches in `install.json`, **and cache the `.patch` files** next to it
  (`/etc/runicgateway/patches/`, `%ProgramData%\RunicGateway\patches\`). Re-runs stay idempotent,
  and uninstall can print the exact hunks offline long after the release tarball is gone (§5,
  Phase 4). As built this caches every patch the tier *evaluated*, not only those that applied,
  because the refusal message names that path as the file to apply by hand. **The cache outlives an
  uninstall** — see Phase 4, where the report that would have been left pointing at a deleted
  directory is what settled it.
- **Cache the pre-image of every file the tier edits**, under `patches/originals/`, mirroring its
  path in the ServUO tree. It is written before the first edit and never overwritten, so a revert
  can be verified byte-for-byte rather than reconstructed from a printed diff — which matters most
  after a `region-match` apply, where the surrounding file was already the operator's. It stays out
  of the ServUO tree, since uninstall has promised never to clean up in there.
- **A `.patch` does not carry everything the tier needs.** Which patches form one unit, which
  companion `.cs` follows which, whether a core rebuild is required and what declining costs are
  declared by the overlay release and read from its manifest — see §7.0.

#### 2.2.1 A whole-file hash mismatch is not a verdict — check the region

A file-level hash compare answers "is this entire file stock?", which is the wrong question. The
patches touch three small regions of three large files; an operator who added a custom command to
`Logging.cs` or a hook to `EventSink.cs` has changed the file's hash without going anywhere near the
lines the patch edits. Refusing on the file hash alone hands most real shards a manual patch job
they did not need. So the decision is made in three rungs, cheapest and safest first, and only the
last one gives up:

| Rung | Test | Outcome |
|---|---|---|
| **0 — already applied** | The hunk's *post*-patch text appears in the file | No-op, recorded as applied. Keeps re-runs idempotent |
| **1 — file is stock** | Whole-file hash matches the patch's pre-image (`index <old>..<new>` in the diff — `git hash-object` on the target reproduces it) | Apply verbatim with `git apply` |
| **2 — region is stock** | File differs, but every hunk's stock-side region is still byte-identical | Apply hunk-by-hunk at the matched offsets |
| **3 — region is modified** | Anything else | **Do not touch the file.** Print the path, the hunks and what is lost; the operator patches by hand |

Rung 2 is the semantic review, and it needs no new metadata: a unified diff already carries the
stock text of the region it edits — the context lines plus the `-` lines *are* the pre-image. For
each hunk the installer reconstructs that block and searches the target file for it, under these
rules:

- **Exact match, not fuzzy.** Only line-ending (CRLF/LF) and trailing-whitespace normalization is
  allowed. No `patch --fuzz`, no context reduction: dropping context to force a match is precisely
  how a patch lands in the wrong method.
- **Exactly one occurrence, or it fails.** Zero means the region moved or was edited. More than one
  means the anchor is ambiguous and the installer cannot know which the author meant. Both are
  rung 3.
- **Line numbers are advisory.** The hunk header's offsets are used only to prefer the nearest
  candidate when reporting; the match itself is by content, since insertions above the region shift
  every number below it.
- **All-or-nothing per patch file, and again per feature.** If one hunk of a patch reaches rung 3,
  none of that patch's hunks are applied — a half-patched `EventSink.cs` compiles against a
  companion `.cs` that expects the whole thing, and a partial apply is harder for an operator to
  unpick than an untouched file. The same rule then applies across the patches of one feature: the
  two vendor-sale patches are a unit (the event, the call site, and the subscriber that needs both),
  so a patch that *could* have been placed is held back when a sibling cannot be — and the run says
  that rather than reporting it as applied.
- **Rung 0 is checked first and is also all-or-nothing.** A file where some hunks are already
  present and others are not is a hand-merge in progress, not an idempotent re-run — that is
  rung 3.

#### 2.2.2 Non-57.4 is allowed, unsupported, and must say so loudly

The rung ladder replaces the blanket ServUO-version gate. The old rule skipped the entire tier on
anything other than stock 57.4 on the grounds that unverified diffs must not be applied to an
unknown tree — but forks are the norm (§1), so that rule skipped the tier for most of the audience.
Content matching gives a stronger guarantee than a version string does: on a non-57.4 tree, rung 1
is simply unavailable (its pre-image hash cannot be trusted), the tier goes straight to rung 2, and
a hunk lands only where the surrounding lines are still character-for-character the ones the patch
was written against.

**That is a mechanical safety guarantee about where text lands. It is not a support commitment, and
the installer must never let the two be confused.** Runic Gateway is designed, built and tested
against **stock ServUO 57.4**. On anything else the patch tier is **unsupported, untested, and not
guaranteed to work** — a hunk can match textually and still be wrong for a tree whose surrounding
behaviour has diverged, and neither the shard's silent script build (§2.1) nor the installer will
tell you that. So:

- **The disclaimer is unmissable, not a footnote.** On a non-57.4 tree the tier prints a banner
  before it is even offered — that 57.4 is the only supported version, that the operator is on their
  own here, and that a bad outcome may not surface until the shard is running.
- **It is off by default and takes an explicit, separate yes.** The interactive prompt defaults to
  **no** on a non-57.4 tree, and `--patches` alone is **not** consent: an unattended run must pass
  `--patches-unsupported-servuo` as well. A flag an operator had to look up cannot be hit by
  accident in a script copied from somewhere else.
- **The label follows the install.** `install.json` records the detected version and the fact that
  the tier ran unsupported; `doctor` shows that row on every subsequent run, not just at install
  time; and the uninstall report carries it too. An operator who inherits this shard six months
  later must be able to see it without being told.
- **It is the first thing quoted back in a bug report.** The tier's summary line names the detected
  version, so a pasted install log answers "which ServUO?" before anyone asks.

The version is detected and reported everywhere; it just no longer *silently* decides. A refusal
becomes an informed choice, which is the point — but it stays visibly the operator's choice.

**What the installer records.** `install.json` stores, per patch, which rung applied it
(`stock-hash`, `region-match`, `already-present`) and the hunk offsets it matched. `doctor` and
`uninstall` report that: a `region-match` apply on a modified file is a different support story from
a clean apply to a stock tree, and the operator should be able to see which one they have without
re-deriving it.

### 2.3 Config paths collide with what the sidecar actually reads

The sidecar reads `$UOLINK_CONFIG`, else `sidecar.toml` in the **working directory**
(`link/sidecar/src/config.rs`), with keys `[shard].bind`, `[web].bind`, `[web].auth_token`,
`[store].path`. The overview proposed a `config.toml` with `[updates]`, `[link]`, `[servuo]` — keys
the sidecar cannot read.

Two files, two owners:

| File | Owner | Contents |
|---|---|---|
| `/etc/runicgateway/sidecar.toml` | uo-link | The sidecar's own schema, unchanged. Service sets `UOLINK_CONFIG` to this path |
| `/etc/runicgateway/install.json` | installer | Deployed versions, file hashes, applied patches, ServUO path, timestamps |

**Working-directory trap:** the sidecar wrote both `sidecar.toml` and `uo-link.db` relative to CWD.
Under `C:\Program Files\` that fails or silently lands in VirtualStore. Phase 0.2 fixed the second
half in the sidecar — a relative `[store].path` now resolves against the directory holding
`sidecar.toml`, so pinning the config alone is enough to put the database somewhere deterministic —
but the config path itself is still CWD-relative by default, and "deterministic" is not the same as
"where this install wants it". The service definition therefore always pins the **config** path:

- Linux: config `/etc/runicgateway/sidecar.toml`, db `/var/lib/runicgateway/uo-link.db`, dedicated
  service user
- Windows: binary under `%ProgramFiles%\RunicGateway\`, **data under `%ProgramData%\RunicGateway\`**

**How each is pinned differs by platform, and Phase 2 settled it that way deliberately.** Linux's
unit carries `Environment=UOLINK_CONFIG=` *and* `Environment=UOLINK_DB_PATH=`, because `/etc` and
`/var/lib` are different directories and both need naming. Windows passes the config as `--config`
inside the service's own `binPath`, and pins nothing else: config and data are both
`%ProgramData%\RunicGateway`, so the sidecar's own anchoring rule already puts the database exactly
where the table above says. The alternative on Windows is a **machine-wide** environment variable —
`sc.exe` offers no per-service one — which every process on the host would inherit and which would
outlive an uninstall. See [Phase 2 as built](#phase-2--uo-link-install-and-service).

### 2.4 The token handoff was missing entirely

The whole point is the website reaching the sidecar, and today that is manual and undocumented in
the install flow: the sidecar generates a token on first run and logs it, then a human pastes base
URL, WS URL, token, and protocol version into Admin → Shard, where it is AES-GCM encrypted and
becomes write-only. This is the largest "I installed it and nothing happened" failure mode.

The installer closes it by printing a copy-paste block at the end of a successful run — see §6.

Phase 0.2 supplied the missing half of that: `uo-link-sidecar --print-config` provisions the config
if absent and prints the resolved settings — token, both binds, `ws_path`, protocol version, db
path — as JSON. The installer reads the block it prints out of that one call. **It never parses the
log**, which was the alternative and would have made the handoff depend on a log format that is not
a contract.

### 2.5 `deploy.ps1` cannot be the cross-platform deployer

It is PowerShell-only; a Linux ServUO host running .NET typically has no `pwsh`. It also hard-throws
when the ServUO process is running — correct behavior, and the installer must inherit it (detect and
refuse, rather than corrupt a live `Scripts.dll`). The installer reimplements the sync natively; it
is a short hash-compare-and-copy that never deletes.

`deploy.ps1` **stays** in `servuo-plugins` as the developer-facing tool. The installer is for
operators.

### 2.6 Prerequisites the overview assumed away

- **`servuo-plugins` had no release workflow.** Only `link` did. "Pull latest repository" is replaced
  by a release tarball, which had to be built first — Phase 0 item 1, now in review
  ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)).
- **arm64 was not buildable today.** `link/release.yml` cross-compiled only
  `x86_64-unknown-linux-gnu` and `x86_64-pc-windows-gnu`, so `platform_key()` refused every other
  host by name. That was a property of the workflows, not of Rust — Phase 5 adds
  `aarch64-unknown-linux-gnu` to both components in the order §5.2 sets out. There is no `.deb`, so
  the cross toolchain is the whole cost.
- **The compat matrix has no home.** `PROTOCOL_VERSION` lives in `link/sidecar/src/main.rs`. The
  sidecar publishes it via `X-UOLink-Version` and `/health`, and the website stores an expected
  value — but the *plugin's* protocol version is not queryable before boot. Phase 0 item 1 gives it
  a home: `servuo-plugins/overlay.toml`, declared into the overlay manifest. See §7.0 / §7.1.

---

## 3. Distribution model

Components are published as Gitea release artifacts. Operators download from the release page
(browser, `curl`/`wget`, or `scp` to the server) and run the binary.

```
Runic Gateway Installer v1.0.0
├── runicgateway-installer-windows-x86_64.exe
├── runicgateway-installer-linux-x86_64
├── runicgateway-installer-linux-aarch64      (Phase 5)
└── SHA256SUMS

uo-link v1.1.0                        (existing release, extended)
├── uo-link-sidecar-windows-x86_64.exe
├── uo-link-sidecar-linux-x86_64
├── uo-link-sidecar-linux-aarch64             (Phase 5)
└── SHA256SUMS

servuo-plugins v<ver>                 (new release, Phase 0)
├── runicgateway-overlay-<ver>.tar.gz         # overlay/ + patches/ + manifest.json
└── SHA256SUMS
```

Binding those together is the **bundle manifest** (§7.1) — published by the installer repo's CI, not
by any component, and the thing the installer actually resolves against.

```bash
scp runicgateway-installer-linux-x86_64 user@server:/tmp/
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64
```

### Unsigned-binary posture

Because releases are unsigned, trust is anchored on checksums and the operator's own verification.
The docs must state this up front rather than let users discover it as a scary dialog:

- Every release publishes `SHA256SUMS`; the install docs lead with the verification command for both
  OSes.
- Windows will show a SmartScreen "unrecognized app" prompt. Documented, with the exact click path.
- The installer verifies the SHA256 of everything **it** downloads (overlay tarball, sidecar binary)
  against the release's `SHA256SUMS` and refuses on mismatch. Self-verification is not optional just
  because the installer itself is unsigned.
- Revisit signing if it ever becomes affordable; the release layout should not have to change.

---

## 4. Component architecture

```
                 Runic Gateway Installer  (Rust, one binary per OS)
                                │
                ┌───────────────┴────────────────┐
                ▼                                ▼
      ServUO integration                      uo-link
                │                                │
       ┌────────┴────────┐              ┌────────┴────────┐
       ▼                 ▼              ▼                 ▼
  overlay sync    patch tier (opt-in)  binary install   service registration
  (never deletes) (per-region rungs)   + config + data  (systemd / Windows SCM)
```

Each component keeps its own lifecycle. ServUO's existing startup process is untouched.

---

## 5. Phases

### Phase 0 — prerequisites (no installer code)

Repo work that must land before an installer can exist.

1. **`servuo-plugins`: add `.gitea/workflows/release.yml`.** Retarget the release *engine* half of
   `link/release.yml` (its header comment explicitly anticipates this — the plan/release steps
   consume only `{version, changelog, artifacts}`). The adapter half produces
   `runicgateway-overlay-<ver>.tar.gz` containing `overlay/`, `patches/`, and a `manifest.json`
   (version, commit, per-file SHA256, declared protocol version, minimum ServUO version).

   As built ([servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7)),
   with three deviations from `link`'s copy that each fell out of the repo rather than being chosen:

   - **No build gates, structural gates instead.** Nothing in that repo can be compiled without
     ServUO reference assemblies, so CI asserts what it honestly can: `Bridge.cfg` and the Bridge
     scripts present, `Scripts.csproj` present (its absence ships code that never compiles while
     ServUO reports success — §2.1), every `.patch` parseable via `git apply --stat`, and each
     patch's companion `.cs` present.
   - **No bump commit, so no push to `main`.** `link` writes the version into `Cargo.toml` because
     the binary embeds it; the tarball embeds nothing but the generated manifest, so the tag *is*
     the version. That workflow needs no branch-protection exception.
   - **`overlay.toml` at the repo root** holds the declared `protocol` and the ServUO compatibility
     values, read by CI into the manifest. It exists because the number needs one maintained home —
     see §7 for why the plugin cannot simply be asked.

   The tarball uses a **fixed** top-level directory, `runicgateway-overlay/`, not a versioned one:
   the installer looks for `overlay/`, `patches/` and `manifest.json` at known paths rather than
   parsing the version it is trying to read. Member order, mtime and ownership are pinned, so a
   given tree yields a byte-identical tarball and its checksum moves only when its contents do.
2. **`link`: make the sidecar installable.** Confirm/settle default data paths, and add a way to
   read back config non-interactively (e.g. `--print-config` emitting JSON: bind addresses, token,
   protocol version, db path) so the installer does not have to scrape logs for the token.

   As built ([link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24)) — the sidecar
   had **no CLI at all** before this, so the shape was chosen rather than inherited:

   - **Four flags, hand-rolled:** `--print-config`, `--config <PATH>`, `--version`, `--help`. No
     argument-parsing crate — it would be larger than the code it replaced — and deliberately no
     flags that duplicate a config key, so `sidecar.toml` stays the single place settings live.
     An unrecognized argument exits `2`; silently ignoring a typo'd flag would start a sidecar that
     is not the one the installer asked for.
   - **`--print-config` performs first-run setup rather than only reporting.** It runs the same
     load path a normal start does, so a missing config file is written and a blank token is
     generated and saved. That collapses "provision the sidecar" and "find out its token" into one
     non-interactive call — which is exactly the sequence §6 needs. `config_created` and
     `token_generated` say whether *this* run did either, because the values alone cannot
     distinguish a fresh install from a re-read of an existing one, and a re-run must not report a
     token as newly minted.
   - **The document is the whole of stdout.** The log subscriber writes to stdout, so it is not
     started in this mode. `ws_path` is emitted from the same constant the route is registered
     with, so the installer's WebSocket URL cannot drift from the server's.
   - **Relative `[store].path` now anchors to the config file's directory, not the CWD** — see
     §2.3, which this half-closes on the sidecar side. Absolute paths are used as written; parent
     directories are created; `:memory:` and `file:` URIs are left alone.
   - **The db path is handed to sqlx as a path, not a `sqlite://` URL.** The URL spelling is
     parsed as one: it percent-decodes the path and splits it on `?`, so an installed path
     containing `%20` opened a different file than the operator named.
   - **No platform data directories are compiled in.** That is the "settle" half of this item, and
     the answer is that the *installer* owns layout (§2.3) and pins `UOLINK_CONFIG` /
     `UOLINK_DB_PATH` in the service definition. Baking `/etc` and `%ProgramData%` defaults into
     the binary would give the same paths two owners and break `cargo run` in a working tree.
3. **Bundle CI in the installer repo** (§7). Compose job (read both repos' latest releases → run the
   two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each
   component's release workflow. This must exist before Phase 1 is useful, since the installer
   resolves what to install *from* the bundle.

   As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3),
   [link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25),
   [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) —
   `installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open:

   - **Bundles are committed to the installer repo, not published as releases** — see §7.1 for
     where and why. That was the one genuinely open question here, and the deciding factor is that
     this repo's *own* releases are the installer binaries. They were committed to `main` until
     2026-08-05, when the first run that actually had a bundle to write found `main` protected;
     they now live on a `bundles` branch (§7.1).
   - **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary.
     `--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0`
     onward, and `--bundle <tag>` has to be able to recompose a bundle from an older pair. Reading
     `sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution
     of a downloaded artifact, and does not provision a throwaway config whose auth token would then
     be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating
     "could not read" as "matches" is exactly how a mismatched pair would ship.
   - **Gate 2 records the hash CI computed itself**, after verifying the download against the
     publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no*
     `SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not
     mention, which would put an unverified artifact in the bundle.
   - **Release metadata is read anonymously**, on purpose: those are exactly the requests the
     shipped installer makes on a host with no Gitea credentials, so a repo flipped to private
     fails CI here instead of on an operator's machine.
   - **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys
     by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job
     rather than silently omitting the new binary from every bundle.
   - **A run that changes nothing writes nothing** — the comparison excludes `bundle` and
     `generated`, which are metadata about the run. Without that the nightly cron would commit a
     dated duplicate of the same matrix every morning.

   The workflow's compose steps were run against the live releases before merge, producing the
   first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so
   the manifest exists ahead of the binary that reads it.
4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the
   shape is settled.

   As built ([`INSTALL.md`](INSTALL.md)) — written *before* the binary on purpose. Everything it
   installs is already released (items 1–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. Phase 1–4 implement it.

   - **It was useful before the installer existed, and outlives it.** Appendix A is the same
     deployment done by hand — bundle fetch, tarball verify + overlay copy, the optional patch tier,
     `--print-config` provisioning, and a systemd unit / `sc create` service — composed from the
     released artifacts' actual contents and the sidecar's config and CLI source rather than from
     memory. That appendix doubles as **Phase 1's acceptance test**: walking it end to end on a real
     shard is what proves the automated path has nothing left to discover.
   - **The installer does not install itself.** §5's `runicgateway doctor` sketch implied a name on
     `PATH`; nothing places one there, and adding self-installation would give the tool a second
     lifecycle to manage. The guide names the downloaded artifact, says to keep it, and shortens it
     in later examples.
   - **The flag surface got fixed here**, because a guide cannot describe a run in the abstract:
     `--verify`, `--bundle`, `--purge` were already named by §5/§7; `--servuo`, `--patches` /
     `--no-patches`, `--host`, `--site-url` and `--yes` are the remainder, chosen so every prompt
     in §6's handoff has a non-interactive equivalent and an unattended install is expressible.
   - **A modified `Bridge.cfg` must survive an update** — see Phase 1, where this changes the sync
     rule inherited from `deploy.ps1`.
   - **Remote-website deployments needed an answer.** `[web] bind` defaults to `127.0.0.1`, which
     only works when the site runs on the shard host. The guide says to widen it, firewall it to
     the website's address, and front it with TLS or a VPN off a trusted network — because the
     token is always required but travels as a plain bearer token over HTTP. `[shard] bind` stays
     on loopback, since that socket carries inbound commands *into* the game.

### Phase 1 — installer core

- ServUO root detection and validation (`ServUO.exe`, `Scripts/`, `Config/`), with version detection
  and an explicit refusal when the ServUO process is running.
- Overlay sync: fetch tarball → verify SHA256 → hash-compare against the server tree → add/change,
  **never delete**. Port of `deploy.ps1` semantics including its `-Verify` dry run (`--verify`).
- **One deviation from `deploy.ps1`: an operator-modified `Config/Bridge.cfg` is reported, not
  overwritten.** `deploy.ps1` overwrites every file whose hash differs, which is right for a
  developer redeploying their own tree and wrong for an operator who has set `LinkUrl`,
  `PublicConnectAddress` and sweep intervals — an `update` would silently revert the shard's entire
  configuration. `install.json` records the hash deployed, so the installer can distinguish "the
  operator edited this" from "the overlay moved on" (§7.0) and act only on the second. The rule is
  specific to `Bridge.cfg`: it is the only file in the overlay that is *meant* to be edited in
  place, and it carries no code, so a stale copy cannot break the build. Every `.cs` file and
  `Scripts.csproj` still overwrite unconditionally.
- Write `install.json`: component, version, source commit, per-file hashes, applied patches,
  timestamp.
- Idempotent re-runs; a second run with no upstream change reports "unchanged" and writes nothing.

**As built** ([installer#4](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/4)) — the
crate at the repo root, `install` implemented end to end, `doctor`/`update`/`uninstall` parsed and
answered with the phase they arrive in rather than "unrecognized command". The decisions that were
not already settled above:

- **It lands on `edge`, not `main`.** `release.yml` publishes an installer binary on every push to
  `main`, and its crate guard was written to arm "the moment Phase 1 lands the crate" — which would
  have published a binary that deploys the overlay but cannot install the sidecar, contradicting
  everything `INSTALL.md` promises a release does. Phases 1 and 2 land on `edge`; the `edge → main`
  cutover cuts the first release. `pr-checks.yml` gates PRs into `edge` on the same rules, so the
  branch where the work happens is not the ungated one. No workflow needed a temporary edit.
- **The run says what it did *not* do.** A Phase 1 `install` ends with an unmissable block naming
  the sidecar as not installed, pointing at `INSTALL.md` A3/A4, and printing the bundle's binary URL
  and SHA256 so a hand install matches the pair. `--patches` is the sharp edge here: it is accepted
  (so the flag surface is the published one) but reports `REQUESTED BUT NOT APPLIED — no stock
  ServUO file has been touched`. A `--patches` run that completed quietly would be read as a
  patched shard.
- **The crate is a library plus a thin binary, and the library is not named after it.** Windows
  applies UAC *installer detection* to unsigned executables whose file name contains `install`: it
  demands elevation before the process starts, and a non-interactive session gets `os error 740`
  instead of a program. That is survivable for the shipped binary — it needs Administrator anyway,
  and `INSTALL.md` already says to run it from an elevated shell — but Cargo names test harnesses
  after their target, so a target called `runicgateway_installer` makes `cargo test` **unrunnable on
  Windows**, on the machine the shard smoke tests live on. The code therefore sits in a library
  called `rgdeploy`, the binary target keeps its published name, and `[[bin]] test = false` stops
  Cargo building a harness under it. Nothing an operator sees changes.
- **Dependencies chosen for the MinGW cross-build:** `ureq` (blocking HTTP over rustls/ring — no
  OpenSSL to cross-compile, and no async runtime for a tool that makes four sequential requests),
  `flate2` on its pure-Rust backend, `tar`, `sha2`, `serde`/`serde_json`, `chrono`, `anyhow`, and
  `sysinfo` for the running-shard check.
- **The shard-running check matches by path, not by process name.** `deploy.ps1` can look for a
  process called `ServUO` because it only runs on Windows; on Linux the same shard is `mono` or
  `dotnet` with `ServUO.exe` as an argument, and a name match would answer "not running" for a live
  shard — the one wrong answer that corrupts `Scripts.dll`. The installer requires a process whose
  executable or command line names *both* the tree being deployed into and `ServUO.exe`, so a second
  shard elsewhere on the host does not block this deploy, and the installer never matches itself.
- **ServUO's version is read from `Server/AssemblyInfo.cs`**, not from `ServUO.exe`'s PE metadata:
  it is the same *source* tree the patch tier diffs against, needs no dependency, and works
  identically on Linux. `57.4.0.0` and `57.4` are normalized to compare equal. An unreadable version
  is reported as `unknown` and treated as **not** supported — an unreadable version is not evidence
  of a good one — which is what Phase 3 will gate the tier on.
- **`install.json` records a state, not a verb.** Per-file entries are `deployed` or
  `kept-operator-modified`, never `add`/`change`/`unchanged`. Recording the run's verb made the
  record differ between a first run and an identical second one, which rewrote the file on every
  run and broke "a second run writes nothing" in the least visible way available. What later
  commands need is whose copy is in the tree, and that does not change because time passed.
- **The `Bridge.cfg` decision compares against the last hash the installer *deployed*, not the last
  hash it *saw*.** Once a file has been kept, the record's on-disk hash is the operator's content —
  so a rule phrased as "is the tree still what the record last saw?" matches on the very next run
  and overwrites exactly the file it had just protected. A keep has to stay kept for as long as the
  edit is there; a live three-run test covers it, because the bug only appears from the second run
  on.
- **A prior record is only consulted when it names this tree.** A host whose `install.json` points
  at a different ServUO root — a shard moved or rebuilt beside the old one — is treated as having no
  prior deployment, which errs toward keeping the operator's file.
- **The download is verified twice, for two different reasons.** The tarball's SHA256 is checked
  against the bundle while it is being written (the trust anchor — these artifacts are unsigned);
  then every extracted file is re-hashed against the release's own `manifest.json`, which catches a
  truncated extraction and is what makes the hashes copied into `install.json` worth trusting. The
  manifest's `protocol` and `version` are also cross-checked against the bundle, so an artifact that
  disagrees with the matrix that named it stops the run before anything is written.
- **`RUNICGATEWAY_STATE_DIR` relocates the installer's own state**, so a run can be tested without
  root. Documented in `--help` rather than hidden: an undocumented variable that moves where a tool
  writes is worse than a documented one, and `doctor` must honour the same value to find what
  `install` wrote.

Verified on this machine against a real ServUO 57.4 tree (`--verify`, which reported the tree's
`Bridge.cfg` as operator-owned and 23 code files as changed) and end to end into a scratch tree:
24 files deployed, a second run reporting `unchanged` and leaving `install.json` untouched, an
edited `Bridge.cfg` kept across three further runs while a hand-edited `.cs` was overwritten each
time, a pinned `--bundle`, a missing bundle tag, and a refusal — pid and path named, exit 1 — with a
process running out of the tree.

### Phase 2 — uo-link install and service

- Linux: binary → `/usr/bin/runicgateway-link`, config → `/etc/runicgateway/sidecar.toml`, db →
  `/var/lib/runicgateway/`, systemd unit with a dedicated user, `enable` + `start`.
- Windows: `%ProgramFiles%\RunicGateway\`, data in `%ProgramData%\RunicGateway\`, service
  registration with automatic start and restart-on-failure.
- Both: `UOLINK_CONFIG` and `UOLINK_DB_PATH` pinned in the service definition (§2.3).
- Token surfacing (§6): run the installed binary once as
  `uo-link-sidecar --print-config --config <the pinned path>` **before** registering the service.
  That both writes the config the service will read and returns the token to print, so the service
  never starts against a config that does not exist yet.

**As built** ([installer#5](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/5)) —
`src/sidecar.rs` (binary, config, handoff) and `src/service.rs` (systemd, Windows SCM), wired into
the same `install` run. The decisions that were not already settled above:

- **Both platforms run the sidecar as a dedicated unprivileged identity.** Linux gets the system
  user this section already specified; Windows gets a **virtual service account**
  (`sc create … obj= "NT SERVICE\RunicGatewayLink"`), which the SCM creates itself and which has no
  password. Plain `sc create` would have run it as `LocalSystem` — the most privileged local
  identity there is, for a process that listens on two TCP ports while its Linux twin deliberately
  does not run as root. The account only exists *after* `sc create`, which fixes the order of the
  file permissions below.
- **`sidecar.toml` is locked down, because it holds the token.** Neither default location protects
  it: `/etc` is world-readable and `%ProgramData%` grants `Users` read by inheritance, so an
  unprivileged local account could read the shard's auth token out of a stock install. Linux gets
  `chmod 600` plus `chown` to the service user; Windows gets `icacls /inheritance:r` down to SYSTEM
  and Administrators **before** registration, then a read grant for the service account after it
  exists. The database directory gets a separate write grant, since SQLite writes journal and WAL
  files beside the database.
- **`--verify` runs no part of the sidecar half.** `--print-config` provisions — it writes the
  config and mints a token — so a dry run that called it would create exactly the state it claims
  not to. A `--verify` run reports what would be installed, reads no token, and prints no handoff.
  It also **carries the existing `link` section of `install.json` through untouched**, so a dry run
  on an installed host cannot make its service disappear from the record.
- **The installed binary's protocol version is checked against the bundle, and a mismatch stops the
  run before the service is registered.** Gate 1 (§7.1) read that number from source at the release
  tag; this is the same check applied to the binary that will actually answer the website. The
  binary is left on disk — harmless without a service — rather than the run pretending to succeed.
- **`RUNICGATEWAY_STATE_DIR` now relocates the sidecar binary too, and suppresses service
  registration.** Phase 1 left the binary path alone because nothing wrote it. A relocated run that
  still dropped a binary into `/usr/bin` and registered a system service would be exactly the
  half-in-the-real-system accident the variable exists to avoid — and there is no such thing as a
  relocated systemd unit or Windows service. Such a run also leaves file permissions alone, because
  hardening a scratch config against the only account that will ever read it just breaks the next
  test run.
- **A host the installer cannot drive gets the recipe, not a failure or a weaker service.** No
  systemd (`/run/systemd/system` absent — the correct test, since `systemctl` is present in plenty
  of containers where PID 1 is not systemd), or a service user that cannot be created: the binary
  and config are still installed, `install.json` records `service: null`, and the run prints the
  exact unit text and commands. There is **no fallback to `User=root` or `LocalSystem`** — a service
  quietly running with more privilege than its own documentation promises is worse than one that was
  not registered. The printed Windows recipe states plainly whether the run locked the config down
  or the operator still has to.
- **`install.json` never records the token.** The `link` section holds versions, the binary's hash,
  the config and database paths, and the service's name, unit path and account. The token goes to
  the terminal and to `sidecar.toml`, and the record is a support artifact people paste into bug
  reports.
- **The service is stopped before its binary is replaced, and restarted rather than started
  afterwards.** On Windows the file is locked while the service runs (and `sc stop` returns as soon
  as the stop is *pending*, so the stop is polled, not slept on); on Linux the replacement is
  permitted but leaves the old code serving until something restarts it. `systemctl start` on an
  active unit is a no-op, which is precisely the wrong outcome after a replacement.

Verified on this machine end to end against a relocated layout: the bundle's Windows sidecar
downloaded and checksum-verified, `--print-config` provisioning a fresh config and returning a
token, the §6 handoff printed with the URLs composed from the host rather than the bind address, a
second run reporting `unchanged` / `already present` and leaving `install.json` byte-identical, a
`--verify` run over an installed host writing nothing and preserving the `link` section, and a
tampered binary detected by hash and replaced with no stray staging file left behind.

**Service registration itself stayed unverified until Phase 4** — a relocated 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, installing into `/usr/bin`,
`/etc/runicgateway` and `/var/lib/runicgateway` as root: the unit is written and `enable`d, the
service comes up `active, enabled` as the unprivileged `runicgateway` user, `sidecar.toml` lands
`600` owned by it, the database is created under `/var/lib` (so the `UOLINK_DB_PATH` pin works),
`/health` answers protocol 3, and `uninstall` takes the service, the unit, the binary and the
account away again while leaving `sidecar.toml`, the database and every overlay file in the ServUO
tree untouched.

Doing that found one bug that only a real service host could show, fixed in
[installer#8](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/8): **`user_created` has
to be sticky.** `service::prepare` answers "did *this run* create the account", which is false from
the second run on, so recording it verbatim made the field describe the run rather than the state —
the same class as Phase 1's two live-run bugs. It rewrote `install.json` on an identical re-run,
and it made `uninstall` (which removes only an account it created) silently leave behind the very
user this tool had added. The record now inherits `true` from a prior record naming the same
account, and only that one. Windows never showed it because the SCM's virtual account is not
something the installer creates.

**Still unverified: the Windows SCM half.** `sc create` demands elevation, and this machine's
automation runs unelevated; the systemd half above is the platform that could be driven end to end.

### Phase 3 — patch tier (opt-in)

Everything in §2.2. Detect applicability, dry-run, apply, record, warn about the core rebuild, and
degrade loudly rather than silently.

The rung ladder of §2.2.1 is the bulk of the work here: parse each `.patch` into hunks, reconstruct
each hunk's pre- and post-image blocks, and resolve the file through rungs 0–3 before writing
anything. The unsupported-version path (§2.2.2) is part of this phase, not a later polish — the
banner, the defaulted-to-no prompt, the `--patches-unsupported-servuo` flag, and the unsupported
marker carried into `install.json`, `doctor` and the uninstall report. Two pieces carry the risk and want direct tests — the hunk parser (headers, `\ No newline
at end of file`, CRLF files) and the uniqueness rule (a region that appears twice must fail, not
pick the first). Fixtures are cheap: the three stock 57.4 files, each with a hand edit far from the
patched region (must reach rung 2), an edit inside it (must reach rung 3), and an already-patched
copy (must reach rung 0).

**As built** ([installer#6](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/6), with the
metadata half in [servuo-plugins#10](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/10))
— `src/diff.rs` (the parser), `src/patch.rs` (the ladder and the applier) and `src/tier.rs` (consent,
writing, reporting, recording), wired into the same `install` run between the overlay sync and the
sidecar. The decisions that were not already settled above:

- **The engine is fully native; `git` is never invoked.** §2.2.1 wrote rung 1 as "apply verbatim
  with `git apply`", but §1 chose the release tarball precisely so there would be **no git on the
  shard host**, and rung 2 needs a native applier regardless. One engine now serves both: rung 1
  keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image,
  computed as a git blob SHA1 in process — while the write goes through rung 2's code path. That
  leaves one set of CRLF and whitespace behaviours to reason about instead of two, and a bug report
  never has to say which engine ran. It is also not academic: the shipped `.patch` files are CRLF in
  a Windows checkout while two of their three targets are LF, so `git apply` **refuses** patches
  this places correctly.
- **What a `.patch` cannot say is declared by the release, with a built-in fallback.** Which patches
  form one all-or-nothing unit, which companion `.cs` follows which, whether a **core** rebuild is
  needed, and what declining costs are all things a diff does not carry. `servuo-plugins/patches/tier.json`
  declares them and the release workflow folds them into `manifest.json` as `patch_tier` (§7.0), so
  adding a patch regenerates release metadata rather than requiring an installer release — the same
  rule §7.1 applies to the bundle. Overlay `v0.1.1` is in the current bundle and declares nothing,
  so the installer carries a built-in description of exactly that release; a declared tier always
  wins. A checked-in fixture of the release workflow's **own jq output** asserts the two descriptions
  are identical, so the two repos cannot drift apart quietly — the failure mode otherwise is a tier
  that is silently never offered.
- **All-or-nothing gained a second level.** §2.2.1 makes it per *patch file*; the tier is also
  all-or-nothing per **feature**, because the two vendor-sale patches are one unit — `EventSink.cs`
  grows the event, `PlayerVendorGumps.cs` raises it, and the companion subscribes to it. Applying
  either alone yields a tree that does not compile or silently never emits. A patch that could have
  been placed but was held back by a sibling says so in as many words; reporting it as applied is
  the exact misreading this tier exists to prevent.
- **The pre-image of every patched file is cached**, under `<state>/patches/originals/`, mirroring
  its path in the ServUO tree. The tier is the only part of the installer that edits a file the
  operator owns, and this is what turns "here are the hunks we added" into a revert anyone can
  verify — which matters most for a `region-match` apply, where the surrounding file was already
  theirs. It lives in the state directory rather than beside the file it copies, because an
  installer-owned file inside the ServUO tree is one `uninstall` has promised never to clean up. It
  is written before the first edit and never overwritten, so it stays pre-tier however many times
  `install` runs.
- **Every patch the tier *evaluated* is cached, not only the ones that applied** — a refinement of
  §2.2's "cache the applied `.patch` files". The refusal message names that path as the file to
  apply by hand (as §4 of `INSTALL.md` already illustrated), so caching only successes would point
  an operator at a file the run had decided not to write.
- **Rung 0 reuses the previous record whole rather than re-deriving it.** The rung is the
  support-relevant fact — how did this land? — and a later run re-deriving it answers
  `already-present` for something that first landed as `region-match`. That flip rewrites
  `install.json` on the second run of an identical install, which is the same class of bug as the
  `Bridge.cfg` comparison in Phase 1: a record describing the run instead of the state. A tree
  patched by hand per `INSTALL.md` Appendix A2 has no prior record, so there `already-present` is
  correctly what gets minted.
- **Declining never erases what an earlier run applied**, and no longer claims a loss that is not
  real. `--no-patches` and an unselected prompt both carry the previous `patches` section through
  untouched, as `--verify` does — and the "Without it:" line now names only the features the record
  does not already show as applied.
- **Withholding `--patches-unsupported-servuo` skips the tier loudly rather than failing the run.**
  By that point the overlay is deployed and the sidecar is about to be installed; turning a completed
  base install into exit 1 over a tier documented as optional would cost the operator more than the
  tier is worth. Saying nothing would be the real failure, so it is reported where it happens.

Verified on this machine against the ServUO 57.4 tree at `C:\Users\colby\Desktop\ServUO`, across
four scratch roots built from its real files: a hand-patched tree (rung 0 on both vendor-sale
patches), a reverse-applied stock one (**rung 1 on the real `EventSink.cs`**, whose blob hash
reproduces the patch's declared `index d30788f` pre-image), a feature resolving at mixed rungs, a
tree with edits inside two patched regions (rung 3 — nothing written, the placeable sibling held
back, no companions copied, and all three patches cached anyway), and a non-57.4 tree both with and
without the extra consent flag. Three consecutive runs left `install.json` byte-identical, the
patched files unchanged, and the cached pre-image still pre-patch.

### Phase 4 — diagnostics and updates

`runicgateway doctor` — the command that makes the whole thing supportable:

```
✓ ServUO found            /opt/ServUO  (57.4)
✓ Overlay in sync         24 files, all hashes match install.json
⚠ Patch tier              1 of 3 applied (region-match) — vendor.sale unavailable
✓ uo-link installed       1.1.0
✓ Service                 running, enabled
✓ Sidecar reachable       127.0.0.1:8080  /health ok
✓ Protocol                sidecar 3 = overlay manifest 3
✗ Shard connected         no shard has dialed in since boot
```

The last check matters most: it is the only thing that distinguishes "files copied" from "the bridge
actually works" (§2.1).

Three of those rows are answered by the sidecar's own CLI rather than by inspecting the filesystem:
`--version` prints `uo-link-sidecar <ver> (protocol <n>)`, and `--print-config` gives the config and
db paths the *installed service* resolves — so `doctor` reports what the binary would actually do,
not what `install.json` believes it was told to do. The protocol row compares that number against
the overlay manifest's declared one (§7.0).

`runicgateway update` — resolves the current bundle (§7.1), then acts asymmetrically by component,
deliberately:

- **uo-link**: compare the bundle's version against what is installed → download → verify checksum →
  replace binary → restart service.
- **plugin overlay**: download the bundle's overlay tarball → verify → re-sync (leaving a modified
  `Bridge.cfg` alone — Phase 1) → record commit → tell the operator ServUO must restart (the
  installer does not restart the shard).

Because both come from one bundle, an update always moves to a combination whose protocol versions
were checked together, rather than to two independently-latest artifacts that may disagree.

`runicgateway uninstall` — **removes only what it exclusively owns, and never edits the ServUO
tree.** The installer cannot know what the operator has changed in those files since deployment, so
a clever automatic revert risks silently eating their work. It removes and it reports:

| Action | Scope |
|---|---|
| Removed | uo-link binary, its service entry (systemd unit / Windows service), `install.json` |
| Kept | `sidecar.toml`, `uo-link.db`, the cached patch set with its pre-patch originals, and (from Phase 5) the backups an upgrade took — `--purge` to drop them |
| **Printed, not done** | Every overlay file deployed into the ServUO tree, listed by path, for the operator to delete |
| **Printed, not done** | The exact hunks each applied patch added to `EventSink.cs`, `PlayerVendorGumps.cs`, `Logging.cs`, rendered from the cached `.patch` files — with the rung that applied each one (§2.2.1), since a `region-match` apply means the surrounding file was already the operator's — for them to revert by hand |

The printed report is also written to a file, so it survives the terminal scrollback of a long
uninstall.

**As built** ([installer#7](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/7)) —
`src/doctor.rs`, `src/update.rs` and `src/uninstall.rs`, plus `service::observe`/`service::remove`
and a `Mode` on the install pipeline. The decisions that were not already settled above:

- **`update` is the `install` pipeline in a different mode, not a second implementation.** This
  section describes it as "re-resolve the bundle, then move both components to it" — which is what
  an `install` over an existing deployment already does, down to keeping a modified `Bridge.cfg`
  and restarting the service after replacing its binary. A separate implementation would have given
  the sync rules, the two protocol cross-checks and the record-carrying logic a second place to
  disagree. What actually differs is four things: a prior record is **required** (an `update` on an
  uninstalled host is a typo or a state directory the run cannot see — never a first install under
  a verb that promises to preserve), the tree comes from that record rather than from detection (a
  host with two shards must not have an update silently move to the other one), the tier's scope
  narrows, and the close is a diff instead of a handoff.
- **`update` does not reprint the token, and does call out a protocol change.** The token has not
  changed and the website already holds it; reprinting a secret nobody has to act on just puts it
  in another scrollback. The protocol number is the one thing an update *can* change that the
  website has to be told about — a stale value in Admin → Shard is answered `409` and looks to an
  operator exactly like the shard going offline.
- **The tier under `update` re-resolves only what an earlier run applied, without asking again.**
  Not a fresh offer: a shard that declined stays unpatched through every update, which is what
  opt-in has to mean. Consent is not re-sought for what is already in the tree — including on an
  unsupported ServUO, where `install` demands a second flag — because the record *is* the evidence
  that the operator opted in, and re-prompting would make an unattended update impossible on
  precisely the hosts that most need their patches re-checked when an overlay moves. New features
  the release offers are named but not applied; `--patches` is how they are taken up. A feature the
  record shows as applied that the release no longer declares keeps its record rather than being
  dropped: its edits are still in the tree, and a record that forgot them would stop `uninstall`
  printing hunks that are really there.
- **`doctor` asks the thing itself, and asks it the way the service does.** `--print-config` is run
  under the same `UOLINK_DB_PATH` the unit pins, so the config and database it names are the ones
  the *service* opens rather than the ones the binary would pick on its own — which is what §5's
  sketch promised and a bare call would have got wrong on Linux. It is also run **only when the
  config already exists**, because that flag provisions: a diagnosis must not create the state it
  is reporting on.
- **`doctor` exits `1` when a row failed, and a `⚠` never causes that.** The rule makes it readable
  from a monitoring script, and the split is what keeps the report worth reading: a stopped shard
  is a `⚠` with the reason ("you have not started it"), while a *running* shard that has not dialed
  in is the `✗` (§2.1's silent failure). Being offline is a `⚠` too — a shard host with no route to
  Gitea is a supported way to run this, and failing a health check over it would report a working
  deployment as broken. Both network calls take short timeouts for the same reason.
- **The patch row re-resolves each recorded patch against the tree.** The cached `.patch` makes it
  possible offline, and the expected answer is rung 0. A core upgrade, a hand revert or a restored
  backup silently removes the tier's edits, and nothing else in the report would notice.
- **The cached patch set and `patches/originals/` survive an uninstall** — a deviation from the
  table above, which listed them as removed. The report that same command prints tells the operator
  to diff their stock files against those originals; deleting them would have made the advice
  impossible to follow within one command's output. They are the only offline record of what the
  tier changed once the release tarball is gone, so `--purge` is what removes them, alongside the
  config and the database. The report names every path it left behind.
- **`--yes` means yes on `uninstall`, not "take the default".** Everywhere else that flag answers an
  offer the *run* made, so taking the safe default is right. Here the operator typed the destructive
  verb; reading `--yes` as "no" would leave an unattended uninstall unable to express itself at all,
  and a script that appears to succeed while removing nothing is the worse of the two failures. The
  interactive prompt still defaults to **no**, after listing exactly what will and will not be
  touched.
- **`uninstall` exits `1` for a step it could not carry out**, having done everything else. The
  common case is a binary still locked by a sidecar somebody started by hand, so a permission error
  on that file says so rather than sending the operator to look at ACLs. The Linux service account
  is removed only when the record says this installer created it; Windows' virtual account goes with
  the service.
- **The overlay listing flags files edited since deployment.** An operator deleting that list file
  by file must not lose their own `Bridge.cfg` settings or a script edit without being told which
  ones those are.

Verified on this machine against a scratch ServUO 57.4 tree built from the real files: a healthy
`doctor` (exit 0), one against a tree with a deleted overlay file, an edited one and a reverted
patch (all three found, exit 1), an `update --verify` that wrote nothing, a real `update` that
repaired all three and left `install.json` byte-identical, `uninstall` with and without `--purge`,
a second `uninstall`, a locked binary reported as a problem with exit 1, and `doctor`/`update` on a
host with no record. `fmt`/`clippy -D warnings`/tests were run for Linux in Docker as well as on the
Windows host, since only half of `service.rs` compiles on either.

### Phase 5 — packaging polish

The last work before the first release. It was sketched as four items — `.deb` packaging, Windows
MSI, arm64 cross build, optional automated backup before upgrade — and the org lead settled its scope
on 2026-08-05: **two of the four are dropped rather than deferred**, because what stops them is an
ownership conflict that does not improve with time, and two are built.

**It ran 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* (§3), so shipping first would have meant a first release superseded by the next one, and
operators who downloaded a bare binary being told to re-download a package. Deferring the cutover
cost nothing: nothing was published from `edge`, and the guide's Appendix A was the path meanwhile
(and remains supported now that it is no longer the default).

| Item | Decision | State |
|---|---|---|
| `.deb` for uo-link | **Dropped** (§5.1) | — |
| Windows MSI | **Dropped** (§5.1) | — |
| arm64 cross build | **Build** — Linux `aarch64`, both components (§5.2) | ✅ Merged — [installer#9](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/9), [link#26](https://gitea.whitlocktech.com/RunicGateway/link/pulls/26), [installer#10](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/10) |
| Backup before upgrade | **Build** — on by default, scoped to what a run overwrites (§5.3) | ✅ Merged — [installer#11](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/11) |
| Docs the release invalidates | **Do** (§5.4) | ✅ Done — `INSTALL.md` took the `aarch64` and backup content pre-cutover; the status rewrites landed after it |

#### 5.1 What ships is plain binaries: no `.deb`, no MSI

Both were proposed before Phases 2 and 4 existed. They now collide with the components those phases
made the installer own, and the collision is the deciding argument in each case.

- **A `.deb` under link's release would own `/usr/bin/runicgateway-link`, the systemd unit and the
  service user** — the same three things `service.rs` writes, hardens and removes, and that
  `install.json` records so `doctor` and `uninstall` can reason about them. Two owners for one file
  is not a packaging detail: `uninstall` deleting a dpkg-owned binary leaves the package *installed*
  and broken, an `apt upgrade` replacing that binary makes `doctor` report drift no operator caused,
  and the unit text would live in two repos free to disagree about the account it runs as. The
  variant that avoids all of that — a binary-only `.deb`, no unit, no user — buys apt-managed
  upgrades of one file, which `update` already does from a **protocol-checked** bundle (§7.1). That
  is the stronger of the two guarantees, so the package would be trading correctness for
  familiarity.
- **An MSI contradicts a decision already locked**: the installer does not install itself (Phase 0.4
  as built). It would also add a second uninstall path — Add/Remove Programs — beside the
  `uninstall` verb, which owns state an MSI cannot see: `install.json`, the cached patch set and its
  pre-images, and the ServUO-tree report that is printed rather than done (Phase 4). Unsigned, it
  raises the same SmartScreen prompt a bare `.exe` does (§3), so it does not even buy the dialog it
  looks like it should.

So §3's release layout loses its `.deb` line and gains no installer package. Nothing in Phases 1–4
changes — this item is a removal, which is why settling it is cheap and shipping it first would not
have been.

**What would reopen it.** If operators ask for `apt`- or `winget`-managed installs, the thing to
package is the sidecar as a standalone service *with the installer taught to detect and defer to a
package-managed one*. That is a design change to who owns the service, not packaging polish, and it
is out of scope here.

#### 5.2 arm64 — Linux `aarch64`, both components

§2.6 recorded arm64 as "not buildable today", which was true of the *workflows* rather than of Rust:
`link/release.yml` and `installer/release.yml` cross-compile `x86_64` Linux and Windows only, so
`platform_key()` (`src/bundle.rs`) refuses every other host by name. Ampere/Graviton instances and
Pi-class boxes are a realistic ServUO home, and the installer's dependency set was already chosen to
avoid OpenSSL and any C toolchain of its own (Phase 1 as built) — which is what makes this two build
steps per workflow rather than a toolchain project.

**Both components or neither.** An installer that runs on `aarch64` but resolves a bundle carrying no
`aarch64` sidecar has moved the failure later, not fixed it. Windows-on-arm and macOS stay unbuilt:
no supported MinGW target here for the first, and no shard host is the second.

**The order is forced by the bundle CI, which is strict on purpose** (Phase 0.3 as built): an
unrecognized `link` asset name is a hard failure, and `linux-x86_64` + `windows-x86_64` are asserted
present. So a link release carrying a new binary reddens the compose job unless it is taught the name
first — and requiring the name before link publishes it fails *every* bundle for as long as the gap
lasts. Hence:

| Step | Repo / branch | Change | Why it is this one first |
|---|---|---|---|
| 1 | `installer` `main` | `bundle.yml` maps `*-linux-aarch64` → `linux-aarch64`, and **does not** add it to the required list | The compose job runs from `main`; teaching it the name first means link's next release composes instead of failing |
| 2 | `link` `main` | `release.yml` adds `aarch64-unknown-linux-gnu`, cross-linked with `gcc-aarch64-linux-gnu` | Publishes the first `aarch64` sidecar; the nightly cron folds it into a bundle |
| 3 | `installer` `main` | `linux-aarch64` joins the required list | Only safe once a release actually carries it — from here a dropped target reddens CI instead of silently vanishing from every bundle |
| 4 | `installer` `edge` | `release.yml` adds the same target; `platform_key()` learns `("linux", "aarch64")` | The installer binary itself, on the branch that carries the crate |

`bundle.yml` is therefore edited on `main` only and never on `edge`, so the cutover merge has nothing
to conflict over.

**What building it found.** Neither crate cross-compiles with the arm64 *compiler* alone.
`gcc-aarch64-linux-gnu` only **recommends** `libc6-dev-arm64-cross`, and both release workflows
install with `--no-install-recommends` — so the C that each crate pulls in (bundled SQLite under
`sqlx` for the sidecar, `ring` under `ureq`'s rustls for the installer) dies on a missing
`bits/libc-header-start.h` while every Rust dependency builds fine. Both halves were reproduced in a
`rust:1-slim-bookworm` container, failure then fix, before either workflow was written.

#### 5.3 Backup before overwrite

**Scoped by what cannot be fetched again.** Not the sidecar binary or the overlay files — both are
re-downloadable and hash-named in the bundle. Not the database either: `link/sidecar/src/store.rs`
creates every table `IF NOT EXISTS` and every one of them holds shard state the sweeps repopulate, so
it is a cache with a schema rather than a record. What an `update` can destroy irrecoverably is:

1. **An operator's own edits to a deployed `.cs` file.** Phase 1 overwrites those unconditionally and
   by design — `Bridge.cfg` is the single exception — so the one place the tool knowingly discards
   work is the one place it should keep a copy.
2. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's saved
   config starts answering `409`/`401` with nothing on the sidecar to explain why (§2.4).

So the rule is **copy what this run is about to overwrite, plus `sidecar.toml`** — not a snapshot of
everything. A snapshot of all 24 overlay files would be mostly byte-identical to a release tarball
that is still downloadable, and the noise would bury the two files that matter.

- **Where:** `<state>/backups/<utc-timestamp>/`, each file mirroring its path in the tree it came
  from, beside a `manifest.json` naming the source path, its SHA256, and the bundle moved from and
  to. In the state directory, not the ServUO tree — the same rule the tier's `patches/originals/`
  follows, and for the same reason: `uninstall` has promised never to clean up in there.
- **When: whenever the run is about to overwrite something**, whichever verb was typed. This
  section first said "`update`, and `install` over an existing record", justified by a first install
  overwriting nothing — **which is wrong**, and building it is what showed that. A tree deployed by
  hand per [`INSTALL.md`](INSTALL.md) Appendix A2 — the path this project recommends while the
  binary is unreleased — has `.cs` files the first `install` plans as `Change` and overwrites, with
  no prior record anywhere to notice. The direct test covers that case, and a genuine first install
  onto a clean tree still writes nothing, because there is nothing to copy. `--verify` writes none
  either, for the reason it runs no part of the sidecar half (Phase 2): a dry run must not create
  state.
- **`sidecar.toml` joins a backup that is already being taken; it is never the reason for one.**
  Nothing in the installer rewrites it, so treating it as a trigger would leave a dated directory
  behind after every no-op `update` — the empty-backup problem one step along. It is copied so that
  a restored set of files arrives with the token that matches it.
- **`--no-backup`** opts out, for an operator with their own snapshotting.
- **Retention is three.** Older ones are pruned as new ones are written; an unbounded directory of
  ServUO source copies on a shard host is its own support problem. `uninstall` keeps them and
  `--purge` drops them, alongside the config, the database and the cached patch set — same rule as
  Phase 4, and the same reason: they are the only offline record of what was there before.
- **The directory is created lazily and the manifest is written last**, so a directory carrying one
  is a *complete* backup. Listing and pruning consider only those: a run interrupted mid-copy must
  neither be mistaken for a backup nor be able to evict a good one by being newer than it. It is
  left on disk for a human to look at rather than silently deleted.
- **Restore is printed, not done.** Consistent with the uninstall report, and for the identical
  reason: the installer cannot know what has changed since, and a clever automatic restore over a
  newer overlay eats work rather than saving it. `doctor` names the most recent backup and its
  timestamp, so an operator asking "can I go back" does not have to know the layout to answer.

#### 5.4 Documentation the release layout invalidates

Not optional, and grouped here because a first release is the moment these are read for the first
time by someone who was not in the room:

- **`installer/README.md`'s status table** — the first thing a visitor to the repo reads, and it
  described a tool that was neither finished nor released. Rewritten at the cutover.
- **`INSTALL.md`** gained the `aarch64` download lines, the backup behaviour and `--no-backup`
  before the cutover, and its status banner afterwards: the installer is the path the guide leads
  with, Appendix A the supported manual one.
- **This file:** §3 lost the `.deb` and gained the `aarch64` assets, §2.6's arm64 bullet is now
  historical, and §7.1's example bundle grew the third asset key.

#### Cutover entry criteria — met, 2026-08-07

Both gates were satisfied before
[installer#17](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/17) merged: this phase,
and the **Windows SCM verification** (see the status header and
[Phase 2 as built](#phase-2--uo-link-install-and-service)). The second was not busywork — it failed
on its first real run with 1053 and needed a sidecar fix before it passed, exactly as running the
systemd half for real had turned up a bug unit tests could not. The bundle the release shipped
against was `2026.08.07`: link `v1.2.1`, overlay `v0.2.0`, both declaring protocol 3.

---

## 6. Token handoff (the end of a successful run)

```
Runic Gateway is installed.

One manual step remains — connect the website to this sidecar:

  Base URL          http://<this-host>:8080
  WebSocket URL     ws://<this-host>:8080/ws
  Protocol version  3
  Auth token        4f9c...   (also in /etc/runicgateway/sidecar.toml)

Paste these into  Admin → Shard  on your Runic Gateway site:
  https://<your-site>/admin/shard

The token is write-only once saved — the site will never show it back to you.
```

Every value in that block except the host and the site URL comes from one
`uo-link-sidecar --print-config` call (§2.4): `web.auth_token`, `protocol`, and `web.bind` +
`web.ws_path` for the two URLs. Only the **host** is substituted — `web.bind` is frequently
`0.0.0.0`, which is not something to hand a website — so the installer composes the URLs from the
host it detects or prompts for, rather than echoing the bind address.

The installer prompts for the site URL only to build that link; it never contacts the website. A
future "installer registers itself with the website" flow (claim code + authenticated endpoint) is
explicitly **out of scope** — it is real backend work in a security-sensitive area and can be added
later without changing anything here.

The printed token is a secret in transit: `--print-config` output must go to the operator's
terminal and the config file, never into an installer log file or a support bundle.

---

## 7. Version tracking, the bundle, and release orchestration

Three components version independently, bound by a protocol contract:

- **sidecar** — `PROTOCOL_VERSION` in `link/sidecar/src/main.rs`, exposed on `/health` and as
  `X-UOLink-Version` on every response; a mismatch is rejected `409`.
- **website** — stores an expected protocol version in `uoLinkConfig` (admin-managed).
- **plugin overlay** — has no queryable version before ServUO boots. The overlay release
  `manifest.json` declares it, and `install.json` records what was deployed.

### 7.0 The overlay manifest

Shipped inside every `runicgateway-overlay-<ver>.tar.gz`, generated by that repo's release workflow:

```json
{
  "component": "servuo-plugins-overlay",
  "version":   "0.1.0",
  "commit":    "968b526…",
  "repo":      "RunicGateway/servuo-plugins",
  "protocol":  3,
  "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
  "patch_tier": {
    "features": [{
      "name": "vendor-sale",
      "summary": "vendor.sale events — player-vendor purchases with buyer, owner, item, price and commission",
      "lost":    "no vendor.sale events",
      "rebuild": "core",
      "patches": [
        { "name": "playervendor-sale-eventsink", "file": "patches/playervendor-sale-eventsink.patch", "target": "Server/EventSink.cs" },
        { "name": "playervendor-sale-gump",      "file": "patches/playervendor-sale-gump.patch",      "target": "Scripts/Gumps/PlayerVendorGumps.cs" }
      ],
      "companions": [
        { "file": "patches/BridgeVendorSale.cs", "install_to": "Scripts/Custom/Bridge/BridgeVendorSale.cs" }
      ]
    }]
  },
  "files":  { "overlay/Config/Bridge.cfg": "32718424…", "patches/…": "…" }
}
```

`version` and `commit` come from the release engine; `protocol` and the `servuo` block are read from
`servuo-plugins/overlay.toml`; `patch_tier` is folded in from `servuo-plugins/patches/tier.json`;
`files` is a SHA256 per shipped file.

Three of these carry weight beyond documentation:

- **`protocol` is a hand-maintained declaration, and has to be.** The plugin announces no version on
  the wire and none is queryable before ServUO boots, so nothing in CI can derive it — which makes
  this line the only thing §7.1's gate 1 has to compare the sidecar against. The duty is stated in
  `overlay.toml` and in that repo's README: **bump it in the same PR that changes the emitters**, the
  way `link` bumps `PROTOCOL_VERSION`.
- **`files` is what makes `doctor` able to tell "the operator edited a deployed file" from "the
  overlay moved on"** (§5, Phase 4). The installer copies these hashes into `install.json` at deploy
  time; a later mismatch against *both* the manifest and `install.json` means upstream changed, a
  mismatch against `install.json` alone means local edits.
- **`patch_tier` is everything a `.patch` cannot say about itself**, and is the reason the tier is
  data rather than code. Which patches form one all-or-nothing unit, which companion `.cs` may only
  be copied once that unit lands, whether the change needs a **core** solution rebuild or just the
  dynamic script build, and what the operator loses by declining are none of them derivable from a
  diff. Declaring them here means adding a patch regenerates release metadata rather than requiring
  an installer release — the rule §7.1 already applies to the bundle. The maintainer-facing source
  is `servuo-plugins/patches/tier.json`; the release workflow folds it in and removes the staged
  copy, so the tarball carries exactly one statement of the table, and gates that every `.patch` is
  described by exactly one feature, that every named patch and companion exists, and that each
  declared `target` is the file its diff actually edits. Installers older than this key ignore it;
  an installer newer than the overlay it is deploying falls back to a built-in description of the
  release that predates it (see [Phase 3 as built](#phase-3--patch-tier-opt-in)).

`min_version` and `patches_verified_against` are separate on purpose. The base overlay only *adds*
files and is expected to work broadly; the patch tier diffs stock ServUO files and is verified
against exactly one version (§2.2).

### 7.1 The bundle manifest

**The bundle is the compat matrix.** Rather than the installer hardcoding versions or blindly
resolving "latest", CI publishes a small manifest naming an exact, checked combination:

```json
{
  "schema": 1,
  "bundle": "2026.08.04",
  "generated": "2026-08-04T16:07:13Z",
  "protocol": 3,
  "link": {
    "repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3,
    "assets": {
      "linux-x86_64":   { "name": "uo-link-sidecar-linux-x86_64",       "url": "…", "sha256": "27d491ef…" },
      "linux-aarch64":  { "name": "uo-link-sidecar-linux-aarch64",      "url": "…", "sha256": "…"        },
      "windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" }
    }
  },
  "overlay": {
    "repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1",
    "commit": "3a52abb…", "protocol": 3,
    "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" },
    "asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" }
  }
}
```

Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally
sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one
hash could only ever have described one of them. Phase 5's `linux-aarch64` (§5.2) is the first
addition that map was shaped for, and it costs the bundle nothing but a key. `schema` versions this document's shape and is
independent of `protocol` and of either component's release version — all three move separately.

The installer fetches the current bundle at run time; `--bundle <tag>` pins an older one for a
reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of
JSON and leaves the installer binary untouched** — operators do not re-download the installer to
pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical.

Two gates run at compose time, both cheap and both worth it:

1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version.
   This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator.
   The two halves are read from different places because they *are* different: the overlay's from
   `manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's
   from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary).
2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`.
   The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that
   check — and the installer verifies every download against it. These artifacts are deliberately
   unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody
   verified would make the chain decorative.

#### Where bundles are published

Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the
installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea
credentials (§1):

```
current.json          → …/RunicGateway/installer/raw/branch/bundles/current.json
bundle-<tag>.json     → …/raw/branch/bundles/bundle-2026.08.04.json                (--bundle)
```

Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle
on the same day — a sidecar release in the morning and an overlay release in the afternoon is the
normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix.

**A branch, not `main`, and that correction cost a day.** This section originally said bundles were
committed to `main` and "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 a push from CI (`pre-receive hook declined`), 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. So the assumption that a working push path already existed was never tested by
anything.

Publishing to a branch of its own keeps every property the original choice was for — a reviewable
diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host
— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the
default branch, buys nothing this does not.

**Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases
are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless
of kind — interleaving bundle releases would make "latest" intermittently resolve to a release
carrying no installer binary.

**The release workflows tag and never write to a branch**, for the same reason and settled at the
same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it.
The version is still written into `Cargo.toml` before building — so a released binary self-reports
correctly — but is no longer committed back, and the next version is computed from the newest tag.
A first release must not depend on a write to a protected branch.

### 7.2 What triggers a bundle

| Trigger | Why |
|---|---|
| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step |
| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step |
| Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar |

`repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version,
whereas dispatching an existing `workflow_dispatch` workflow via the API works today.

**A failed dispatch is a warning, never a failed release.** By the time that step runs the component
release is published and correct; failing the job would misreport it. This also keeps the dispatch
from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer
repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch
costs latency, not correctness, which is the whole reason the cron exists.

### 7.3 Stale-overlay handling: dispatch, don't wait

Each component **self-releases on merge to its own `main`**, using the same conventional-commit
engine. Note that "updated since the last release" must mean *releasable* commits — the engine sets
`RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not**
cut an overlay release, and the bundle keeps using the existing one.

The compose job's copy of that rule additionally **excludes merge commits**, whose subject is
`Merge pull request '<the real subject>'`. Without that, every squash-free merge of a `feat:` branch
would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a
`fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly
declines to run.

So by the time the installer's CI looks, the release normally already exists. If it finds
`servuo-plugins` main ahead of its latest release *with* releasable commits, it:

1. fires that repo's release workflow via workflow-dispatch and **does not wait for it**,
2. composes this bundle from the assets that exist right now,
3. writes a loud warning into the job summary.

The new overlay release lands minutes later on its own and the nightly cron folds it into the next
bundle. This gets the automation without the flaky part: dispatching another repo's workflow is
fine — that workflow still runs its own gates — but *polling* it is not, because Gitea's dispatch
endpoint returns no run handle, so the job would have to guess which run is its own and hold a
runner idle meanwhile. The warning exists so a genuinely broken release workflow surfaces once
rather than being silently retriggered every night forever.

### 7.4 Open risk

**Settled as of the v3 cutover.** Protocol work landed on `edge` branches and the `edge → main`
cutover has now merged, so `main` speaks protocol 3 consistently across the repos. The rule it
motivated stands regardless and is not a temporary measure: **the installer hardcodes no protocol
version anywhere.** It reads what the artifacts declare, and §7.1's gate 1 is what stops a
mismatched pair from being published as a bundle — which is the mechanism that will matter at the
*next* protocol bump, not just this one. See `docs/link/v3.md`.

---

## 8. Open questions

1. **Does the installer manage ServUO stop/start?** Currently it refuses while ServUO runs and tells
   the operator to restart afterward. Offering to stop/start would be friendlier but means owning
   another shard's process lifecycle, and the shard's own start scripts vary.
2. **Co-location assumption** — the shard dials out to the sidecar on loopback `127.0.0.1:7788`, so
   sidecar and ServUO must share a host. Should the installer support installing only uo-link on a
   different host, or hard-assume co-location?
Resolved and moved into §1 / §2.2 / §5: uninstall scope, and minimum ServUO version.

**Resolved — Windows service mechanism** (was question 1). Registration is `sc create` with
`obj= "NT SERVICE\RunicGatewayLink"` (plain `sc create` would run as `LocalSystem`, which the Linux
half pointedly does not do), `sc failure … actions= restart/5000` as the counterpart of systemd's
`Restart=on-failure` / `RestartSec=5`, and the config pinned in `binPath` rather than in a
machine-wide environment variable. No WinSW/NSSM shim: that would be a third binary to keep current.

**Corrected 2026-08-07 — the sidecar needs its own service mode after all.** This section previously
recorded that `sc create` against the *plain console binary* worked and needed no change to `link`.
It does not, and the first Windows run proved it: `sc start` failed with **1053** and the event log
read *"a timeout was reached (30000 milliseconds) while waiting for the … service to connect"*,
with `SERVICE_EXIT_CODE : 0` — the process had started fine and simply never spoke to the SCM.

The premise was a false symmetry with systemd. systemd supervises *any* foreground process; the
Windows SCM supervises only a process that calls `StartServiceCtrlDispatcher` within ~30 seconds and
then reports its own state transitions. There is no third option where `sc.exe` adopts an arbitrary
console executable — it is a service-aware binary or a shim, and the shim was already rejected.

So `link` gains a Windows service entry point (the `windows-service` crate, behind
`[target.'cfg(windows)'.dependencies]`). The objection that this puts Windows plumbing inside a
platform-agnostic component is answered by keeping it *only* at the edges: `app::run` is the whole
sidecar and is shared, while `windows.rs` and `unix.rs` do nothing but start it and tell it when to
stop. Nothing platform-specific reaches the shared path, and Cargo neither resolves nor builds the
Windows crates for Linux.

Consequences worth knowing:

- **One binary, no `--service` flag.** The dispatcher is tried first; failing with
  `ERROR_FAILED_SERVICE_CONTROLLER_CONNECT` (1063) means "not started by the SCM" and falls through
  to a normal foreground run. `cargo run` and a hand-run diagnostic are unchanged.
- **A service has no stdout**, so in service mode the sidecar logs to a daily-rolled file beside its
  config instead of into the void.
- **`Running` is reported only once the shard port is bound and the store is open**, so a bad config
  fails the *start* rather than flapping Running → Stopped, and a failed run leaves a nonzero
  `SERVICE_EXIT_CODE` behind rather than the misleading `0` above.
- **A sidecar older than v1.2.0 can never start as a service on Windows**, however good its config.
  The installer says so by name when it sees 1053.

**Resolved — branch targeting for the new repo** (was question 4). The v3 cutover landed:
`servuo-plugins#6` merged, so that repo's `main` and `edge` agree at protocol 3. The release
workflow targets `main`, and the installer repo starts clean on `main`. §7.4's caution still applies
in principle — the installer hardcodes no protocol version, it reads what the artifacts declare —
but the specific `edge`/`main` disagreement that motivated it is gone.

---

## 9. Administrator experience

Before:

```
find plugins → copy files → edit ServUO → download bridge → start bridge
→ configure startup → find the token → troubleshoot paths
```

After:

```
download artifact → verify checksum → run installer → select ServUO directory
→ install components → paste 4 values into Admin → Shard → start ServUO normally
```
