docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe

PROTOCOL.md §11 specifies protocol 5: the walk rooted at the framework's own
config directory, the version a write must present back, the set-shaped write,
the watched reload and the automatic restore. PLAN.md §21 is the phase as built
— four org-lead decisions, the float trap and what avoiding it cost, and four
defects a browser found that 179 green tests did not.

PLAYER_WALK.md gains the configuration walk, because the acceptance line needs
the sidecar and the game on one host and the rig cannot reach one yet.
INTEGRATION.md gains the four things an operator should know before they edit a
plugin's settings from a web page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-22 08:55:39 -05:00
parent 132205f0f4
commit 82145d3b4a
4 changed files with 404 additions and 2 deletions

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **4** as of the permission mirror (§10) — declared in
The wire version is a single integer — **5** as of configuration from the site (§11) — declared in
**four** places that must agree:
| Where | Repo |
@@ -882,3 +882,208 @@ caller sent, so no request can arrive claiming to be a different command or aime
at a correlation id somebody else is waiting on.
---
## 11. Protocol 5 — configuration from the site
R18, and the first command on this bridge that writes to the game host's
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
owns; this replaces bytes in a file and then asks the framework to read them.
```
website sidecar plugin
│ │ │
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
│ │ │
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
│ │ │
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
│ whole file TEXT │ │ reload, WATCH
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
```
**The website composes the bytes and the plugin writes them.** That split is the
one design decision everything else here follows from, and §11.5 is why.
### 11.1 The roots come from the framework, and one of them is forbidden
The walk is rooted at `Interface.Oxide.ConfigDirectory``oxide/config` on
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
never composed from a literal, and that amendment was proven the best way it
could have been: this bridge's own config landed in **both** places, written by
the same source file.
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
definitions — and both frameworks' own permission stores (`oxide.users.data`,
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
settings editor that strayed there would be editing §10 underneath itself.
### 11.2 `config.list` — a description of the tree, never its contents
```json
{
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
"root": "/home/container/oxide/config",
"self": "RunicGateway",
"files": [
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
"plugin": "ZoneManager", "editable": true },
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
"plugin": "Kits", "editable": true },
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
"reason": "larger than this bridge will carry" }
],
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
"truncated": false,
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
}
```
Four things about that shape are load-bearing.
**No file is hashed here.** A version is produced by `config.read`, on the one
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
this walk reads directory entries and nothing else.
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
folder for a nested file and the filename otherwise, and a folder name is
convention rather than contract. Infer it silently and the failure is the
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
*it*, and the write is reported as a success while the plugin that was actually
edited never re-read anything.
**A file past a limit is listed and marked, never hidden.** An operator who
cannot find a file they know exists goes looking for a bug in the bridge; one who
can see why it was refused does not.
**`self` is the plugin naming itself**, so the website can lock the three keys in
*our* config that would cut this link (§11.6) without matching on a filename
somebody may rename.
### 11.3 `config.read` — one file, and the version a write must present back
```json
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
```
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
**not** a cryptographic digest: nothing here is a security claim — the website
never computes one, it only echoes back the one it was given — and
`System.Security.Cryptography` is one more thing that would have to be available
under two plugin compilers.
### 11.4 `config.write` — the set, the reload, and the undo
```json
{ "cmd": "config.write", "reqId": "r-9",
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
"reload": "ZoneManager" }
```
The plugin, in order:
1. resolves and guards every path (§11.6), checks every version, and checks that
every document parses — **before the first byte is written**. Same posture as
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
the last ten per file, and holds the original in memory for the rollback;
3. writes the set;
4. reloads the named plugin **through the framework**, not by composing a console
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
like a command that worked;
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
6. if it arrives, re-reads each file and reports the new versions. If it does
not, **restores every file, reloads again, and reports the failure with the
tail of the server's newest log file.**
```json
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
"ok": false, "reloaded": false, "rolledBack": true,
"reason": "'ZoneManager' did not reload within 4s",
"log": "…Error while compiling ZoneManager…",
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
```
**That rollback is the feature.** Without it this is a web form that takes a
required plugin off a production server one typo at a time — and four plugins are
required (R6/R17), so a broken `ZoneManager` config is also event participation
gone.
Three consequences worth naming:
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
give up, restore, wait again — and the caller holds a socket throughout. It
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
report arrives after the only thing waiting for it has gone. The sidecar
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
inequality rather than trusting it.
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
on load and save it back, so the file after a successful reload is regularly
not the file that was sent. The report says so; a website that assumed
otherwise would conflict with itself on the next save.
- **The bridge will not reload itself.** The reload would unload this plugin and
close the link carrying the answer, leaving a rollback with nothing watching
it — the one failure the mechanism exists to report would be the one it could
not. `reload-self` is refused, and our own settings apply on the next
deliberate reload instead.
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
frameworks deserialize a config into typed C# classes, so a naive
read-modify-write **silently rewrites every whole-numbered float as an integer,
on fields nobody touched** — and Newtonsoft may coerce that or may throw. A throw
at load is a plugin that does not come back.
So the website never parses, mutates and re-serialises. Its editor records the
**source span** of every value and splices new literals into them, which is why
`config.write` carries whole file text: the bytes on the wire are the bytes that
will be on disk, and the fields nobody edited are byte-identical. A number's new
value travels as the literal an admin typed, and never becomes a JavaScript
number anywhere in the path.
The plugin's contribution to that is deliberately nothing beyond checking that
the document parses. Giving this end an opinion about content would put the
decision in two places, and only one of them can be tested against a real
Newtonsoft.
### 11.6 Addressing by path is a new bug class, and it is guarded here
Protocol 4 addressed things by name. This addresses them by path, which is
exactly the change that introduces traversal — so the plugin refuses a path that
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
does not resolve **under the canonicalised config root**. Links are not followed:
any file or directory carrying a reparse point is skipped by the walk and refused
by the resolver, because resolving one is how a tree that looks bounded turns out
not to be.
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
code and a permission set. That is not laziness: only the process holding the
directory can decide whether a path resolves inside it, and a guard in the middle
would be a weaker second opinion in a place with no way to check it.
The website checks the *shape* before spending a round trip, and the bridge's own
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
because "which file is ours" is a question about the website's configuration, not
about the game's.
### 11.7 What the sidecar does NOT do
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
config this sidecar cached would be an edit an operator made over SSH that the
website then silently overwrote. All three routes fail when the game is down,
like `/status`, because "what is on that host's disk" has no stale answer worth
giving.
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
timeout on this bridge with a knowable answer, because the plugin writes a whole
set or restores a whole set and never half of either — so the body says to
re-read rather than to guess, and names the reload window that is probably still
running.
---