Changelog
2026/11/02 — deploy: the server image builds again (M7)
The v/2.0.1 release failed in “Build and push the server image”: cargo build -p pacenotes-server exited 101 inside the container. The docs commit
had added tools/mdbook-repo-links to the Cargo workspace, and
deploy/Containerfile copies only the paths it names into the build. Cargo
reads every workspace member’s manifest before building any one package,
so the missing tools/ stopped it, though the server does not use it.
The Containerfile now copies tools/. Reproduced first, by loading the
workspace from exactly the paths the old Containerfile copied (the same
“failed to load manifest for workspace member” error), then a release build
of the server from the new file set.
Nothing before a release built from just those paths, so every CI check had
passed. The server job now does: it copies what the Containerfile’s COPY
lines name into an empty directory and loads the workspace there, which
fails on the v/2.0.1 Containerfile and passes on this one. The root
CLAUDE.md says a new workspace member needs its COPY line.
2026/11/02 — README: matches the application as it is (M7)
The README had drifted from the code. It said downloads went to a per-user
cache (they live in the game folder, as its own later section said), that
scene 3 offered two buttons (there are three: Restore/Uninstall was
missing), described wizard/ as having two routes and the wizard as seven
scenes, listed legacy/ as part of the repository (it is git-ignored, and
not one file of it is tracked), and pointed at the Python-drawn
architecture README. It also opened with an unclosed HTML paragraph, which
mdBook warned about.
Rewritten against the code: the three routes and what gates them, the download page’s expanded tree and partial boxes, retries and what happens when a category or the whole transfer comes back empty, the restart as administrator, the refusal while the game runs, restore points, and the documentation site. The layout table names what is really there.
2026/11/02 — docs: one mdBook on GitHub Pages, with rustdoc and D2 diagrams (M7)
The documentation was a dozen Markdown files spread over the repository,
plus nine architecture diagrams drawn as PNGs by Python and Pillow from
explicit coordinates. It is now one site, built by docs/build.sh and
published to GitHub Pages by .github/workflows/docs.yml from master:
- Every document, included rather than copied: each chapter under
docs/src/is one include line at the same relative path as the original, so a document keeps one source and its links work in the book as on GitHub. mdBook’s defaultindexstep is off, because it renames README chapters and kills every link to them. - The diagrams, redrawn in D2 and rendered to SVG at build time: the two ER models, four activity diagrams, and the SysML block, deployment and requirement diagrams, corrected to the application as it is (the 700 kB gate, the scene 5 readiness check, identical files no longer rewritten, the running-game check, redirects). The SVGs are never committed. The Python generator and its PNGs are gone; there is no Python left in the project.
- The API reference, rustdoc with private items for the shared crate, the server, and the installer’s library and Windows application. The installer’s two halves share a crate name, so each gets its own run; it builds only on Windows, so CI makes it in a Windows job and hands it over.
tools/mdbook-repo-links, a small Rust preprocessor in the workspace: a link to a repository file that is not a chapter (source, scripts, the licence) points at the file on GitHub instead of dying in the book.
One thing had to change in the installer for this: rustdoc prints the
default server address build.rs reads from installer/.env, so a docs
build on a machine with a real .env would have published that address.
build.rs now reads the file PACENOTES_ENV_FILE names when it is set,
and the docs build always points it at .env.example.
deploy/README.md no longer pipes through python3 -m json.tool, and the
unused authors key left [workspace.package], where Cargo warned about it
on every build.
Pinned to the latest releases, D2 v0.9.0 (now at d2lang/d2) and mdBook
v0.5.4. Rendered with D2 and inspected, the first drafts needed work:
- Light only. With a dark variant, a system in dark mode switched the text to light colours while the boxes kept their light fills, unreadable. An image in a page follows the system’s scheme, not the book’s theme.
- Each source names its own layout in its
d2-configvars: dagre for the activity flows, which ELK tangled into back-edges; ELK for the tables and containers.build.shpasses no flags. - Width. D2 does not wrap labels, so long sentences made the ER diagrams 5 900 px wide. Labels are broken by hand, column types are short (the chapter’s tables explain them), and containers stack; nothing is wider than 2 300 px now, most under 1 600.
- SY-3 is a grid, one row per requirement — test case, requirement, block — so no line crosses another, and SY-1’s composition edges carry their diamonds.
2026/11/02 — deploy: the forced command’s path is generated, not typed
The first real deploy failed with exit code 127, fish: Unknown command: /home/<user>/.local/bin/pacenotes-update. Not fish and not a hang: sshd runs
an authorized_keys command= through the user’s login shell, and that path
did not exist — the server user’s home is /srv, and the README’s template
said /home/<user>. The README now generates the line from $HOME (under
bash -c, since the login shell may be fish) and says what a wrong path
looks like. Also fixed: a doubled ~/.local/.local/bin in the install step
and a stale ~/bin in the Quadlet’s header.
2026/11/01 — ci: actions on Node 24, and where the default server lives
GitHub now forces Node-20 actions onto Node 24 with a deprecation warning.
The runtime is chosen by each action’s own runs.using, not by the runner
image, so the fix is newer action versions, checked one by one against
their action.yml: checkout v5, upload-artifact v6, setup-buildx v4,
login v4, build-push v7 and action-gh-release v3 all declare node24, and
every input these workflows pass still exists. rust-cache v2 already runs
on Node 24; rust-toolchain is a composite action with no Node at all.
The release job failed with PACENOTES_DEFAULT_SERVER unset although it
was set — in the production environment, whose variables only reach jobs
that name it. The installer job deliberately does not: naming it would
expose the deploy key to a job running third-party actions and crates’
build scripts, and put installer releases behind any reviewer rule meant
for deploys. The variable belongs at repository level; the error message
and deploy/README.md now say so, and why.
2026/11/01 — ci: the workflow runs again, and checks itself
ci.yml had been invalid since 3f516e0: the installer job’s name,
installer (windows: lint, …), held a colon followed by a space, which YAML
reads as a nested key, so GitHub refused the whole file and none of CI ran.
The name is quoted now.
Also:
- A
workflowsjob runs actionlint (pinned to v1.7.7, with the runner’s ShellCheck) over every workflow file, so a file GitHub cannot parse — or a broken expression,needs:target or shell line — fails a run instead of silently never starting one. timeout-minuteson every job (5 / 20 / 45) instead of GitHub’s 6 hours, which a hungTest-HttpClient.ps1could otherwise hold a runner for.- The Windows job caches the repository root’s
target/as well as the installer’s:Test-HttpClient.ps1buildspacenotes-serverthere, and it was rebuilt from scratch every run. That build is now--locked, like the server job’s. permissions: contents: readfor the workflow’s token, and aconcurrencygroup that cancels a branch’s or pull request’s older run when a newer push arrives.cd-release-on-tag.yml: the server job’s outputs are written in one grouped redirect (ShellCheck SC2129, which the new lint would flag).
None of this could be run here: actionlint is fetched from GitHub at run time, and downloading it locally was not permitted. The next push is the test.
2026/11/01 — release: the server image is built in CI and deployed on a tag (M7)
Pushing a v/X.Y.Z tag now runs three jobs in cd-release-on-tag.yml: the
installer to GitHub Releases as before, the server image to
ghcr.io/<owner>/pacenotes-server:X.Y.Z (and :sha-<12>), and, once the
image exists, a deploy to the VPS. The repository is public, so its workflow
artifacts are too; the server therefore ships ONLY as a GHCR package, which
is private, and never as a release asset or artifact.
The host stops building. The Quadlet runs the local tag
localhost/pacenotes-server:current with Pull=never, so a restart is just
a restart. The new deploy/pacenotes-update is the only thing that moves
that tag: CI SSHes in with a key that authorized_keys locks to the script,
which takes nothing from the caller but an X.Y.Z version, pulls that image
with the host’s own read-only token, keeps the running one as :previous,
restarts and waits for the healthcheck — and puts :previous back and fails
the job if the new container never turns healthy. podman auto-update was
not used: it cannot be scoped to one unit, and nine other containers on the
host carry AutoUpdate, so every release would have updated them too.
Tested against isolated storage with stand-in images and a throwaway unit:
a healthy update, a redeploy of the same version (a no-op), and an update
whose healthcheck fails (rolled back, exit 1). The first run found a real
bug: podman untag <name> with one argument strips EVERY name from the
image, so a successful deploy dropped :current and the following prune
could delete the running image; it now names the image and the tag.
Publishing content is now manifest gen alone; it never deploys code.
deploy/README.md covers the GHCR token, the deploy key, the production
environment’s five secrets and the move from the build-on-start unit. The
GitHub run, the GHCR package’s visibility and the SSH hop cannot be tested
from here: the first real tag is the end-to-end test.
2026/11/01 — installer: a larger window, and the Download tree opens expanded (I9)
The window was 615x512 px, sized for an 800x600 screen. Every page is now 470x300 DLU instead of 330x215 — about 860x680 px at 100%, the most that fits a 1024x768 screen above its taskbar. Each page keeps its arrangement; the extra width widens every control, and the extra height goes to what holds lists: the Download tree, the logs, the message of the day, the configuration lists and the restore list and preview.
The Download tree opens with every row expanded, so each package is in view without a click. It had opened collapsed so every category needing a pick stayed on screen; a long co-driver list now pushes the categories below it a scroll away, which the larger window offsets.
Test-UiLayout.ps1 checks the window is larger than 800x600 yet fits
1024x768 above a taskbar, measured at 100% from the window’s own DPI;
Test-WizardDownload.ps1 checks every row is shown without a click.
2026/11/01 — installer: a partly ticked category shows a partial box (I9)
On scene 4, a category with only some of its items ticked showed a blank
space where its checkbox belonged. The model and the control both knew the
state (image 3), but the control builds its checkbox images when
TVS_CHECKBOXES arrives, and from the template that was before the partial
style was asked for: there was no third image to draw. The template no
longer sets TVS_CHECKBOXES; the page adds it after the partial style.
With a real partial image the control cycles unchecked → checked → partial, so a click on a partly ticked category now ticks all of it, rather than clearing it as the cycle’s next step would.
Test-WizardDownload.ps1 ticks one voice and measures the category’s
checkbox as drawn: the old build drew 1 dark pixel there against 59 for an
empty box. The tree helpers now find a row by walking the visible rows, so
they work on an expanded tree too.
2026/11/01 — installer: installs four to fifteen times faster (I9)
Measured on a voice-pack-shaped package, 3 000 files and 114 MB, on one machine:
| before | after | |
|---|---|---|
| fresh install | 11.8 s | 3.0 s |
| install over a manual install of the same files | 20.3 s | 1.35 s |
| uninstalling that again | 3.1 s or worse | 0.63 s |
- The zip’s directory is read once per package, not once per file
(
zip::Archive). Finding each file by walking the whole directory made an install of n files n² entry parses: 8.5 s of the 11.8. - A file that already holds the incoming bytes is not written again, nor decompressed a second time: its hash is taken during the comparison that found it identical. On a first run over a manual install that is every file.
- Uninstalling no longer re-reads the payload for every file. An identical original of an installed entry needs nothing at all — the file was just hashed against what was written, which is the original — and a write-ahead entry reads the payload once.
- The CRC check uses a full byte table (1 KiB) instead of a nibble table: 0.42 s → 0.24 s.
- The log pane appends a line instead of setting its whole text again for each one. On the control itself, 1 500 lines took 19.8 s that way and 3.5 s appended. It mattered most where there are many lines — a first run backing up the files of a manual install — and it ran on the UI thread, so the finished run waited behind it.
What remains, and why it stays: writing through a temp file and a rename
(1.8 s against 1.1 s for plain writes) keeps a half-written file from ever
existing under the real name, and the SHA-256 of each file (0.43 s) is what
the ledger records. New tests: an identical file keeps its modification
time, its original survives an emptied cache, Archive reads what
read_entry does, and scene 6’s pane holds the run log in order.
Size: 627.5 kB, +5 kB.
2026/11/01 — installer: the way forward is light green (I9)
The button that carries the normal flow forward now has a light green background: Next in each of its labels, Finish, and scene 3’s Download/Update. A disabled one stays the theme’s grey, and Back, Cancel and the secondary routes keep their look.
No library changes. A themed push button ignores the colours its parent offers, so these are painted whole from the button’s custom-draw notification: fill, border, label in the button’s own font, and the hot, pressed and focused states. Next and Finish belong to the property sheet, so the sheet is subclassed once it exists; Download/Update is painted by scene 3’s page.
Test-UiLayout.ps1 samples the rendered colour of the primary button,
Cancel, both kinds of route button and the disabled Next (70/70); on the
previous build both “light green” checks fail on the theme’s grey. The
drawing pulls in gdi32.dll, which Windows ships, so Test-BinarySize.ps1
allows it — and its import-gate message, whose -f formatted only half
the string, now names the DLL it complains about.
Size: 622.5 kB, +1.5 kB.
2026/11/01 — server: no colour codes in a log that is not a terminal (S12)
The server coloured its log whether or not anyone could see the colour, so
a log written to a file or the journal kept the ANSI escape codes, and a
search for agent= missed every access line: the field name and its =
sat on either side of an escape. Test-HttpClient.ps1 failed that way
when run from a terminal that set TERM. Colour is now on only when
stdout is a terminal.
2026/11/01 — installer: the message of the day keeps its line breaks (I9)
Scene 2 showed the message of the day as one run-on line. A Win32 edit
control breaks lines only at CRLF, and a .txt written on the server, or
in any editor set to LF, ends its lines in LF alone. The text is now
re-joined with CRLF before it is shown, whichever endings the file used.
Test-WizardDownload.ps1 serves a three-line, LF-only message and reads
back the line count the control itself reports (140/140 in both
languages; the unfixed build showed 2 lines, the one long line wrapped).
2026/11/01 — installer: the size gate moves to 700 kB
The scene 5 readiness check landed the release build on exactly 620 kB
(634,880 bytes), with nothing left for the next change. The maintainer
raised the gate to 700 kB. Unlike the two raises before it, this one was
not measured against the levers in installer/CLAUDE.md: the icon and
serde_json are still unspent, and the forbidden crates stay forbidden.
2026/11/01 — installer: scene 5 opens only with something to pick (I9)
Download/Update → Back → Configure/Install → Back → Download/Update, on a folder with nothing downloaded, let Download through with nothing ticked into an empty configuration screen; doing it again locked scene 4. The Configure route rebuilds the shared tree from the cache alone, and scene 4, having loaded its catalogue once, never loaded it again: the page showed the catalogue while its gate read the cache-only model, and every tick pointed at a row the model no longer had. Taking that route now marks the catalogue as not loaded, so scene 4’s next visit fetches and rebuilds both.
The rule the report asked for holds on its own: the configuration screen opens only when every one of its questions has a ticked package whose payload is in the cache. Configure/Install checks it on scene 3 and stays there with a warning naming what is missing; Download checks it after 4b, and when every voice failed, say, returns to scene 4 with those unticked. This reverses an earlier choice: a category the server publishes nothing under used to leave an empty list and install without it, and now holds the wizard short of scene 5.
Returning to scene 4 exposed a second bug: 4b decided what to fetch from tree states dating from scene 4’s first visit, so a second transfer fetched everything again. It now skips what the cache already holds at the catalogue’s sha256.
Test-WizardDownload.ps1 repeats the reported sequence twice on a fresh
folder before its download gate, and an eighth pass fails every voice
(138/138).
Size: 620 kB, +2 kB — exactly at the gate.
2026/10/31 — installer: it will not install while the game is running (P12)
A running game holds its files open, so an install into its folder failed
partway with sharing violations. The engine’s pre-flight has refused when
told the game was running since I7 — but nothing ever told it: the wizard
passed false.
The installer now looks for the game’s executable among running processes, and compares each one’s FULL PATH with this folder. A copy of the game running from another install does not lock this folder, so it does not block; one whose path cannot be read (running elevated, say) counts as running, since refusing is the safe answer. The check guards installs and restores alike, before anything is written.
Test-WizardDownload.ps1 gained a seventh pass, with Windows’ own
ping.exe standing in for the game under its marker’s name: running from
another folder, the install goes ahead; running from this one, it stops
with “the game is running”, the ledger untouched (118/118).
Size: 618 kB, +0.5 kB. std::fs::canonicalize would have cost 3.5 kB and
broken the gate; the two path variations that matter are normalised by hand.
2026/10/31 — installer: no Visual C++ redistributable needed; the gate moves to 620 kB (I10)
The exe imported VCRUNTIME140.dll, the Visual C++ redistributable, which
Windows does not ship. On a PC without it, Windows refused to start the
installer with an unlocalized “VCRUNTIME140.dll was not found” — before a
single line of ours could explain anything. The README told users to go
and install it.
Measured on the same commit:
| Build | Size | Needs the redistributable |
|---|---|---|
| dynamic CRT (as it was) | 595.5 kB | yes |
| fully static CRT | 692.5 kB | no |
| hybrid CRT: VC runtime linked in, Universal CRT from Windows | 617.5 kB | no |
The installer now builds with the hybrid CRT. The Universal CRT has shipped
with Windows since Windows 10, so the exe needs nothing installed. The icon
lever would have fitted that under the old 600 kB gate; the maintainer
chose to keep the icon and raise the gate to 620 kB, its second raise,
argued in installer/CLAUDE.md “The size budget”.
+crt-staticlives ininstaller/.cargo/config.toml, andbuild/runtime_link.rsadds the two link arguments that keep the Universal CRT dynamic. ARUSTFLAGSvariable silently REPLACES the config file’s flags, sobuild.rsrefuses to build without+crt-static, and CI andRun-AllTests.ps1now deny warnings through clippy rather thanRUSTFLAGS(CI keepsRUSTFLAGSfor the server job only).Test-BinarySize.ps1reads the exe’s import table and fails if it needs any DLL Windows does not ship —VCRUNTIME140.dllcoming back, or any new one. It parses the PE file itself, so it needs no Visual Studio tools.- The README no longer sends users to install the redistributable.
2026/10/31 — installer: Enter and Tab on scene 3 (I9)
Measured with real keystrokes on every scene that runs without a server. Scenes 1 and 2 were already right: focus starts on the one input, Enter presses the primary button, Esc asks before closing. Scene 3 was not:
- Enter did nothing. The page draws Download/Update as its default, but in a property sheet Enter goes to the sheet’s own Next, which scene 3 disables, since its routes are buttons in the page. The folder field now claims Enter and takes Download/Update, with the same checks as a click — no game found, or a folder this account cannot write, say so.
- Tab reached the main route last, after both secondary ones, because template order is Tab order. Download/Update now comes straight after the folder and game; nothing moved on screen.
Esc needed nothing: it asks first wherever Cancel is offered, and the dialog manager ignores it on scenes 6 and 7, where Cancel is not.
Test-UiLayout.ps1 now presses keys through the dialog manager (Send-Key,
Get-FocusedControlId in Win32Ui.psm1), posted into the wizard’s queue
rather than sent as real input: real keys go to whatever window is in front,
and a background test could not reliably put the wizard there. It checks
where focus starts on scenes 1–3, scene 3’s Tab order and its Enter, in both
languages (65/65). Size: 595.5 kB.
2026/10/31 — decided: the last game folder is not remembered (I9)
Remembering the last folder per game would need storage in the user
profile, outside the game folder — the exception already declined for the
access key. Scene 3 starts empty each run, except after a restart as
administrator, which carries the folder over. The item leaves
installer/TASKS.md; the decision is in installer/requirements.md. No code
changed.
2026/10/31 — installer: the cache sweeps what nothing needs (I9)
I9 asked to sweep “orphaned” payloads — the ones the cache index no longer names. Built as written it would have broken restore: the index keeps one version per package, so after an update the previous version’s payload is unnamed, and that is exactly the payload a rollback needs.
Since the cache moved into the game folder, everything that can need a payload sits in the same folder, so the sweep can know what is in use:
- When a route is taken on scene 3, the cache removes leftover
.partfiles and every payload named by none of the index, the ledger and the restore points. The run log says what went and how much it freed. - If the ledger or any restore point cannot be read — written by a newer build, or damaged — no payload is deleted: what it needs is unknown.
- A
--cacheoverride is never swept; it may serve other folders. - No “remove from cache” control: what nothing needs goes by itself, and what something needs must not.
Tests: two unit tests for the sweep; three scenarios on a real game folder
(the payload a rollback needs survives and the rollback still works, a
version nothing ever installed goes, and an unreadable ledger stops the
sweep); and the flow test checks a leftover .part file is removed by a
real run and logged (115/115).
Size: the first version cost 11.5 kB and broke the gate (603 kB) — two
BTreeSets and a second copy of the restore-point reader. Rewritten over
plain slices and the existing restore::points, it costs 3.5 kB: 595 kB,
5 kB left under the gate.
2026/10/31 — installer: no unwrap, expect or panic outside tests, enforced (I9)
I9 asked to replace the unwraps left in UI paths. A sweep found none left:
every unwrap, expect and panic! in the installer’s own code was in a
test module, in --selftest-panic (whose whole job is to panic), or in the
build script, which is meant to fail the build with a message.
So the item became a rule that stays true: lib.rs and main.rs deny
clippy::unwrap_used, clippy::expect_used and clippy::panic whenever
they are not compiled for tests, and CI’s clippy runs with -D warnings.
The deliberate panic carries an allow saying why. The root and installer
CLAUDE.md drop “unwrap tolerated while scaffolding” for the rule as it
now is. Behaviour and size are unchanged.
2026/10/31 — decided: the access key is never remembered (I8, §7)
Two open items planned to remember the access key and to encrypt it with DPAPI. The maintainer decided against both: the key is typed on scene 2 each run and never stored. Scene 2 runs before a game folder is chosen, so a remembered key could only live in the user profile — the one exception the “nothing outside the game folder” rule would have needed — and a key left behind on a shared or borrowed PC is worse than retyping it. The only time the key travels is a restart as administrator (P11).
The items are gone from installer/TASKS.md; the decision is recorded in
installer/requirements.md under “Things that are deliberately NOT done”
and in installer/DESIGN.md, scene 2. No code changed.
2026/10/31 — installer: a failed download is retried, and no longer ends the run (§7)
One failed package ended the whole run at scene 7, whatever the reason and however many others had arrived — a server that blinked once cost the user everything they had ticked.
- A failure a retry could fix is retried: no response at all, 408, 429, 5xx, or a payload damaged in transit (its sha256 did not match). Three attempts, waiting 1 s and then 3 s; Cancel works during the wait, and each retry is a line in the run log. A 401 or 404 fails at once, and so does a local disk error, which is not the network’s fault.
- A package that still fails is left out and the rest carry on: scene 5 does not offer it, and scene 7 names it with the log open. A failed UPDATE keeps the installed version — leaving it out would make the plan uninstall it, which a failed download must never do.
- When nothing at all could be downloaded the run still ends at scene 7.
- Scene 4b and
--selftest-downloadshare one retrying path, which now reportsRETRIES:.
Test-HttpClient.ps1 drives the selftest through a stand-in in front of the
real server: a 503 once and a damaged payload once each arrive after exactly
one retry, and a 404 fails after one request (60/60). Test-WizardDownload.ps1
gained a sixth pass where one package fails once and another for good: the
run reaches scene 5 without the lost voice, installs the rest, and scene 7
names it (112/112).
Size: 591.5 kB, up 3 kB. 8.5 kB remain under the gate.
2026/10/31 — installer: a folder it cannot write is caught on scene 3 (P11)
A game under C:\Program Files cannot be written by a standard account,
and the installer asks for no elevation (asInvoker). Since the download
cache moved into the game folder, such a run got through scenes 3, 4 and
the picks, then failed at scene 4b’s downloads.
Now scene 3 probes the folder when a route button is pressed — the moment the user commits to it. The probe removes whatever it created, so a folder the user abandons keeps no trace. When it cannot write:
- it names the folder, says that is usual under Program Files, and offers to restart as administrator. No, or cancelling the UAC prompt, leaves the user on scene 3;
- the elevated instance opens straight on scene 3 with the language, the folder and the access key carried over. The key is on that process’s command line, readable by other programs running as this user: the maintainer’s call, for a restart that asks nothing twice;
- it waits for the instance that launched it to close. That needed a fix in the single-instance check, which kept the handle it got for a name already taken — and so kept the mutex alive after its owner had gone;
- already elevated and still unable to write, it explains and offers nothing, since asking again would loop.
Tested where it happens: Test-UiLayout.ps1 makes a folder this account
cannot write (a deny rule, no admin needed), checks scene 3 stops with the
.rc‘s own message and leaves the folder untouched, then launches with the
restart arguments while the first instance is still open and checks it
resumes on scene 3 in that language, with the folder and its game (60/60 in
each language). The command line round-trips through Windows’ own
CommandLineToArgvW in unit tests, folders with spaces and a trailing
backslash included. The UAC prompt itself is not automated.
Size: 588.5 kB, up 6 kB — the most of today’s changes, for the token query, the UAC launch and the restart’s command line. 11.5 kB remain under the gate.
2026/10/31 — installer: a cap on what http::get holds in memory (D12)
http::get — the manifest, the MOTD, /health, /whoami — read the
whole body into memory with no limit. The documents are small, but that is
the server’s promise rather than the installer’s, and a broken or hostile
server sending without end would have eaten the machine.
It now refuses a body over 8 MiB, far beyond any real manifest (kilobytes;
8 MiB would hold some 20,000 packages), and says so: “the response is
larger than the 8.0 MB accepted from …”, not a bare abort. It COUNTS as the
body arrives rather than trusting Content-Length, which a chunked reply
does not send. Packages are unaffected: they stream to a file through
get_streaming.
Test-HttpClient.ps1 checks both sides of the line with a loopback
listener — the real server never sends such a body: exactly 8 MiB is
accepted and read in full, an endless chunked one is refused naming the
cap (54/54).
2026/10/31 — installer: a failed /health no longer hides the catalogue (D12)
One failed GET /health on scene 2 made the whole run offline: scene 4
built its tree from the cache without asking for the catalogue, although
/packages would have answered — a load balancer hiccup, a /health
slower than the rest. It did the same when the user simply reached scene 4
before /health had answered at all.
Scene 4 now always asks for the catalogue; /health decides only scene 2’s
status line. When both fail the status still says the server could not be
reached, when only the catalogue fails it says the list could not be read,
and a catalogue that answers after /health did not is written to the run
log, since that mismatch is what support would want to see.
Test-WizardDownload.ps1 gained a fifth pass that builds exactly that
situation: a stand-in in front of the real server fails /health and relays
everything else. Scene 2 reports Offline, scene 4 shows the whole catalogue
and says it is ready (103/103).
2026/10/31 — installer: the run log says when, which build and where (§7)
Support asks the same three things before anything else: when did this happen, which build was it, and which server was it talking to. The run log answered none of them.
- Each log FILE now opens with a header —
Pacenotes Installer <version>(the release tag’s), the UTC start time, the server URL and the Windows build — then a blank line. - Every line in the file carries its time, in UTC and in the shape the
server’s log writes (
2026-10-31T12:11:31Z …), so the two can be lined up. A line said before the game folder was known keeps the time it was said when it is replayed into the file; the crash hook’s line is timed too. - The Windows build comes from
RtlGetVersion.GetVersionExWwould have said Windows 8: the manifest names no newer Windows, so it is told the compatibility answer. - The pane on scenes 6 and 7 is unchanged: no header, no times.
- Dates are formatted in one place,
utc_time.rs, which the restore point names and the key-expiry notice now use as well.
Test-WizardDownload.ps1 checks the header and the times on the real file
the wizard wrote (98/98). 582 kB against the gate.
2026/10/31 — installer: the release tag is the version
A release used to start by typing the same version into five places —
installer/Cargo.toml and four VERSIONINFO fields in res/installer.rc —
which the workflows then read to CREATE a tag. It now runs the other way:
pushing v/X.Y.Z is the release, and that tag is the only place a
version is written.
cd-release-on-tag.ymlbuilds on a pushedv/*tag and passes it to the build asPACENOTES_RELEASE_TAG.build/release_version.rsturns it into the user agent, the conflict report’s version and all four VERSIONINFO fields (through a header the.rcincludes), and FAILS THE BUILD on any other shape — a mistyped tag cannot become a release. The workflow then checks the built.execarries the tag’s version before publishing.- Without a tag a build is
0.0.0-dev, so a developer’s build is obvious in the server’s access log and in the file properties. - The release on merge to
masterand the manual release are gone: both readCargo.tomland created the tag from it. Merging publishes nothing. Cargo.tomlsaysversion = "0.0.0", with a comment that the tag decides.Test-VersionSync.ps1, which checked that the five hand-typed copies agreed, is replaced byTest-ReleaseVersion.ps1, which builds from real tags and reads the version back out of the binary — file properties and user agent — and checks that nine malformed tags (v1.2.3,v/1.2,v/1.2.3-rc1,v/65536.0.0, an empty one, …) each fail the build.
This supersedes the S1 entry below in one respect: the user agent’s version
now comes from the tag, not from Cargo.toml.
2026/10/31 — installer: the user agent names the build (S1)
The installer announced itself as Pacenotes-Installer/0.1 whatever its
version, so the server’s access log could not tell one build from another.
It is now Pacenotes-Installer/<version>, taken from Cargo.toml at
compile time, so it moves with the version bump that tags a release.
Test-HttpClient.ps1 checks it where it matters: in the real server’s
access log, against the version in Cargo.toml.
That closes the installer’s share of S1. The rest of the finding was
settled elsewhere: the manifest version and minimum installer were
superseded by the per-game schema hash (S11), which already tells an
out-of-date installer to update; the server has logged the agent since D11;
and generated_at / generation wait for the server’s /ready (S6).
The conflict report already carried the same version, so the two agree.
2026/10/30 — installer: a bearer token no longer leaks across a redirect
The design review suspected it (C1, “probably with the Authorization
header”). It is now measured, and it was true: left to itself WinHTTP
follows a 3xx and re-sends Authorization to the new origin, while
http.rs never sees the hop at all. Two local listeners on two ports, the
first answering 302 to the second: the second received Bearer <token> in
full, and the client reported a clean RESULT: OK with the second’s body.
Nothing logged it, because nothing knew.
That is a real leak, not a theoretical one. A 302 from a misconfigured CDN, a hijacked DNS record, or an operator moving downloads without thinking about the header would hand over the user’s access key to a host they never agreed to talk to.
So the decision is taken away from WinHTTP. send now sets
WINHTTP_OPTION_REDIRECT_POLICY_NEVER and follows redirects itself:
- at most three hops, so a redirect loop ends in milliseconds rather than on the timeout;
- no https→http downgrade — refused, not followed;
- the token attached only while scheme, host AND port are unchanged, and dropped the moment any of them is not;
- the 3xx body is never fed to the sink: it belongs to a response the caller is not getting, and would otherwise land inside a package file;
- a request with a body refuses to redirect at all, rather than silently choosing between re-sending the report and turning it into a GET.
A redirect to the same origin still works, so a server that rewrites paths does not break auth, and a redirect to a CDN for public content still works — what cannot happen is the key going with it.
tests/Test-Redirects.ps1 pins it, driving the real WinHTTP path through
--selftest-http: cross-origin drops the token, same-origin keeps it, and
a loop gives up by itself. It needs no desktop and no server, so CI runs
it. The https→http refusal is deliberately NOT covered — an
HttpListener on loopback cannot easily serve real TLS and http.rs
lives in the bin, out of reach of tests/ — and that gap is written down
in installer/requirements.md rather than left to be assumed.
2026/10/27 — installer: nothing is written outside the game folder
The download cache moves from %LOCALAPPDATA%\PacenotesInstaller\cache to
<game dir>\.pacenotes\cache, beside the ledger, the backups, the restore
points and the logs. That was the last thing this installer wrote anywhere
but the folder it was pointed at.
- Deleting the game folder now removes every trace of the tool, and a folder copied to another drive or PC arrives complete — with its downloads and its restore points, which is what makes the way back still work there.
- The cost, accepted deliberately: two game folders no longer share one cache, so a package wanted in both is fetched twice. Disk is cheaper than state nobody remembers to clean up — and the second copy is what keeps the first folder’s restore points working after the second is deleted.
cache::default_rootis gone;cache::dir_for(game_dir)replaces it, and the wizard opens the cache when scene 3 settles a folder, exactly as the run log is attached.--cache <dir>stays as a development override.- An audit of every write the installer makes found nothing else outside
the game folder. What remains elsewhere is deliberate and is not install
state: the
--selftest-*developer flags use a throwaway temp directory, and a panic that happens before any folder is chosen writes its report beside the exe, because at that moment there is no game folder to put it in. - The UI suite no longer passes
--cache, so it exercises the real default, and it asserts both halves: the downloads land in the game folder, and nothing under%LOCALAPPDATA%is touched during a run.
Older versions left downloads in %LOCALAPPDATA%\PacenotesInstaller.
Nothing reads that folder any more; it can be deleted, and the README says
so.
2026/10/27 — installer: the restore scene
The last functional gap. Every install has written a restore point since I7 — the ledger as it was, plus the files that run replaced — and nothing could read one back, so users were accumulating a safety net they could not use. Scene 3’s Restore/Uninstall button now opens a scene that lists those points newest first, previews what going back to one would do, and does it.
- The list is a date plus what the run after it did (
+4 -0 ~0), derived by diffing consecutive snapshots: nothing records what a run was about, and a column of identical dates is not something anyone can choose from. Signs rather than words, so the line needs no Polish plural forms. - The last entry, “Remove everything this installer added”, is a restore to an empty state — the same code, not a second path, and the other half of the button’s name.
- The preview leads with removals, because that is the part to read before agreeing. The PLAN it renders is the value handed to the worker, so what runs cannot differ from what was approved.
- A restore takes a restore point of its own: a user who restores to the wrong state must be able to come back from that too.
- Afterwards the game’s configuration step runs again with the picks the restored state implies, or the files would come back without the language and voice the user had chosen.
Three things that cost more code than the happy path:
- It cannot reuse
engine::plan. That asks the cache INDEX which version it holds, and the index holds one per key: the newest. Rolling back an update means naming the older payload, which is still on disk because payloads are sha-keyed.install_packagenow takes anInstallingdescriptor instead of looking the package up. - Never make it worse. A package whose payload is gone from the cache is not removed either — the wrong version beats an empty folder — and the preview names it before the user commits. Everything else is still restored.
- Restore the provenance, not just the bytes. The snapshot records what
was at each path before this installer first wrote there. A fresh
install of the old version records
prior: absentfor a file it just deleted and rewrote, so a later uninstall would DELETE a file the user always had. The ledger written afterwards takes the snapshot’s entries, and the end-to-end test restores, then uninstalls, and checks the user’s own file comes back.
Found while testing, and fixed: two runs in the same second shared a
restore point directory, the second overwriting the first’s snapshot and
saved files — silently destroying the way back the first run had promised.
Points are now suffixed (…31Z-2) when the name is taken.
Also: rbr.ico was ONE 256x256 frame costing 85 kB of a 600 kB budget,
more than serde and the whole install engine together, which Windows then
downsampled to 16x16 for the title bar. It is now 16/32/48 as BMP plus 128
as PNG, 45 kB, built by installer/res/Build-Icon.ps1 from the source art
— smaller AND sharper at the sizes actually used. build.rs watches the
icon now; it did not, so a new icon was not a reason to rebuild and the old
bytes stayed in the binary.
Tests: 13 planning tests off Windows, 8 against a real game folder (including the bad-update rollback and the provenance proof), and a fourth pass in the wizard suite that restores through the real UI and checks the folder afterwards. 90/90 there, whole suite green, 576.5 kB against the gate.
2026/10/26 — installer: scene 3’s three routes, and a crash that hid itself
- Scene 3’s buttons are now arranged by importance, in two columns:
Download/Update is the main flow, so it is the default button, the widest,
and as tall as the other two together, alone on the right;
Configure/Install and the new Restore/Uninstall are secondary work on
a folder you already have, so they stack in a narrower column on the left,
top and bottom aligned with the big button so the three read as one block.
Test-UiLayout.ps1measures the arrangement — column, stacking order, relative width, matching height — because “looks primary” is the point of it and nothing else would catch a regression.- Restore/Uninstall answers “not implemented yet” until the restore scene lands. It ships early so the page’s shape does not change under users later, and so someone looking for a way back can see one is coming.
- Fixed: Configure/Install → change a pick → Back → Download/Update killed
the installer. The Configure route loads a tree MODEL without a tree
CONTROL to mirror it into. SysTreeView32 then sends an item-changed
notification carrying
lParam = 0before it holds anything of ours — indistinguishable from “node 0” — and the old guard (node >= tree.len()) no longer caught it, because the model was no longer empty. The posted refresh then indexed a handle table that was still empty and the process aborted. Fixed in three places, each of which would have been enough:- the handle table is sized wherever the model is assigned, so the two cannot drift;
- the refresh is driven off the handle table rather than the model’s length;
- a notification is validated by its
hItem, not by an lParam that an item we did not insert also has. The third pass ofTest-WizardDownload.ps1now walks the whole sequence.
- A panic now says where it happened. This is a GUI binary with
panic = "abort": a panic had nowhere to print, so the window simply vanished and Windows recorded an offset into a stripped exe. A hook inmain.rswritespanicked at <file>:<line>: <message>into the run log — or intopacenotes-installer-crash.logbeside the exe before a game folder is known — and shows it once. Only the first panic opens a window: the modal pumps the message queue, which can deliver the very message that panicked, and one fault was observed becoming a cascade of boxes. It found the bug above on its first run.- What the USER sees is localized and plain: what happened, where it was written, that the game folder is not half-installed, and “Show that file?”, which opens Explorer with the log selected. A report the user cannot find is one they cannot send. The Rust text stays in the file.
- Loaded after re-applying the chosen language: the hook can run on the worker thread, which never had it set.
--selftest-panicandtests/Test-CrashReport.ps1exercise the crash path, which using the installer correctly never does. They assert the exit code Windows reports, that the report names a source file and line, and that a panic before any game folder is settled still leaves a report beside the exe.- NOT done, and deliberately: a
SetUnhandledExceptionFilterfor access violations and other faults that never reach a Rust panic hook. Worth revisiting if a crash ever appears that leaves no panic line.
- Taking a second route in one session no longer starts a second log file. One file per run, not per button press.
- Release build 580 kB against the 600 kB gate. 48/48 layout checks per language, 76/76 flow checks, 7/7 crash checks.
2026/10/26 — installer: two routes, and the log moves into the game folder
- The run log lives in the game folder:
<game dir>\.pacenotes\logs\ install-<utc stamp>.log, beside the ledger, the backups and the restore points it describes. Nothing of ours is written to the user’s profile any more except the download cache, which is shared by every game folder.- One file per run, newest five kept. A single overwritten file meant that re-opening the installer to see what went wrong erased the run being looked at (DESIGN-REVIEW §7 asked for at least three).
- The file is attached when scene 3 settles a folder, and the lines from before that — language, server, catalogue — are replayed into it, so the file is the whole run rather than its second half.
- Downloading and installing are now two screens with the configuration
step between them. Scene 3 offers two routes:
- Download/Update → 4 (pick) → 4b (fetch) → 5 (configure) → 6 (install) → 7.
- Configure/Install → straight to 5, then 6. No server, no transfers: the questions are answered from the cache and the ledger.
- Why: a download fills a cache, an install writes to the game folder. Someone can fetch a voice pack at home and install it on a train, and a user who only wants to change which co-driver is active never has to wait for — or depend on — the server. That is the common case after the first run.
- The two routes are in-page buttons with the sheet’s Next disabled on that scene: they are alternatives, not a primary and an afterthought, and each is one click rather than select-then-Next.
Routedecides only what is SKIPPED. Both routes end in the same questions and the same install, reading the same tree, so there is no second “offline” code path to keep in step.
- Scene 4b is scene 6’s template and handlers at an earlier moment. Cancel belongs to it — a half-finished download costs nothing — and is now DISABLED on scene 6, where every phase writes to the game folder and the engine cannot stop between files; the button could only have lied.
worker:DownloadJob/run_downloadandInstallJob/run_installare two jobs, withFetchedas the download’s own outcome, because a finished download is not a finished run.InstallJobcarries no token and no package list: it installs from the cache and reaches the network only to report a conflict.- Found by the UI suite as soon as the second route existed: scene 5 now defaults to what is already installed. A route whose purpose is changing an install must not ask every question blank — and a selection that omitted an installed package would have uninstalled it. The user’s own pick still wins over the default.
- Tests: the flow suite walks both routes end to end in one run (68/68), asserting the ordering that the split exists for — when the questions are asked the payloads are cached and the game folder has no ledger yet — and that the second run keeps the first run’s log. 44/44 layout checks per language. Release build 573 kB against the 600 kB gate.
2026/10/25 — installer: the client halves of S10, S11 and D2
Three protocol changes landed on the server the same day; these are the
installer sides of them. One commit, because they interleave in
wizard.rs, worker.rs and api.rs and splitting them would mean
commits that do not build.
- Install schema (S11).
build.rsturns everyschemas/<game>.tomlinto a static table (schema.rs: the file’s bytes viainclude_bytes!, plus each category’sid/select/install_root). Read with a hand-rolled strict reader, not thetomlcrate — three string keys in one table array need no parser, nothing parses TOML at runtime, and a schema using more than that fails the BUILD with a line number.- The schema now DRIVES behaviour:
schema::choice_categories(theselect = "one"categories, in file order) decides what scene 4 demands a tick in, what scene 5 asks, and the tree’s category order. Labels stay in the.rctables, mapped by category id. schema::checkcompares the manifest’s per-game schema hash with the embedded copy’s. A hash this build does not know blocks DOWNLOADING for that game only, withIDS_S4_NEEDS_NEWER; reconfiguring, repairing, uninstalling and restore keep working.ALSO_ACCEPTEDholds hashes of revisions that are compatible anyway, so a rollout need not force an update for a corrected comment.- Only checked against a manifest that came FROM THE SERVER, so an offline run rebuilt from the cache never claims to be out of date.
src/schema/tests.rsreadsschemas/rbr.tomloff disk at test time and asserts the compiled-in copy is byte-identical, hashes the same, has no CRLF, and that its single-pick categories are the ones the installer asks a single pick from. The embedded hash and the compiled behaviour cannot drift apart.
- The schema now DRIVES behaviour:
- Key validation via
/whoami(D2)./packagesnever 4xxs — it answers the public listing for a key it does not accept — so the old check could never fail and a mistyped key looked accepted.api::whoamimaps 401 toRejectedand everything else, including a 200 it cannot parse, toUnreachable: a protocol change must not lock a paying user out.KeyStatus.expires_atis used, not ignored — a key within 14 days of expiry raises an informational modal naming the DATE (a date, not a day count: Polish needs three plural forms andload_string_argsubstitutes one%s). - Conflict reports (S10).
http.rsgrewpost_json, sharing one request path withget_streaming.api::report_conflictspostsshared::ConflictReportafter the install — so what is reported is what happened — with no token, no identity and no local paths, trimmed to the server’s 8 KiB cap by measuring (fitting_conflicts) rather than guessing.Overlap::refsconverts the engine’sgame/directory/namekeys to thedirectory/namerefs the report carries and DROPS any key it cannot split, since the server discards bad entries silently. A failed report is a log line; the user’s install succeeded either way. --selftest-report <game>posts a fabricated report through that same code, andTest-HttpClient.ps1now asserts on the JSONL line the server stored (refs in install order, nopcn_, the server’s own timestamp) plus the whole/whoamimatrix: no key, unknown key, live key, and a key minted--expires-days 3. 49/49 against a live server.- Fixed by the suite:
Test-WizardDownload.ps1still demanded a tick inpacenotes, which the schema marksselect = "any". The test was stale, not the gate — an operator adding a folder must not add a question every user is forced to answer. 54/54. - Release build 566 kB against the 600 kB gate.
2026/10/25 — server+shared: conflict reports from installers (S10)
POST /report/conflict, no auth, bodyshared::ConflictReport{game, installer, conflicts: [{file, packages}]}— one entry per shared file, its writers in install order (last = winner). Revised from the flat lists first sketched, which lost which packages fought over which file.- Treated as hostile input: 8 KiB cap (413),
game/installer/each file and ref validated, bad entries dropped and counted (400 only when none survive), nothing echoed back, empty 202 on success. - Rate limited per client (10/h) and globally (600/h) with
Retry-After, checked before the body is parsed. Behind Caddy the peer is always Caddy, so the client comes fromX-Forwarded-For— believed ONLY from a--trusted-proxy(PACENOTES_TRUSTED_PROXIES, IP or CIDR); a forged header from anyone else changes nothing (tested). - Stored as JSONL in
data/reports/conflicts-YYYY-MM-DD.jsonlwith the server’sreceived_at; no IP, key, hostname or local path is written. manifest genwarns per package pair reported in the last 30 days.- Found by the suite: tokio’s
File::write_allmay return before the bytes reach the OS, so the 202 could precede the write; appends now flush. deploy/README.mddocuments the reports, what they contain, and the trusted-proxy setting (10.0.0.0/24on this host).
2026/10/25 — server+shared: per-game install schema (S11)
Replaces the planned “manifest version”. Decided in review: what an installer can fall behind on is the SHAPE of a game’s catalogue — its categories, which ask for one pick, where each installs — not the package list.
schemas/rbr.toml(repository, canonical, byte-exact via.gitattributes -text, ASCII-only) declares RBR’s four categories. Deployed as<data>/<game>/schema.toml.shared:Manifest.schemas: { game: GameSchema { sha256, categories } }.sha256is the hash of the file’s RAW bytes — what installers compare. All fields are plain strings so a newer server can only fail an old installer’s comparison, never its parse.for_gamefilters schemas too.manifest genis strict: no schema → nothing published for that game; unparsable → same; a package in an undeclared category → skipped. The server bundles the repository schemas and warns when a data root’s copy differs (“installers from this release will refuse”).motdandreportsare reserved data-root names.- Upgrade guard:
manifest genREFUSES (exit 1, nothing written) when a game the current manifest serves would vanish because its schema is missing — otherwise the first restart after upgrading would empty the live catalogue. Rehearsed against a copy of the live data root: refused without the schema; with it, all 24 packages published under hashb9c5d124…. - Installer test fixtures (
Test-HttpClient.ps1,Test-WizardDownload.ps1) now ship schema files; not run here (Windows-only). - Installer side recorded in
installer/TASKS.md: embed the schema and compare hashes (a list per game, for rollouts), block downloading only.
Before redeploying the live server: cp schemas/rbr.toml /home/user/pacenotes/data/rbr/schema.toml, then run manifest migrate-to-sidecars
(deploy/README.md, “Upgrading”).
2026/10/25 — server: the manifest is reproducible (DESIGN-REVIEW D5)
manifest genno longer reads the previous manifest: the catalogue is a pure function of zips + sidecars (+ schemas, next). Deleting a sidecar key now actually removes it, and two replicas built from the same tree publish identical manifests.- A package without a sidecar
versiongetssha-<first 8 hex of its sha256>instead of the fixed0.0.0, so two payloads never share a version. - Safety net for the switch:
manifest genREFUSES (exit 1, nothing written, old manifest keeps serving) when a run would drop a value the current manifest carries, naming each one;--accept-metadata-lossoverrides for a deliberate removal. manifest migrate-to-sidecars: one-off move of manifest-only values into sidecars (adds missing keys, never overwrites, never pins0.0.0or a derived version, idempotent). Run once when upgrading (deploy/README.md).deploy/README.mdno longer calls the packages “reproducible” — they are operator content with no other copy; the manifest now genuinely is.
2026/10/25 — server: observability and HTTP hygiene (DESIGN-REVIEW D11, D12, C2)
- D11 One INFO line per response on the
accesstarget: method, path+query, status, bytes, ms, user agent, and the key as its 12-char HASH prefix (-none,malformedjunk) — matchable againsttoken list, never the key. Production runs at INFO, where the oldTraceLayerlogged nothing. Caddy’s HTTPS block gainslog. - D12
Content-Dispositionnames the leaf (Numeric Standard.zip, notstyle/Numeric Standard.zip), with an ASCIIfilenamethat cannot break its quotes and an RFC 6266filename*carrying the exact UTF-8 name. - C2
/packagesisprivate, no-store+Vary: Authorization(it differs per key); protected downloadsprivate, no-store; public onesno-cache. Every download hasETag: "<sha256>"and answersIf-None-Matchwith 304 — only after authorization.
2026/10/25 — server: robustness (DESIGN-REVIEW D10, S4, S5, S7)
- D10 The server handles SIGTERM, not just ctrl-c — as the container’s
PID 1 it previously ignored
podman stopuntil SIGKILL. In-flight requests drain for at most 20 s. Verified live: idle stop 0.01 s; a stop with a 250 s download in flight ends at 20.0 s. The Quadlet setsStopTimeout=30so podman waits for that drain. - S5
manifest.jsonis written atomically (temp + rename). A broken sidecar or a stray zip now withholds only that package: the rest still publishes andmanifest genexits 2 withERROR:lines (1 = nothing written). The entrypoint prints a banner instead of a one-line echo. - S7 Local auth DBs run in WAL mode with a 5 s
busy_timeout, sotoken mintduring traffic cannot 500 a user. - S4 (minimal) A package whose size on disk differs from the manifest is answered 503 “being updated”, not streamed as a short body that fails the client’s sha check as “corrupted”.
2026/10/25 — server: auth correctness (DESIGN-REVIEW D2, D3, D8, D9)
- D3 Download statuses no longer enumerate. On a non-public directory, a caller with no live key gets 401 whether or not the package exists; a live key without the grant gets 404, identical to a missing package. 403 is gone. The installer’s live-server test (anonymous protected download = 401) still holds.
- D2
GET /whoami: 200shared::KeyStatus { expires_at }for a live key, 401 for none/malformed/unknown/expired/revoked./packagescan never answer “is this key valid?” (it falls back to the public listing), so the installer’s scene-2 check was dead code; it moves to/whoami(installer/TASKS.mdI8). - D8 Token rows decode fail-closed. Found while writing the test:
libsql 0.6’s typed
row.get::<i64>PANICS on a type mismatch rather than erroring, so drift would have killed the request. Decoding now goes throughget_valuewith explicit matching. - D9 Public downloads never touch the auth DB;
/packagesserves the public listing when the DB fails; a bearer value not shaped like a minted key is rejected before any query. Proven by a test that drops the auth tables mid-run. - 401 responses now carry
WWW-Authenticate: Bearer.
2026/10/13 — deployable server (deploy/)
Everything needed to put a real server in front of the installer for testing:
deploy/Containerfile— multi-stage build (rust:1-slim → debian-slim), binds0.0.0.0:8080because the CLI’s loopback default would be unreachable from Caddy, and pinsCARGO_BUILD_JOBS=1for the 3 GB host.deploy/entrypoint.sh—manifest genthenserve, so publishing a package is “copy the zip in, restart the unit”. A failed generation leaves the previous manifest serving rather than taking the server down.deploy/pacenotes.container+pacenotes.config.env— Podman Quadlet following the host’sexample.containerpattern (clone branch → build → discard checkout), with one volume (~/pacenotes/data→/data), a healthcheck on/health, and no published port: Caddy is the only route in and terminates TLS. Validated with podman’s own quadlet generator.deploy/Caddyfile.snippet—pacenotes.example.comreverse-proxying topacenotes:8080, plus the optional internalhttp://block.deploy/README.md— configuration and deployment walkthrough: data layout, publishing, sidecars, MOTD, access keys, installer test commands, operations table and troubleshooting.libsqllost itsreplicationfeature (tonic/tonic-web/prost-build/ libsql_replication): embedded replicas are not something this server creates, and the dependency tree was the bulk of the image build.core+remote+tlskeep Turso-by---db-urlintact. Tests unchanged and green.
Two departures from the example.container pattern, both deliberate:
podman image prune -f without --external (the --external form deletes
concurrent builds’ intermediate containers — see below), and
StartLimitBurst=3/StartLimitIntervalSec=1h so a crash cannot loop a
release build.
2026/10/12 — one server, every game
Decided with the maintainer: one instance serves all games, and the first folder level under the data root names the game.
- Layout is now
<data>/<game>/packages/<directory>/<name>.zip, with<data>/motd/<lang>.txtbeside the games and one<data>/manifest.jsoncovering all of them.serve/manifest gentake--data(PACENOTES_DATA_DIR, default./data) in place of--packages. PackageInfo.gamecomes from that folder, sogameis no longer a sidecar key: a sidecar still carrying it fails the run. Nothing positional comes from a .toml any more.GET /packages?game=<id>filters to one game; without it the document covers every game. An unknown game is an empty list, not an error.GET /download/{game}/{directory}/{*name}: the game leads the address, because two games can hold the same directory+name — without it the route would have silently served the wrong game’s bytes. All three segments stay lookup keys; a miss on any is 404.sharedgainedvalid_id(one rule for games and access directories),KNOWN_GAMES,Manifest::for_gameandPackageInfo::download_path.manifest genwarns on the easy mis-filings (category where an access directory belongs, category/publicwhere a game belongs, game folder with nopackages/, unknown game id, name needing percent-encoding).- The real
server/datatree was re-filed underpublic/: 24 RBR packages now publish with correct categories, and a 28 MB nested download plus a percent-encoded name (style/Numeric%20Standard) were verified over HTTP.
INSTALLER SIDE, not done here: the download URL gained a leading game
segment, /packages can be filtered by game, and a cache/ledger key of
“directory/name” is no longer unique across games — use
PackageInfo::download_path() or prefix the game.
2026/10/12 — server side of protocol rev. 2
manifest genrecurses: a package’snameis now its path under the access directory without.zip(packages/public/codriver/polish/male/Kamil.zip→public+codriver/polish/male/Kamil), so the operator’s folder layout is the installer’s Download tree. Names are validated withshared::valid_name; rejected or non-UTF-8 names are skipped with a warning instead of entering the manifest.GET /download/{directory}/{*name}is a wildcard route so nested names match. The security invariant is untouched: both halves stay manifest lookup keys, only a hit yields a path on disk, and every miss — including percent-encoded traversal — is 404.GET /motd?lang=xxserves<packages>/motd/<lang>.txtas text/plain, falls back toen, 404s when absent (the installer treats that like being offline) and needs no credential.langis restricted to[a-z]{2,8}before it touches the filesystem.manifest gennow warns when a top-level folder is a known category name, i.e. when the access-directory level has been left out — the mistake the realserver/datatree had.- Sidecars lost their last positional key:
gameaside, nothing about where a package sits comes from a .toml any more.
2026/09/20 — M3: server auth
- libSQL auth store:
db.rsopens--db-url(local file,:memory:, or remote Turso by URL withPACENOTES_DB_AUTH_TOKEN) and appliesmigrations/0001_init.sqlidempotently on every start. auth.rs: bearer parsing, sha256 hashing, minting (pcn_+ 32 random bytes, hand-rolled unpadded base64url — no dependency for 20 lines), and a single-querytokens LEFT JOIN grantsload.TokenRow::is_liveis the one definition of “not revoked, not expired”, shared bydecideand the/packagesfilter./packagesfilters to public + granted directories and never 4xxs (a bad credential silently sees the anonymous listing, so directory names cannot be enumerated)./downloadenforcesdecide(): 401 missing/expired/ revoked, 403 valid-but-not-granted, 404 no such package — resolved before the access check, so a nonexistent package is 404 for everyone.tokenadmin CLI:mint(prints the key once, stdout),grant,ungrant,revoke,list. Credentials are addressed by raw key or hash (full or the short formlistprints);publicand invalid directory names are refused; an ambiguous hash prefix is an error.serversplit into lib + bin soserver/tests/api.rsdrives the real router: the {anon, no-grants, dirA, dirA+dirB} x {public, dirA, dirB} matrix for listing AND download, plus expired, revoked, ungrant-takes- effect, traversal and ApiError bodies. 35 tests green, clippy -D warnings clean.- Verified live: minting, listing, refusal to grant
public, per-grant filtering, 401/403/200 on download, revoke taking effect — and the log contains neither the key nor the Authorization header.
2026/09/20 — M1a (protocol v2) + M2 (server MVP)
shared: protocol v2.PackageInfogainsgame,category,display_name,exclusive_group,requires; newPackageRef(“directory/name” — the download path, cache key, ledger key andrequiresformat in one shape),PackageInfo::label()/required_refs(),Manifest::get(). Old (v1) manifests still deserialize via serde defaults, and absent optionals are not serialized, so the JSON stays hand-readable.gameis not in the written M1a list:installer/DESIGN.mdmade the app multi-game, and without it every package would appear under every ribbon tab.server: M2 complete.GET /packages(public-only for now) andGET /download/{directory}/{name}streaming viatokio_util::io::ReaderStream;manifest genscans<dir>/<directory>/*.zip, hashes each zip and merges its<name>.tomlsidecar (precedence: sidecar > previous manifest > protocol default). A malformed sidecar aborts the run; danglingrequires, sidecars without a zip, public-requires-protected and cross-game dependencies are collected warnings.- Verified end-to-end with curl: health 200; anon listing shows only
public; download byte-identical with correct Content-Length/Type/Disposition; unknown package and..%2F..%2Fdata/auth.dbboth 404. - KNOWN GAP:
/downloadis not access-checked yet (M3/S6)./packageshides protected directory names, but that is concealment, not access control — do not deploy before M3.
2026/09/18 — M0/M1 done (server side)
- All workspace dependencies pinned to exact versions;
clapgained theenvfeature so thePACENOTES_*fallbacks compile.cargo build,cargo clippy -D warningsandcargo testare green for shared+server. - GitHub Actions ported from
legacy/.github/workflows(C#/dotnet → Rust):ci.yml(new): fmt + clippy + test for shared/server on Linux, installer test + release build + 500 kB size gate on Windows.cd-debugbuild-manually.yml: manual debug artifact (exe + pdb).cd-deploy-manually.yml: manual release — version frominstaller/Cargo.toml, tag-exists check, size gate, GitHub release.cd-deploy-on-pull-request.yml: same, on merged PR to master. Supabase secret injection dropped (no Supabase); the archivedactions/create-release+upload-release-assetpair replaced withsoftprops/action-gh-release.
2026/09/18
There was an error with the installer package.
error: current package believes it's in a workspace when it's not:
current: E:\Git\RBR\CoPilot_InstallerV2\installer\Cargo.toml
workspace: E:\Git\RBR\CoPilot_InstallerV2\Cargo.toml
this may be fixable by adding `installer` to the `workspace.members` array of the manifest located at: E:\Git\RBR\CoPilot_InstallerV2\Cargo.toml
Alternatively, to keep it out of the workspace, add the package to the `workspace.exclude` array, or add an empty `[workspace]` table to the package's manifest.
Patched with addition of “workspace.exclude” in top-level Cargo.toml