chore(facts): protocol 5, Module API 1.9.0, and the 2026.09.01 bundle #27

Merged
whitlocktech merged 4 commits from docs/platform-facts-protocol-5 into edge 2026-09-01 17:45:18 +00:00
6 changed files with 72 additions and 26 deletions

11
PLAN.md
View File

@@ -1622,6 +1622,17 @@ a mechanism rather than diligence:
replaceable by a file copy, and that promise survives exactly as long as nobody types the address replaceable by a file copy, and that promise survives exactly as long as nobody types the address
into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the into a paragraph. Same argument as `checkTokens.mjs` and colour literals — the check is the
mechanism, diligence is not. mechanism, diligence is not.
**All three network checks read a file through Gitea's `contents` endpoint, never `raw`**
`checkFacts.mjs`, `checkQuickstart.mjs`, `checkReference.mjs`. Phase 12b found the reason.
`raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea keeps
a copy for six hours: on cutover day this check read `website`'s `version.js` from a fortnight
earlier and failed the site for saying Module API 1.9.0 when `main` said 1.6.0 — except that
`main` said 1.9.0, and nothing anyone could edit here would have made it pass. `contents`
answers `private, must-revalidate` and is not cached, at the cost of a base64 decode. Same
argument as `checkLinks.mjs` fetching nothing: a check that goes red on someone else's
infrastructure is a check people learn to ignore, and one that goes red on a stale copy is
worse — it is indistinguishable from the failure it exists to report.
- **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a - **`scripts/checkLinks.mjs`** — every internal link resolves; every outbound link into a
`RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23), `RunicGateway` repo points at a branch path, not a commit permalink. **Built in phase 4** (D23),
and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from and it reads `dist/client` rather than `src/`: half the links these pages carry are assembled from

View File

@@ -60,8 +60,28 @@ async function api(pathname) {
return res; return res;
} }
const raw = async (repo, filePath, ref) => /**
(await api(`${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`)).text(); * A file's bytes, read through the `contents` endpoint rather than `raw`.
*
* `raw` answers with `Cache-Control: public, max-age=21600`, so the CDN in front of Gitea
* serves a copy for six hours and this check can read a blob most of a working day old.
* That is not theoretical: on the day of the engagement cutover it reported website's
* MODULE_API_VERSION as 1.6.0 -- the value from two weeks earlier -- and failed a site
* whose number was right. A check that goes red on stale data is a check people learn to
* ignore, which is the one failure mode this file exists to avoid.
*
* `contents` answers `private, must-revalidate`, which the CDN does not cache, so it is
* always the ref's current blob. The cost is a JSON parse and a base64 decode.
*/
async function raw(repo, filePath, ref) {
const meta = await json(`${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`);
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
}
const json = async (pathname) => (await api(pathname)).json(); const json = async (pathname) => (await api(pathname)).json();

View File

@@ -55,12 +55,18 @@ const checked = [];
const ok = (what) => checked.push(what); const ok = (what) => checked.push(what);
const fail = (what, detail) => failures.push({ what, detail }); const fail = (what, detail) => failures.push({ what, detail });
/** Same raw-file accessor checkFacts.mjs uses, and for the same reason. */ /** Same file accessor checkFacts.mjs uses, and for the same reason -- including the CDN one. */
async function raw(repo, filePath, ref) { async function raw(repo, filePath, ref) {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return res.text(); const meta = await res.json();
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
} }
/** /**

View File

@@ -53,12 +53,18 @@ const checked = [];
const ok = (what) => checked.push(what); const ok = (what) => checked.push(what);
const fail = (what, detail) => failures.push({ what, detail }); const fail = (what, detail) => failures.push({ what, detail });
/** Same raw-file accessor checkFacts.mjs and checkQuickstart.mjs use. */ /** Same file accessor checkFacts.mjs and checkQuickstart.mjs use, CDN caveat included. */
async function raw(repo, filePath, ref = 'main') { async function raw(repo, filePath, ref = 'main') {
const url = `${BASE}/api/v1/repos/${ORG}/${repo}/raw/${filePath}?ref=${encodeURIComponent(ref)}`; const url = `${BASE}/api/v1/repos/${ORG}/${repo}/contents/${filePath}?ref=${encodeURIComponent(ref)}`;
const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } }); const res = await fetch(url, { headers: { Authorization: `token ${TOKEN}` } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`); if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
return res.text(); const meta = await res.json();
if (meta.encoding !== 'base64' || typeof meta.content !== 'string') {
throw new Error(
`${repo}:${filePath}@${ref} did not come back as a base64 file (encoding ${meta.encoding}).`
);
}
return Buffer.from(meta.content, 'base64').toString('utf8');
} }
/** /**

View File

@@ -4,12 +4,13 @@ description: One number, declared in three repositories, that decides whether a
--- ---
import { Aside } from '@astrojs/starlight/components'; import { Aside } from '@astrojs/starlight/components';
import platform from '../../../../data/platform.json';
The loopback wire protocol between the game plugin and the sidecar is a **versioned The loopback wire protocol between the game plugin and the sidecar is a **versioned
compatibility contract**, not a build dependency. Nothing compiles the three sides together, compatibility contract**, not a build dependency. Nothing compiles the three sides together,
so the number is what stops a mismatch from being discovered as corrupted data. so the number is what stops a mismatch from being discovered as corrupted data.
The current protocol is **4**. The current protocol is **{platform.protocol}**.
## Three declaration sites ## Three declaration sites
@@ -17,8 +18,8 @@ The same number is written down in three places, and they must move together.
| Where | What declares it | | Where | What declares it |
|---|---| |---|---|
| `link/sidecar/src/main.rs` | `pub const PROTOCOL_VERSION: u32 = 4` — what the sidecar speaks | | `link/sidecar/src/main.rs` | `PROTOCOL_VERSION`, currently {platform.protocol} — what the sidecar speaks |
| `servuo-plugins/overlay.toml` | `protocol = 4` — what the plugin overlay speaks | | `servuo-plugins/overlay.toml` | `protocol`, currently {platform.protocol} — what the plugin overlay speaks |
| The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim | | The bundle manifest | Copied from `overlay.toml` by CI, so a released pair carries its own claim |
<Aside type="caution" title="Bump the overlay in the same PR as the emitters"> <Aside type="caution" title="Bump the overlay in the same PR as the emitters">
@@ -44,19 +45,19 @@ allowed to be chosen independently.
## What a bump obliges ## What a bump obliges
Changing a message shape means editing every side plus the specification. A protocol-4 Changing a message shape means editing every side plus the specification. The most recent
change touched: bump touched:
| Repository | What had to change | | Repository | What had to change |
|---|---| |---|---|
| `servuo-plugins` | The emitters, the config keys, and `overlay.toml` | | `servuo-plugins` | The emitters, the config keys, and `overlay.toml` |
| `link` | `PROTOCOL_VERSION`, a store migration, and the projections | | `link` | `PROTOCOL_VERSION`, and the projections |
| `module-uo` | The tables, the ingest, and the kind-to-feature map | | `module-uo` | The tables, the ingest, and the kind-to-feature map |
| `docs` | The protocol document and the integration guide | | `docs` | The protocol document and the integration guide |
Note `link`'s entry: **a protocol bump can require a store migration**, because the sidecar **A protocol bump can also require a store migration**, because the sidecar persists what it
persists what it forwards. That is not automatic, and version 4 was the first bump that forwards. That is not automatic version 4 needed one and version 5 did not, because
needed one. version 5 only widened frames the store already keeps whole.
## This is not the module API version ## This is not the module API version
@@ -92,8 +93,10 @@ What is worth inheriting is the **shape**:
## Canonical documents ## Canonical documents
[`link/v5.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v5.md)
is the current protocol's record, including its cross-repository obligations, and
[`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md) [`link/v4.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v4.md)
is the protocol-4 record, including its cross-repository obligations; the one before it;
[`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md) [`link/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PLAN.md)
§7 is the wire protocol, and §7 is the wire protocol, and
[`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md) [`link/INTEGRATION.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/INTEGRATION.md)

View File

@@ -12,23 +12,23 @@
"wrong protocol number in the first place." "wrong protocol number in the first place."
], ],
"verifiedOn": "2026-08-19", "verifiedOn": "2026-09-01",
"protocol": 4, "protocol": 5,
"moduleApi": "1.6.0", "moduleApi": "1.9.0",
"bundle": { "bundle": {
"tag": "2026.08.19", "tag": "2026.09.01",
"sidecar": "v2.0.0", "sidecar": "v2.1.0",
"overlay": "v1.0.0", "overlay": "v1.1.0",
"servuoMin": "57.4" "servuoMin": "57.4"
}, },
"releases": { "releases": {
"link": "v2.0.0", "link": "v2.1.0",
"installer": "v0.1.1", "installer": "v0.1.1",
"Module-uo": "v1.0.2", "Module-uo": "v1.1.0",
"Android-app": "v0.5.0" "Android-app": "v0.5.0"
}, },