Skip to content

feat: the whole boot chain, and a directory per identity - #2

Merged
wamxx merged 59 commits into
developfrom
feature/boot-media
Aug 29, 2026
Merged

feat: the whole boot chain, and a directory per identity#2
wamxx merged 59 commits into
developfrom
feature/boot-media

Conversation

@wamxx

@wamxx wamxx commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Two bodies of work that arrived in order and cannot be separated now — the second is built on the first and edits its tests.

1. Boot media: one binary, the whole chain

Everything between "a machine is powered on" and "an installer is running and asking for its answer". Previously that gap was somebody else's TFTP server and somebody else's ISO extraction.

  • ISO9660 reader (src/boot/iso.rs) — a file in an image is one contiguous extent, so serving a kernel is a seek, never an extraction. Rock Ridge and Joliet included.
  • Media listener on its own socket, its own budget, its own timeouts. A test proves answers keep succeeding with four image transfers in flight — that is the property the second socket exists for.
  • TFTP (src/boot/tftp.rs) speaking RFC 1350 plus options, tested over real UDP.
  • Branded iPXE (packaging/ipxe/) — no binaries in git; the directory is the GPLv2 written offer, and the loaders ship in the release and the .spk.
  • Image sources that store nothing about any image: each entry names the checksum index a vendor already publishes, so a baked-in table can never ship stale and serve 404s.
  • media prepare adds one file to an ISO as a plan — ~200 bytes on disk, applied on the wire — so the bytes stay what the vendor published and stay checkable against their own SHA256SUMS.
  • POST /installed closes the loop: the machine says it finished, and its claim is dropped before it reboots. The window to rename a file by hand was however long the firmware took to come back.
  • The DSM package carries a real desktop application for the settings panel and ships the loaders, so the share's boot folder arrives filled.

This ran on real hardware. On 2026-08-28 a Lenovo vPro machine was powered on and installed itself with Proxmox VE 9.2, unattended, from a DS416j — DHCP handoff, TFTP, branded iPXE, the menu's answer, kernel and initrd and a 1.6 GB image over HTTP, the injected mode file, and its own answer.toml. Then it disarmed itself and came back up on its own disk instead of reinstalling.

Seven defects stood between "every harness green" and that, and none of them was visible to the rig. They are recorded in docs/development/traps.md — a TFTP windowsize acknowledged but not implemented (nine minutes per loader), a transfer logged before it happened rather than after, a connected data socket the kernel filtered on port, an initrd given a name so it became a file instead of the initramfs.

2. A directory per identity

A machine's answers were files sharing a stem: 98fa9b50d810.toml beside 98fa9b50d810.preseed. Nothing held them together — they were adjacent by sorting. They are a directory now:

answers/98-fa-9b-50-d8-10/proxmox.toml
                         /debian.preseed
                         /boot.ipxe

The directory name is the identity; the extension is the format and the stem is nothing at all. proxmox.toml and answer.toml are one document to this server. A write overwrites an existing document where it stands, so an operator's own naming survives.

Two documents of one format in one directory is a reported problem, never a silent choice — there is no tiebreak anyone could have predicted. Groups and the fallback take the same shape, so there is one rule rather than three, and groups/default are reserved as machine ids in both stores: a database that accepted groups would export into a directory that cannot hold it.

Disarming stays a sibling directory (installed-<id>/). The machine's directory keeps meaning "this machine's configuration", no new exclusion rule is needed, and installed.rs did not change.

Breaking, with a way across

A servable document left flat is reported with its destination and no longer served. Half-reading the old layout would mean a machine whose answer moved silently between two files.

$ rescriptum migrate
  98fa9b50d810.toml -> 98fa9b50d810/proxmox.toml
  groups/rack-a.toml -> groups/rack-a/proxmox.toml
  2 document(s) to move — nothing has been changed. Re-run with --apply.

It shows by default and moves only when told to; one taken destination aborts the whole run rather than leaving a half-migrated directory.

What it costs, measured

At 2,000 machines on an M1 Pro, a full store reload goes from 28.6 ms to 63.5 ms — a readdir per identity on top of the file already opened. It is syscalls, not allocation: removing the allocations moved nothing. The listing cache amortises it over a second's worth of requests, and end-to-end throughput did not move measurably.

The mtime also sees less than it did. A document added inside a machine's directory is one level below what version() watches, so RELOAD_BACKSTOP catches it rather than the version token — the same rule that already covered a file edited in place, now the normal case. Tests pin both halves.

Testing

582 tests, all green. Each of the nine new ones was watched failing before being trusted — the project has been caught by a test that passed for the wrong reason.

  • tests/common/mod.rs is new: fixtures go through StoreWrite, so they land where an admin-API write would and cannot drift from the layout. One copy, not one per suite.
  • New behaviour went in tests/stores.rs and therefore runs against both stores.
  • packaging/boot-rig/ boots a claimed and an unclaimed machine in QEMU and asserts four markers. cargo test does not run it.

Documentation

Both languages, in the same commit as the change. The layout, the migration path, the reserved names, the measured cost, and the traps.

Not done

The DSM package has not been re-run on the machine. remote-check.sh, lifecycle-test.sh and the rig's fixtures were adapted to the new layout, but packaging/dsm/CLAUDE.md requires running the DS416j for anything under that directory. That is outstanding.

cargo build --no-default-features is broken, and was before this branchconfig.rs reaches for crate::boot::tftp::MAX_BLOCK without a #[cfg]. It is a CI gate (ci.yml:55), so that job will fail here for a reason this PR did not introduce and does not fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6

wamxx and others added 30 commits August 27, 2026 12:41
Phase 1 of plans/boot-media.md, the part with no wiring: reading an image,
placing it, listing what is held, and writing the boot stanza each family
needs. All dependency-free, as the plan's budget requires.

- `iso.rs` reads ISO9660 far enough to turn a path into an offset and a
  length. A file in an image is one contiguous extent, so "extract the
  kernel" is a seek — nothing is unpacked and nothing is copied. Rock Ridge
  names win over the mangled identifiers, which is what makes
  `auto-installer-mode.toml` findable at all. Its test builder writes images
  in memory: no binary fixture in the repository.
- `probe.rs` places an image from a table of markers. `/.disk/info` is read
  first, and Proxmox is why: `prepare-iso --pxe` strips `/boot` from the ISO
  it emits, so the obvious marker misses exactly the image most likely to be
  dropped into a media directory. Reading that file is upstream's own
  identification method. A trimmed image also finds the vmlinuz and
  initrd.img the assistant left beside it.
- `catalog.rs` discovers rather than declares, cached behind the directory
  mtime with the same backstop the answer listing uses. AppleDouble entries
  are skipped from the first commit; reserved names are refused rather than
  shadowing a route.
- `stanza.rs` holds what each family needs on the wire. The Proxmox stanza is
  upstream's own output, `proxmox-start-auto-installer` included — the plan's
  table said Proxmox needs nothing on the command line, and that is wrong:
  without that parameter a machine boots the interactive installer.
- `sha256.rs` and `cpio.rs`, hand-written, for digests and for `initrd+iso`.

45 tests, twelve of them watched failing first — two were real bugs, a
`locate` that returned a sentinel instead of None and a SHA-256 `update`
that dropped a partial block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Completes Phase 1 of plans/boot-media.md. A machine can now fetch the
installer itself — kernel, initrd, image — from the same server that decides
its answer, and the two cannot drift apart because one component knows both.

The listener is its own socket, and that is forced rather than preferred:
the answer endpoint answers on any path, its whole-connection deadline is ten
seconds where a 1.5 GB transfer is two minutes, and its connection semaphore
would be held for minutes by a download. `tests/media.rs` proves the
consequence rather than asserting it — answers keep succeeding with four
transfers in flight.

Ranges, ETag, If-Range, HEAD and 416 are all here because real clients need
them: five of the seven installers range-fetch, and UEFI HTTP Boot sends HEAD
before it fetches. `initrd+iso` is synthesised on the wire — initrd, a cpio
header naming proxmox.iso, the image — so old loaders work without a second
1.5 GB file on disk.

Configuration gains RESCRIPTUM_PUBLIC_HOST (a host, never a URL — it is
written into URLs for two listeners) plus the media directory, address,
timeout, connection cap and CIDR allowlist. Media is off until a directory is
named, so nothing changes for an existing deployment.

Two bugs the tests caught rather than review: `HeaderName::from_static`
panics on a name that is not lowercase, which killed the connection instead
of answering 405; and the same-port guard refused two `:0` listeners, which
can never collide because the kernel picks both.

93 new tests over the 333 that were here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The plan asks for one feature covering media and TFTP, default on, so
`--no-default-features` still produces the smallest possible answer server.
It refuses loudly rather than ignoring a media directory it cannot serve —
the same shape `open_store` already uses for RESCRIPTUM_STORE=sqlite without
the sqlite feature.

Measured on armv7 (gnueabihf, floor 2.17), which is the target the budget is
written against:

    sqlite + boot   2,602,056
    sqlite only     2,482,000
    neither         1,316,648

So boot costs 120,056 bytes. Two things worth recording: that is 71% of the
plan's ≤170 KB budget spent on Phase 1 alone, and the figures in CLAUDE.md
(2,103,456 / 944,928) are stale — they predate the switch from musl to
glibc, and taking them at face value made this look like a 293% overrun.
Measure before concluding.

Also fixes a silent hole in the self dev-dependency: without
`default-features = false` it re-enabled sqlite and boot for every test
build, so `cargo test --no-default-features` would have tested the full
binary and reported coverage that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The plan's own rule is that a gate must not be exceeded silently, so the
budget finding goes in the plan rather than in a commit nobody re-reads:
`boot` costs 120,056 bytes on armv7, which is 71% of the ≤170 KB allowance
for Phase 1 alone. Phases 2 and 4 will not fit in what is left. Re-decide the
figure or split the feature; do not drift past it.

Three things the plan had wrong or open, now settled from sources:

- The trimmed-Proxmox marker needed no bench. `inspect-iso` identifies an ISO
  by `/.disk/info` and PRODUCTLONG, and `--pxe` does not strip it.
- Proxmox does need something on the kernel command line after all —
  `proxmox-start-auto-installer`. Without it the machine boots the
  interactive installer.
- `;` separates iPXE commands only as a whole whitespace-delimited token, so
  Ubuntu's NoCloud argument needs no escaping. Read out of `core/exec.c`.

CLAUDE.md gains the boot layout, the six new variables, the real sizes and
four traps that each cost a red test — including that
`HeaderName::from_static` panics on a name that is not lower-case, and that
the size figures in that file go stale when a target changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
A user-visible change lands with its documentation, so Phase 1 gets its page:
getting an image in, what the probe can tell about it, the endpoints, why the
listener is a second socket, generating a boot stanza, and why
RESCRIPTUM_PUBLIC_HOST is a host and never a URL.

The scope statement in the guide index needed correcting rather than
extending. "Not a PXE/TFTP/DHCP server" was one claim doing three jobs, and
one of them has stopped being true: DHCP stays a non-goal in any form, TFTP
is not here *yet*, and the installer's kernel, initrd and image are served
now. Saying so in one bullet would have been vague where it used to be exact.

Configuration and CLI references gain the six variables and the four
commands; the compile-time table gains `boot` and real ARMv7 figures, with
the note that the old ones were stale.

`notabene lint` is green — and note it lints against the last public build,
so `docs:build` has to run first or a new page reads as a broken link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…ding

Phase 2's first half. TFTP is core rather than optional — an appliance that
needs somebody else's TFTP server is not an appliance — and it hands over
exactly one file. At 1468 bytes a round-trip an image would take twenty
minutes where HTTP takes fifteen seconds, so the rule is written into the
module and into the root it is pointed at: the loader, and then HTTP.

One table maps option 93 to a loader, and both the TFTP server and (next)
`boot dhcp-snippet` read it, so what an operator pastes into their DHCP
server and what this one hands out cannot drift. It carries the recorded
exception the registry alone would get wrong: RFC 4578 called 0x0009 x86-64,
IANA calls it EBC, and real firmware sends either.

`tests/tftp.rs` speaks the protocol over real UDP, and it earned its keep
immediately by finding two bugs of the "works by hand, never after a reboot"
kind:

- A file whose length is an exact multiple of the block size never ended. A
  short block is what finishes a transfer, and such a file has none — so it
  must end with an *empty* one. Watched red with the defect restored.
- The per-peer cap counted datagrams rather than transfers, so four stray
  packets locked an address out. That is not a hostility threshold: **a PXE
  ROM retransmits its read request** when an answer is slow, a sleeping NAS
  disk is enough to cause it, and each retransmission is a fresh transfer
  from the same address. Junk now costs no slot, the cap is eight, and a
  test boots six retransmissions to pin it.

Privilege dropping is bind-then-drop, groups before gid before uid, and it
verifies the drop by trying to undo it — a process that thinks it dropped and
did not is worse than one that never tried. `libc` becomes a direct
dependency and costs zero new crates: tokio already had it.

467 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The milestone the goal is written against: one binary boots an arbitrary
machine into a menu, and a known machine into an unattended install, on a
network whose DHCP server gained two options and was otherwise not touched.

The bootstrap is where "a menu is the default answer" actually lives, and it
is one `||`: chain to the answer endpoint with the machine's identity in the
query string, and fall through to the menu when nothing claims it. No new
concept in select.rs, and it is served by the media listener because it has
to work when the answer set is empty — the state every new install starts in.

The menu is rendered from the catalogue per request rather than kept in sync
as a file, so an ISO dropped in the directory is in the menu on the next
fetch. `item local` is first and the timeout falls through to it: a machine
that PXE-boots by accident ends up on its own disk rather than waiting for a
human who is not coming.

`boot dhcp-snippet` writes six formats from the same table TFTP serves from,
and `tests/cli.rs` pins the two together — a snippet naming a loader the
server does not hand out fails silently at the ROM. Two findings went into
it: a Windows policy cannot condition on option 93 at all (the architecture
reaches it only inside the option 60 string), and Kea's own documentation
says to prefer `user-context` over `#` comments because "most JSON tools
detect them as errors".

507 tests.

**The size budget is now exceeded and the plan records it rather than passing
quietly**: `boot` costs 205,368 bytes on armv7 against a ≤170 KB allowance,
with Phase 4 still unwritten. The number is not padding — it buys an ISO
reader, a catalogue, a media listener, TFTP, a menu and six configuration
generators — and the budget was set before any of it existed. The gate's job
was to make that visible before a release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…uns it

The last piece of Phase 2 that is not Rust: what TFTP actually hands out.
`branding.h`, the embedded script, a SHA-pinned upstream commit, a build
script and a CI job that builds all six loaders and then asks the server
whether the set satisfies the table it serves from.

**Written, not yet built, and the README says so in its first heading.** The
pin was chosen from upstream's tag list and the make targets from upstream's
documentation; neither has been compiled here. The first CI run is what turns
that from plausible into verified, and `PINNED` records the fallback commit
for the likely case that v2.0.0's major bump does not build cleanly.

Two decisions worth keeping:

- **The embedded script's port is a contract**, not a preference. It can read
  no configuration — it is baked in before any deployment exists — so 8001 is
  as fixed there as an answer URL baked into an ISO. `boot check` warns when
  the configured port has moved away from it.
- **`PRODUCT_ERROR_URI` is deliberately left pointing at ipxe.org.** That
  database turns a 32-bit error code into a sentence and links the line of
  code that raised it. Redirecting it at us would replace a working
  diagnostic service with nothing, and the person staring at a hex code at
  3am is exactly who it exists for. `PRODUCT_SHORT_NAME` stays "iPXE" for
  upstream's own stated reason.

The GPL obligation is met by construction rather than bolted on: the loaders
are separate files never linked into an MIT binary, and this directory is the
written offer for their source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 2's page: the four links from power-on, which of them are ours, the
two lines their DHCP server needs, why the loader has to carry an embedded
script at all, and what a machine actually sees.

Three things it says plainly because they are the ways this fails quietly on
somebody else's network: a UEFI HTTP Boot client discards an offer that does
not echo `HTTPClient` in option 60, a Windows DHCP policy cannot condition on
option 93 at all, and a snippet naming a loader that is not on disk fails
silently at the ROM with nothing on any console.

The scope statement needed another correction. "Not a TFTP server — not yet"
was true for one commit; it is now a TFTP server, and the honest remaining
non-goal is DHCP in any form. The blast-radius table moves into the guide
too, because "a boot server" sounds load-bearing and is not: nothing this
installs depends on it afterwards.

The loaders page carries a warning rather than instructions that would not
work — `packaging/ipxe/` is written and unbuilt, and the guide says so where
somebody would otherwise go looking for a download.

`notabene lint` green over 76 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 3's harness: three services on a network with `internal: true`, so a
rig that runs a DHCP server is structurally unable to answer anything on the
host's LAN — the same "did installing this break the network" hygiene the
product lives by. No /dev/kvm anywhere: it has to pass under TCG, because the
development machine is a Mac.

Two markers, both deterministic and neither a screenshot. An unclaimed
machine must reach a disk whose only content is a boot sector that prints a
magic string to the serial console — proving it went through DHCP, the
loader, the bootstrap and the menu, found nothing claiming it, and fell
through. A claimed machine's answer fetches a sentinel, so that assertion is
a line in the *server's* log rather than something the client printed.

dnsmasq is configured from `boot dhcp-snippet`'s own output, which makes the
rig a test of the snippet too: if what we tell operators to paste is wrong,
nothing boots.

**Building the loaders for the rig verified the whole packaging half**, and
found two real bugs on the way:

- The build must be amd64. iPXE's BIOS targets are 32-bit x86, and an ARM64
  host's gcc produces a wall of `-m32` errors that reads like a broken
  Makefile.
- ARM64 needed `CROSS_COMPILE=aarch64-linux-gnu-`, absent from the first
  version — the host compiler was used and died on `-mlittle-endian`.
- The ISO and USB targets were being asked for in the EFI build directory
  rather than the BIOS one, and failed silently into a `||`.

The pinned commit now produces all eight loaders; `strings` finds
PRODUCT_NAME, PRODUCT_URI and `embed.ipxe` verbatim in the EFI builds; and
`rescriptum boot check` agrees the set satisfies the loader table. What
remains unproven is what firmware does with them, which is the rig and then
real hardware — the standing rule that nothing ships on harness evidence
alone is unchanged, and both READMEs say where the line is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Phase 4, and the last external tool goes. Proxmox reads
`/auto-installer-mode.toml` from the mounted image to learn where its answer
lives; every other family takes a URL on the kernel command line. Adding that
one file used to mean `proxmox-auto-install-assistant prepare-iso`.

It is tractable because **on the PXE path the image is never booted, only
mounted** — the requirement is "still a readable ISO9660 filesystem exposing
one more file", which is a far weaker problem than the one xorriso solves. An
ISO9660 file is a contiguous extent, so adding one is three small overwrites
and an append: the content past the end, a directory record in the slack at
the end of the root extent, and the volume space size in both descriptors.

So this produces a *plan* rather than a file — offsets and bytes, applied
while streaming. No second copy on disk, the source never mutated so its
published digest stays verifiable, ranges still work because the arithmetic
is trivial, and changing the answer URL recomputes 300 bytes.

The trap that decides whether it works is Rock Ridge:
`auto-installer-mode.toml` is not a legal ISO9660 identifier, so the record
is called `AUTO_INS.TOM;1` and the installer would never find its file. The
real name lives in an `NM` entry, and in the Joliet tree too when there is
one — which tree a mount reads is not ours to decide. With neither, this
refuses, and refusing is complete: the fallback is one command on any Debian
box whose output this server is happy to serve. A UDF image is refused
outright, because patching the ISO9660 tree of a Windows ISO produces
something that looks right and is not.

Watched red: removing the `NM` entry turns
`a_file_added_to_an_image_reads_back_under_its_real_name` red, which is the
whole trap in one test.

Also removes two duplicate fields clippy caught in the ISO reader — I had
added `root_extent` beside the existing `root`, which is exactly the "two
copies drift" failure this codebase warns about.

524 tests. The boot feature now costs 227,840 bytes on armv7 — 134% of the
budget, recorded in the plan with the per-phase breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The media guide gains `media prepare` and `media export` in both languages —
what a sidecar is, why nothing is copied, when it refuses and what the way
out is, and why a source that changed underneath is refused rather than
patched in the wrong place.

CLAUDE.md gains the boot layout as it now stands, the re-measured sizes, and
eight traps that each cost something to find:

- a PXE ROM retransmits its read request, so a per-peer cap is a fairness
  bound rather than a hostility threshold;
- a TFTP transfer ends on a short block, and "short" includes empty;
- `;` separates iPXE commands only as a standalone token;
- `net0` is the first NIC rather than the booting one, and iPXE
  percent-encodes nothing on plain expansion;
- a UEFI HTTP Boot client discards an offer that does not echo `HTTPClient`,
  and a Windows DHCP policy cannot condition on option 93 at all;
- `auto-installer-mode.toml` is not a legal ISO9660 identifier;
- iPXE's BIOS targets need an x86 compiler and its ARM64 ones a cross prefix.

Also deduplicates a `dhcp-boot` line the generator emitted twice, because
0x0007 and 0x0009 share a tag. dnsmasq ignores the second; an operator reads
the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…he archive

No installer image is in this repository or in a release, and now the server
can go and get one: `media add <url> --sha256 …` fetches straight into the
media directory. There is still no TLS in the binary — forty crates and a
megabyte on ARMv7 for a job every host already has a tool for — so this
shells out to curl or wget and says plainly when it finds neither, on the
same precedent as `check` calling the Proxmox validator.

Three properties, each a way it would otherwise go wrong:

- **A download lands on a `.part` name and is renamed only once the digest
  matches.** The catalogue probes whatever it finds, so a partial download
  would become an entry — a truncated ISO probes as unknown, and a machine
  would try to boot it. An interrupted fetch leaves the part file and
  resumes.
- **A URL requires `--sha256` unless `--unverified` is passed.** This decides
  what every machine on the network installs; an image pulled off a mirror
  with nothing checking it is the one place here that would be a shrug, so
  the unsafe path is a deliberate flag rather than the default. A local file
  keeps the digest optional — the operator already had it.
- **A fetch never overwrites an existing image.** Machines may be booting it.

The consequence worth naming, and now said in the guide, in CLAUDE.md and in
the plan: **the media directory is the archive.** Nothing modifies an image
after it lands — preparing one produces a sidecar plus an injection applied
on the wire — so the bytes on disk stay exactly what the vendor published and
their digest stays checkable against the vendor's own SHA256SUMS. `media
list` grew a SOURCE column so which entries are the archive and which derive
from it is visible rather than inferred.

Also adds a `.dockerignore`. The boot rig's build context was **6 GB** —
`target/` alone was most of it — and every run spent two minutes transferring
artefacts the image rebuilds from scratch anyway. A rig nobody waits for is a
rig nobody runs.

536 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Two failures from actually running the rig, and both were invisible until it
ran:

- `iproute2` was missing from the client image, so `ip` was not there to
  bridge eth0 and QEMU would have booted with no network at all. Five
  `ip: command not found` lines scrolled past and the script carried on,
  because every `ip` call tolerated its own failure and the guard at the
  bottom of the function never noticed. It now checks for `ip` up front and
  proves `tap0` exists rather than trusting that nothing printed an error.
- `docker compose run` reuses whatever image is already there, so the client
  image was never rebuilt and the fix above silently did not apply. `run.sh`
  now builds it explicitly, and the client leaves the `manual` profile so
  `build` can see it.

Everything before QEMU already worked on the first real run: the DHCP
configuration generated from the server's own snippet, the stack up on an
isolated network, the branded loaders built inside the container, and
`boot check` agreeing from inside the server that the set is complete.

Also handles the one download failure that repeating will not fix: curl exits
33 when a mirror ignores the Range header, so resuming onto a partial file
fails identically forever. The message now says to delete it rather than
"run this again".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The rig's own rule, broken by the rig: the network is `internal: true`, so
nothing on it can reach an apt repository. dnsmasq was installed at run time,
apt failed into `/dev/null`, and the container exited 127 with
`exec: dnsmasq: not found` — a minute before a client was booted at it. It
now has an image, like the loaders, for the reason the README already gave.

The deeper failure is that nothing noticed. **A container that died looks
exactly like one still starting**, so `run.sh` now checks every service is
running before it boots anything and prints the log of any that is not.
Without that the symptom was four minutes of QEMU and two missing markers,
which reads as a broken boot chain rather than a broken harness.

`media add <url>` also names a missing media directory itself rather than
leaving curl to say "Failed to open the file", which points at a path instead
of at the setting that produced it.

The rig README gains a table of what the first four runs cost, because each
is a shape that will recur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
All four markers reached on a development machine under TCG: the DHCP
handoff answered from `boot dhcp-snippet`'s own output, a loader fetched over
TFTP, the unclaimed machine fell through to its local disk, and the claimed
machine reached its own answer. That is the whole chain — DHCP, our branded
loader, its embedded script, the bootstrap, the answer engine, and either an
unattended answer or the menu.

And it has been red for the right reasons, which is what makes green mean
anything: deleting a loader stops the run at `boot check`, and deleting the
claimed machine's answer turns that marker alone red while the machine still
falls through to its disk — so the fallthrough covers a deleted answer too.

**The rig's shape changed, and the measurement is why.** A QEMU guest bridged
into a container has a MAC of its own, and Docker Desktop's virtual switch
does not forward frames from a MAC it did not assign: container-to-container
TCP works, and a DHCP broadcast from the guest reaches nothing at all —
tcpdump on the receiving side captures zero packets while the client's own
tap0 and eth0 counters show the frames leaving. So the primary rig is now one
container on a private bridge with no uplink. Nothing crosses Docker's
network, which is stronger isolation than the `internal: true` the plan asked
for. The four-service variant stays as `run-compose.sh` for a Linux host.

Five failures had to be fixed before it ran at all, and every one was
invisible until it did: `iproute2` missing from an image; a client image
never rebuilt, because `docker compose run` reuses whatever exists; dnsmasq
installed at run time on a network that by design cannot reach apt; dnsmasq
logging to syslog, where a container has none, so the DHCPACK marker could
never have matched; and a disk attached with `if=ide` to a `q35` machine,
which has no IDE controller — SeaBIOS said "could not read the boot disk" and
it read as a broken menu.

Also fixes `--help` in three scripts: `sed -n … "$0"` cannot find a
relatively-invoked script after a `cd`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The tripwire the plan asks for: BIOS only, TCG, one claimed and one unclaimed
client, time-boxed, no OS image. The dev rig is what decides; this re-proves
the chain on every push without a real machine, and it is sized so it cannot
fail for capacity reasons.

It also breaks a link deliberately and requires the break to show. A green
rig that has never been red proves nothing, and the cheapest way to keep that
true is to prove it on every run rather than to remember to do it by hand.

CLAUDE.md gains the five traps the rig's first runs paid for, of which the
one worth repeating is not technical: **a container that died looks exactly
like one still starting**, and three of the five hid behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The package now serves installer images from the same NAS that decides the
answer. `RESCRIPTUM_MEDIA_DIR` is one uncommented line away, the share gains
`media/` and `boot/` folders at start, and port 8001 is registered with the
firewall beside the answer port — registering does not open it, and the
alternative is an operator who enables media and then cannot find rescriptum
in the rule editor.

**TFTP is not in the package, and the env file says why rather than offering
a setting that breaks.** Port 69 is privileged and DSM 7 does not let an
unsigned package run as root, so `RESCRIPTUM_TFTP_ADDR` would produce a
package that refuses to start. DSM has its own TFTP server and it is the
right one here: point it at the share's `boot` folder, put the loaders there,
and the chain continues on port 8001. DSM hands over one file; that is the
whole of its part. `RESCRIPTUM_USER`/`_GROUP` are documented the same way —
the package already is its own unprivileged user.

`lifecycle-test.sh` caught a real defect on its first run, which is what it
is for: the first version of this wrote a live `RESCRIPTUM_MEDIA_ADDR` with
`RESCRIPTUM_MEDIA_DIR` still commented, and that combination is a startup
error — **the package would not have started at all.** The address is now
commented too (the default is already 8001), and three new guards pin it.
Watched red: reintroducing the defect turns 54 green into 46 green and 8 red.

The admin API's example moves off 127.0.0.1:8001, which the media listener
now owns and which the server refuses as a collision.

The DSM application needed no code: its field list comes from
`config --json`, so the thirteen new variables already render. They needed
labels and help in both languages — `check-spk.sh` keeps the two files in
lockstep, and it passes.

Both packages build and pass the structural check; the lifecycle harness runs
54 checks green in a Linux container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
40 checks green on the VM, `on-dsm.sh` exit 0: installed, started, answered a
machine with its own merged answer, the desktop application linked and its
CGI refusing an unauthenticated request, logrotate rotating a live
descriptor, an upgrade over a hand-edited env file leaving it untouched, and
an uninstall leaving the share alone.

The four new assertions are the on-machine evidence for boot media: the
`media` and `boot` folders are created by the start script and writable by
the package user. Confirmed by hand first — `drwxrwxrwx+ rescriptum` on the
real volume — and now asserted, so it is tested rather than observed once.

`check-spk.sh` also asserts the shipped `.sc` template registers the media
port. That guard was seen red without being broken on purpose: it fails the
older packages still in `dist/`, which is exactly what it is for.

DSM's own TFTP server was verified to exist on the machine rather than
assumed from documentation — `/usr/bin/opentftp`, with `rc.sysv/tftp.sh`
beside it. That is what the env file and the guide now point operators at,
and it is the load-bearing claim in both.

CLAUDE.md records the fourth place DSM pressed back: a privileged port,
answered by not having one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
… creates

Two things the DSM settings panel showed, and the second was a trap I had
built.

**The empty fields are by design** — a variable whose default is "off" has
nothing to show — but two of them should not have been empty. The package
creates `media/` and `boot/` in its share and then left the settings blank,
so an operator faced a field with no hint of what to type for a folder that
already existed. Both are now named in the env file.

**Naming the boot folder would have killed the package.** It implied a TFTP
server on port 69, which a DSM package cannot bind — and a failed bind is a
`return ExitCode::FAILURE`, so the whole server dies, not just TFTP. The help
text under that very field invited the operator to fill it in. Measured on
the machine rather than assumed: a non-root uid on DSM 7.2.2 gets
`[Errno 13] Permission denied` on UDP 69. Neither macOS nor a Docker
container reproduces it — Docker grants NET_BIND_SERVICE by default — which
is exactly why it had to be the real thing.

So `RESCRIPTUM_TFTP_ADDR` gains `off`, spelled the way `RESCRIPTUM_LOG=off`
already is. **Off is a value, not an absence**: the loaders stay served over
HTTP at `/boot/…` and stay checked by `boot check`; only the listener goes,
and something else hands the file over. The default has not moved — a plain
Linux host that names a boot directory still gets the TFTP server the plan
calls core.

Verified on DSM 7.2.2, from the machine's own log:

    media listening on 0.0.0.0:8001 — serving …/media
    tftp is off — /volume1/rescriptum/boot is still served over HTTP at /boot/…
    rescriptum 0.2.0 listening on 0.0.0.0:8000

40 checks green on the VM, 55 in the lifecycle harness, and the new guard
watched red: commenting the `off` line back out turns 55 green into 54 and 1.

Also corrects two passages in the netboot guide that had gone stale — the
loaders are built now, and `BOOT_DIR` is no longer simply "the off switch for
TFTP".

540 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
…host has

The settings panel showed an empty field for RESCRIPTUM_PUBLIC_HOST while the
server derived an address at startup, so the one place an operator looks was
showing something other than what the server does. `settings()` now fills the
default by deriving it, the way it already does the CPU count — the panel
renders a default as the field's value, so the derived address appears without
the UI knowing anything new.

Derivation gains a second source. The routing table answers on a host with a
default route; an isolated provisioning segment has none, and there the
interface list still answers when the host has exactly one address. With
several it does not guess.

The startup line now names the alternatives instead of warning generically:
one address is stated plainly, and several are all listed, which is what makes
"is this the address my machines reach" answerable from the log rather than by
going to look at the host.

+1,784 bytes on armv7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The on-machine run never touched the panel's backend, which is how a blank
RESCRIPTUM_PUBLIC_HOST field reached a DSM install: nothing between the Rust
tests and Package Center looked at what `api.cgi?action=config` would return.

The check runs `rescriptum-cli config --json` as the package user — the exact
command the CGI shells out to — and asserts the address is both present and one
the machine's interfaces actually carry. Asking the CGI over HTTP would need a
DSM session and would prove the same values through a login.

Watched failing on the VM with the derivation removed: 40 passed, 1 failed,
reporting the blank. With it restored, 42 pass.

The Synology page and the reference table described the old startup warning;
both now describe what the panel shows and what the log names beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
The class of bug, not the instance: the panel renders a variable's default as
the field's value, so a default that exists only where the server consumes it
shows as a blank while the server runs on something it derived. Two entries in
KNOWN are special-cased for this and nothing in the type system says a third
would need it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012TSRpBJgtVZg1T9nPQWbbL
Port 69 is the only privileged port in the design, so it is the only bind
that can fail for something nobody configured. Measured on a DSM 7.2.2
machine: the capability comes from a `setcap` outside the package, an
upgrade replaces the binary and silently drops it, and with a fatal bind
the whole package then goes to `start_failed` — taking answers and media
with it and failing every install in flight to report that a second port
could not be opened.

Answers are the product; TFTP hands over one file and something else can.
So it degrades instead, loudly, in three places at once: a startup warning
that names what still works, a non-zero `boot check`, and the settings
panel. The failure mode being refused is the silent one, not the degraded
one.

`boot check` gained a real probe, and writing the test is what found out
why it needed one: **binding is not a health check**. A bind that succeeds
means nothing is listening — the degraded state, not the healthy one — and
a bind that fails cannot tell this server apart from another daemon
squatting the port, because both are `AddrInUse`. So it sends an actual
read request and reports what a machine would get.

Both halves watched red: making the bind fatal again kills the server
before it answers, and dropping the failure count makes `boot check` call
it fine. The first version of the second assertion passed for the wrong
reason — three missing loaders were already failing the command — so the
fixture now writes every loader the table names and a control run with
TFTP off proves the directory is otherwise clean.
A previous session met a packaging constraint and traded away the
product's first principle: it wrote `RESCRIPTUM_TFTP_ADDR=off` into the
package and pointed operators at Synology's own TFTP server. rescriptum
*is* the TFTP server — an appliance that needs somebody else's is not an
appliance — and the constraint turns out not to be one.

Measured on a DSM 7.2.2 machine, all four routes to port 69. `run-as:
root` in conf/privilege is refused with synopkg error 319, `invalid
package privilege content`, both in `defaults` and as a per-action
ctrl-script, even though Synology's own packages use exactly that shape. A
`security.capability` xattr baked into package.tgz installs — the pax
inner format is accepted — but Package Center strips it during extraction.
`setcap cap_net_bind_service=+ep` on the installed binary works, and the
package then binds udp/69 as its own unprivileged user alongside 8000 and
8001. `net.ipv4.ip_unprivileged_port_start` does not exist on that kernel.

So the env file no longer sets the variable at all: the default is
0.0.0.0:69, which is what the generated DHCP snippet and every loader we
ship already expect. What it does instead is say what the one root command
is, and that an upgrade replaces the binary and drops the capability with
it — hence the Task Scheduler boot-up task. `off` stays available as a
deployment workaround for an operator who wants it, which is all it ever
should have been.

69/udp joins the firewall entry, on `dst.ports` with a protocol suffix
like the tcp entries already use rather than an invented `dst.udp.ports`
key. And the settings panel grew a `tftp:` line, because a failed bind no
longer stops the server: without it the only trace would be a startup
warning that scrolled past hours ago.

lifecycle-test.sh 55 → 58, and the three new checks were each watched red:
reintroducing the `off` line, deleting the panel's report, and making it
claim to be serving with nothing bound.
The seven files that carried `RESCRIPTUM_TFTP_ADDR=off` as though it were
the design, corrected. `off` keeps its place as a deployment workaround
for an operator who wants one — it is never how anything here ships.

The Synology guide's "TFTP: use DSM's, not ours" becomes "TFTP needs one
root command": the `setcap` line, the Task Scheduler boot-up task that
survives an upgrade, and what `boot check` prints while the capability is
missing. The netboot guide and the configuration reference move a failed
TFTP bind out of the fatal table and into the warnings one, with the
reason rather than the rule.

And the four DSM routes to port 69 are recorded as traps with their error
codes, in both languages — `run-as: root` refused with synopkg 319 in both
shapes, the xattr stripped by Package Center, `setcap` working,
`ip_unprivileged_port_start` absent. The claim they replace had sat in
CLAUDE.md unmeasured; it was true by luck. Two more traps beside them: a
file capability does not survive an upgrade, and binding is not a health
check — a bind that succeeds means nothing is listening.

Both new anchors verified against the built HTML, since `notabene lint`
checks routes and not anchors. A truncated sentence in the French traps
page ("`check-spk.sh` vérifie les") is finished while passing.
The largest remaining gap in Phase 2, and the blocking one: a deployment
that installed the package got a TFTP server with nothing to hand out.
`boot check` said so on a fresh install — three MISSING loaders — and
every machine the generated DHCP snippet sent there would ask for a file,
get nothing, and stop. "One binary boots an arbitrary machine" was true
only for whoever built iPXE themselves.

`release.yml` gains a `loaders` job: build from the pinned commit, ask
`boot check` whether the directory satisfies the table the server hands
out from, and attach `rescriptum-boot-assets-<version>.tar.gz`. It is its
own download and belongs to no binary archive or `.spk` — iPXE is GPLv2,
separate files served alongside is mere aggregation, and `packaging/ipxe/`
is the written offer that travels with it.

Run end to end in a bookworm container before being written down: all
eight loaders, `ipxe.iso` and `ipxe.usb`, `boot check` green, 3.1 MB
packed. Two things that run found:

- **The ISO target does not need an ISO writer, it needs `isolinux.bin`.**
  With xorriso installed it still failed with `util/genfsimg: could not
  find isolinux.bin`, and the note said "needs xorriso or mkisofs" — which
  sends you after the wrong package. Debian's is `isolinux`; adding it
  makes the bootable ISO build, so IPMI virtual media comes for free.
- **`boot check` now probes the TFTP port**, so the CI and release steps
  that ask it about a *directory* pin `RESCRIPTUM_TFTP_ADDR=off`. On a
  runner where port 69 is neither bound nor bindable it would otherwise
  report a real problem and fail the wrong job over it. The rig is what
  proves a loader actually gets handed over.

The bundle carries a README saying where it goes, because the loaders are
inert until `RESCRIPTUM_BOOT_DIR` names them. Both guides now point at the
download first and keep building it yourself as the alternative.
`boot check`'s TFTP probe had a test for the port being dead and none for
it working — so it could have reported every port as a problem and still
looked correct, because only the failing case is one anybody notices.

Two outcomes pinned: `Served`, against a real server over real UDP, ending
in `boot check` exiting zero and saying "handed over"; and `Refused`, a
server that is there without the file asked for, which `boot check` itself
never produces because it only ever asks for a loader on disk. Telling
that apart from silence is what separates a misconfigured root from
nothing running at all.

Watched red by making a DATA reply read as silence.
`on-dsm.sh` printed the firewall line and never asserted anything about
it, and nothing anywhere covered the route to port 69 — which is the one
thing the whole `off` reversal depends on. 42 → 47 checks, run on the DSM
7.2.2 VM:

- **`dst.ports="8000/tcp 8001/tcp 69/udp"` survives into
  `/usr/local/etc/services.d/rescriptum.sc` verbatim.** The protocol
  suffix was inferred from the form the tcp entries already use rather
  than from documentation, so it needed measuring; it holds.
- **Without the capability the package still answers.** That is the
  non-fatal decision validated on the machine instead of at a desk, and
  the log carries its `cannot bind TFTP` line rather than going quiet.
- **`setcap cap_net_bind_service=+ep` plus a restart binds `udp/69`** as
  the unprivileged package process, and answers are unaffected by gaining
  it — `netstat` shows `0.0.0.0:69 … rescriptum`.

The stale comment above it, saying the boot folder is what DSM's own TFTP
server gets pointed at, goes with them.
The testing page had drifted badly: 333 tests against a real 545, a
per-suite table missing `tests/media.rs` and `tests/tftp.rs` entirely, and
stale figures in half its rows. Counted per file rather than remembered.

Three sections added for what the table now names — why a TFTP transfer
can only be tested as a conversation, why the media suite ends every abuse
case by proving answers still work, and that the boot chain lives in a rig
`cargo test` does not run. The harness table gains the route to port 69,
which is what `on-dsm.sh` now owns, and the go-red paragraph gains today's
numbers with the three most recent checks and how each was watched failing.

Both new in-page anchors verified against the built HTML — `notabene lint`
checks routes, not anchors.
wamxx and others added 28 commits August 27, 2026 21:41
"DSM 7 does not let an unsigned package run as root" was measured but not
explained. Reading `libsynopkg.so.1`'s strings on the 7.2.2 machine gives
the rule verbatim: a package failing `verifyPackageSignature` may not have
a `ctrl-script` or `executable` section, must have `defaults.run-as` =
`package`, may not join the admin group, and — `non-synology package
should not use privilege migration`.

Which explains what looked like a contradiction: FileStation,
StorageManager, QuickConnect and SecureSignIn all carry `"ctrl-script":
[{"action":"start","run-as":"root"}]` in their own conf/privilege, the
exact shape refused to us with error 319. The shape is legal; the
signature is what makes it legal for them.

The line worth knowing is `tool capabilities should not exist`. DSM's
privilege format has a native `capabilities` field —
`SYNOPackageTool::Privilege::ChangeCapabilities` is in the same library —
so a signed package declares `cap_net_bind_service` in conf/privilege and
never needs `setcap` at all. The mechanism we want exists and is closed to
us, which settles that the manual step is the price of not being signed
rather than something better packaging could remove.

Marked explicitly as not measured: whether a third-party publisher's
signature would pass. The string says *non-synology*, not *untrusted*.
The library string said "non-synology package" and I recorded that a
third-party publisher's signature was an open question. Synology's
developer guide closes it: "If you are developing a package with root
privilege, you are not able to install that package unless it is signed by
synology." SynoCommunity hit the same wall (spksrc#4170, #4215).

Two things worth having beside it. The `capabilities` field is
*documented*, not just a symbol in a binary —
`"capabilities": "cap_chown,cap_net_raw"` on a tool entry since
7.0-40656 — so a signed package would declare `cap_net_bind_service` and
the manual step would vanish entirely.

And there is exactly one documented bypass, a development token: generate
debug.dat from Support Center, send it to Synology, drop the signed token
at /var/packages/syno_dev_token. It is valid only on the NAS that produced
the debug.dat, so shipping that way would mean every user doing a round
trip with Synology before installing. One local `setcap` is strictly
better for them.

So the manual step is settled rather than provisional, and this records
why, with the sources.
The procedure in packaging/dsm/vm/README.md predated both the loaders
being inside the package and the setcap step, so following it would have
produced a package that fails its own structural check and then a TFTP
server that never binds.

Written by running it: build the loaders in a container, cross-compile
armv7, wrap, check, install through Package Center, the one root command,
and how to tell from the NAS whether a loader is actually handed over —
`boot check`'s `handed over` line, which is a real TFTP read answered with
real data rather than a port that merely opened. Plus the Task Scheduler
task, because a file capability does not survive an upgrade, and the
warning that `on-dsm.sh` uninstalls at the end so it goes before a real
setup rather than after.

`setcap` targets `readlink -f /var/packages/rescriptum/target` rather than
a literal /volume1 path — `target` is a symlink into @appstore on
whichever volume the package landed on, and readlink -f was checked on the
machine.

The build guide gains the loaders prerequisite it now has, and loses a
stale row: the armv7 package has come from the glibc target since
Synology's 3.10 kernels broke musl's time64 fallback, and that table still
said musleabihf.
Found on the DS416j, by installing the real package: `boot check` answered
"boot assets are off" on a NAS where the boot folder existed, the loaders
were in it and 69/udp was registered with the firewall. The env file was
the one an older version wrote — four settings, no RESCRIPTUM_BOOT_DIR.

The cause is a rule that is right on its own terms: the live env file is
written only when absent, so an upgrade never replaces somebody's port and
tokens with defaults. But taken alone it makes every new feature invisible
to every installation that predates it, and since `etc/` survives an
uninstall, removing and reinstalling does not fix it either. The
`.env.example` is rewritten every time and is supposed to be the discovery
path; nothing makes anybody read it.

So `postinst` now appends keys the live file has **never heard of** and
touches nothing that is present. **A commented-out key counts as
present** — that is the whole safety property, and it gives the operator a
way to say no: deleting a line means "never heard of it" and gets it back,
commenting it out means no and is respected.

It also restates mode 600 after writing, because the file holds an admin
token and a file arriving from an older version may never have been 0600.

Six checks in lifecycle-test.sh (62 → 68), and the harness earned its keep
twice over: it caught the mode being left at 644, and it made me rewrite a
"the original content survives" assertion that was a tautology comparing a
string with itself. Two defects watched red — removing the top-up
reproduces the DS416j bug exactly, and treating a commented key as absent
overrides the operator's "no".
The four routes to port 69 were measured on a 7.2.2 VM, and that left one
thing genuinely open: the VM is x86_64 with /volume1 on btrfs mounted
`nodev` but not `nosuid`, and a `nosuid` mount makes the kernel ignore
file capabilities outright — which would have closed the last open route
on the one machine this project exists for.

It holds. On the DS416j (ARMv7, armada38x) the capability survives, the
package binds udp/69 as its unprivileged user, and `boot check` answers
`0.0.0.0:69 handed over ipxe-arm64.efi` — a real read request answered
with real data. Which also puts the armv7 glibc binary on the machine for
the first time since this branch rebuilt it.

What is still not proven there: the panel's `tftp:` row needs a browser,
and no real machine has PXE-booted from this NAS yet. The rig proves the
chain in QEMU, which is not the same claim.
…ndexes

Asked for: a catalogue of ISOs to pick from instead of hunting a URL and a
digest by hand. The obvious shape — a table of URLs with digests baked in
— would have been wrong the day it shipped. Proxmox prunes old ISOs from
its CDN, Debian and Ubuntu publish point releases every few weeks, and
this project *requires* a digest for a URL because that decision is what
every machine ends up installing. A baked-in table would need re-cutting
on somebody else's schedule, forever, and would serve 404s in between.

So nothing about a specific image is stored. Each entry names **the
checksum index the vendor already publishes beside its own images**, and
the names and digests are read from it when somebody asks. The list is
current because it is the vendor's, and the digest rule is satisfied by
the vendor's own file — which is what the documentation already tells
people to do by hand.

    rescriptum media sources                 # the catalogues
    rescriptum media sources proxmox-ve      # what one offers, right now
    rescriptum media add --from proxmox-ve proxmox-ve_9.2-1.iso

Said plainly in the module and not glossed: taking the digest from the
same host as the image is **not** a signature check. Over HTTPS it
authenticates the vendor's domain and catches a truncated download, a
corrupt mirror and a file that changed underneath — most of what actually
goes wrong — and nothing more. `--sha256` with a digest obtained out of
band stays the stronger path.

Five sources, and **every index URL was fetched before being written
down**, plus one image URL derived from each: a table of plausible 404s
would be worse than no table. Two index formats, because that is all that
exists in the wild — coreutils `<digest>  <name>` (Proxmox, Debian,
Ubuntu, with Ubuntu's `*` binary marker) and BSD tag `SHA256 (n) = d`
(AlmaLinux, Rocky, inside a PGP-clearsigned document whose wrapper is
skipped rather than refused).

Sorting is natural rather than lexicographic, and that is not cosmetic:
the first row is the one that gets clicked, and plain string order puts
9.10 behind 9.9 — offering a rack an older installer than it asked for.

Verified against the live indexes, and end to end: the digest resolved for
proxmox-ve_9.2-1.iso is 4e88fe416df9b527…, character for character what
Proxmox publishes, and the fetch starts against it.

+31,520 bytes on armv7 (2,709,840 → 2,741,360), recorded rather than
quietly spent — the `boot` budget was already over.
Asked for: do this from the application rather than over SSH, and offer
the usual ISOs to click rather than hunting a URL and a digest.

A fourth tab — what is held, a catalogue to pick from, and a URL field for
what the catalogue does not offer. The manual path is deliberately in
front of people rather than documented as a command-line escape hatch: a
digest obtained out of band is stronger evidence than one read from the
same host as the image, so it is the better of the two and should look it.

**The panel grows no rule of its own.** It starts a download by calling
`media add`, which is where the digest rules live and are tested, and it
follows one by watching the `.part` file that command already writes —
`media add` renames it only once the digest checks out, so the partial
file's size *is* the progress and its disappearance *is* the completion.
Nothing about progress had to be invented for the browser, and nothing
here can disagree with what the CLI actually did.

A CGI cannot hold a request open for 1.5 GB, so the fetch is backgrounded.
Three details, each a trap this package has already paid for once:
`</dev/null`, because a background child inheriting the CGI's stdin holds
the request open forever — which is how `su` hung this script; `setsid`,
or the web server reaps the download a second in; and every value reaching
the CLI as one argument, so a URL with a semicolon in it is data.

Ten checks in lifecycle-test.sh (68 → 78), all guards: a download refusing
to be a GET, refusing a POST without X-Rescriptum, a URL with no digest,
a digest that is not one, a scheme nothing can fetch, and an image name
that is a path. Removing the digest guard was watched red.

And two on the machine (50 → 52), because the catalogue is the one part of
this package that talks to the internet and whether that works is a
property of the NAS — its resolver, its uplink, its curl. The DSM VM reads
Proxmox's index and offers proxmox-ve_9.2-1.iso.
The `prepare` action was wired into api.cgi and nothing in the panel
called it — so the tab could fetch an image and then leave the one step
that makes it an unattended install available only over SSH. Caught by
being asked whether it was there.

It sits directly under the listing it acts on, because it is the step
nobody guesses at. Proxmox only, and **the CLI is what refuses the rest**,
with its own sentence shown verbatim: every other family takes its
answer's URL on the kernel command line, where `media ipxe` already puts
it, so injecting a file they never read would be a no-op that looks like a
step. Deciding that in the panel too would be a second implementation to
keep honest.

Two checks (78 → 80): a well-formed id for an image that is not there has
to reach the CLI and report its refusal, and must not come back claiming
exit 0. The traversal guard was already covered.
From the maintainer, installing a real machine: after an install the node
reboots straight back into PXE, and "no answer for this machine" ought to
mean "get out of the way" rather than "here is a menu". They are right,
and it is the better polarity.

`RESCRIPTUM_BOOT_UNCLAIMED=local` makes the bootstrap fall through to
`exit 0` instead of the menu — control back to the firmware, next boot
device, works on BIOS and UEFI alike (`sanboot --drive 0x80` is BIOS-only).

**The two settings are opposite readings of what an answer file is for.**
With the menu — still the default, and the project's thesis — a file
claiming a machine says *leave this one alone*, because without one it
lands somewhere a human could click. With `local` a file says *install
this one* and its absence is the safe state, which is what a fleet already
in production needs and the reading that scales: the machines you want to
reinstall are always fewer than the ones you do not.

The payoff is that netboot stays first in the BIOS order forever, and
reinstalling becomes "add a file, reboot" — no console, no hands on the
hardware. Removing the file is what stops it happening twice.

Watched red by pinning the switch to false. `every_variable_is_described_
exactly_once` also earned its keep immediately: it caught the new key
being in `KNOWN` but not in `envfile::KNOWN_KEYS`, which is the exact
split it exists to catch — described but never read.

Also corrects what I told the maintainer an hour ago. I warned about a
reinstall loop with only a .toml in place; there is none. The menu's first
entry is the local disk and its timeout falls through to it, which the
rig already asserts. The loop needs an .ipxe answer that boots the
installer, and this setting is what makes that safe.
They carry root credentials. A scratch directory beside the repo is where
they belong, and it must never be a directory git can be talked into
tracking.
The loop, closed properly. A machine claimed by an `.ipxe` answer
installs, reboots, is claimed again, and installs again — wiping its disk
each time. Doing the disarm by hand works and is what shipped first, but
it is a race: the machine reboots the moment the installer finishes.

Verified against Proxmox's own documentation before a line was written,
because the block in our examples was written by this project and could
have been fiction. It is not: `[post-installation-webhook]` fires after a
successful install and **before the reboot**, POSTs JSON, and that body
carries the network interfaces — MACs included. Which means it is the same
shape as the request that asked for the answer, and `Facts` reads it with
no new parsing. The `auth-token` goes in the body as a top-level `token`,
not as a bearer — unlike the answer token.

That last detail decides the routing: the route runs **before** the answer
token's guard, because otherwise setting an answer token would 401 every
webhook. And the path is reserved **only when the token is configured**,
so a deployment that never uses this keeps "POST on any path is an answer
request" whole — which is what lets a URL be baked into an ISO.

Narrow by construction, because this is the one path where something
arriving over the network changes the answer set:

- machine documents only, never a group — one machine finishing must not
  disarm its neighbours, so the lookup never consults groups rather than
  filtering them out afterwards;
- format `ipxe` only — the `.toml` is the record of how the machine was
  built and the installer is what reads it;
- moved under an `installed-` prefix, never deleted, so nothing it does is
  irreversible.

Arriving twice is a success: a webhook may be retried, and a machine
installed from the menu was never claimed. A disarm that *fails* logs
`still armed`, because otherwise the consequence is silent.

Both guards watched red: moving the route after the bearer guard makes
every webhook 401, and dropping the format filter takes the machine's
.toml with it. The group test asserts the group *does* claim the machine
before asserting it survives — without that it would pass for a fixture
that was never loaded.

561 tests.
Asked, correctly: this was built against Proxmox's webhook — what about
the others. Two things were tangled and needed separating.

**The claim is not Proxmox-specific.** It is an `.ipxe` document, which is
about the loader rather than the operating system, so every family is
claimed the same way and needs the same disarm.

**The report back is where they differ.** Proxmox has a webhook; nobody
else does. Debian has `late_command`, Ubuntu `late-commands`, RHEL and its
rebuilds `%post`, SUSE a chroot script — all of them can run one `curl`,
and none of them will compose Proxmox's JSON body. The endpoint made that
harder than it had to be, so it now also takes the identity from the query
string and the credential from an ordinary bearer header:

    curl -fsS -X POST -H "Authorization: Bearer nas:s3cr3t" \
      "http://server:8000/installed?mac=$(cat /sys/class/net/*/address|head -1)"

No body at all. Same secret either way, same constant-time comparison —
the body form exists because it is what Proxmox sends and Proxmox cannot
be told to send a header.

Watched red by reverting to a body-only identity, which leaves the
kickstart path disarming nothing.

The guide names where that line goes per family, and says plainly that the
example's `head -1` is right on a one-NIC machine and wrong on a four-NIC
one — the wrong MAC disarms the wrong machine.

562 tests.
…nt one

From the maintainer, setting it up by hand: the token should be generated
at install if it is not set. Right — the webhook only works when the
string in a machine's answer and the one the server checks are identical,
and two blank fields that must agree is a thing people get wrong once and
then debug as "the machine reinstalls itself". Generated here, the server
half is already correct and the answer document only has to copy it.

`od -An -tx1 -N16 /dev/urandom` rather than `openssl rand` or base64: all
three exist on the 7.2.2 machine, but od is POSIX and the most likely to
be on the oldest thing this package may install on. 128 bits of hex, which
also survives being pasted into a TOML string without quoting questions.

Two guards worth naming. **An empty token would be an endpoint anybody can
call**, so if the generator produces nothing the setting is written
commented out and the feature stays off — nothing beats something there.
And **a token already in the file is never replaced**: the top-up only
adds keys the file has never heard of, so an upgrade cannot silently
orphan every answer document carrying the old one.

Four checks in lifecycle-test.sh (80 → 84): generated, long enough, hex,
and an existing secret left alone. Watched red by stubbing the generator
to echo nothing — which fails on emptiness and on length, exactly where it
should.

The settings panel gains the label and the help in both languages, so it
is not a bare key next to a value somebody is afraid to touch.
A machine on the maintainer's network fetched a loader and did nothing.
The log said:

    tftp: ipxe-x86_64.efi 1164800 bytes blksize=1468

which reads as success and was not: **that line was written before the
first byte went out.** Every failure path after it returned silently, so a
transfer that stalled at block one and a transfer that completed produced
identical logs — and the one diagnostic a boot server has said the
opposite of the truth.

Reported at the end now, with the outcome: `sent <file> N bytes`, or
`FAILED after N of M bytes` at status 500 so `RESCRIPTUM_LOG=problems`
keeps it, naming the knob that fixes the commonest cause.

That knob is new, and the reason it exists is arithmetic: **1468 fills a
1500-byte path exactly** — 1468 payload, 4 TFTP, 8 UDP, 20 IP. It is what
iPXE asks for and what leaves nothing over. One VLAN tag makes the frame
1504, and a PXE ROM meeting that generally stops with no message at all.
`RESCRIPTUM_TFTP_BLKSIZE` caps what we agree to; 1400 leaves room for a
tag and most tunnels, 512 always works.

The default stays at 1468 rather than being lowered on a hunch: dropping
it for everybody costs every deployment throughput to fix a minority's
network, and the failure it causes is now loud enough to find. Whether it
should move is for a measurement, which this change is what makes
possible.

Two tests, both watched red: an abandoned transfer that leaves no trace
when the line goes back to the top, and a cap that a client's larger
request walks straight past.

564 tests.
`boot check`'s probe wants one block and no more, and it walked away
without acknowledging — which, now that an abandoned transfer is reported,
made the server retry for four seconds and log a FAILED line. So running
the health check wrote a scary alarm into the one file an operator reads
to find a real one. Seen immediately, on a real NAS, in the middle of
debugging something else.

The probe says goodbye with an ERROR packet, which is how TFTP says stop,
and the server now tells a deliberate cancel from a client that vanished:
`stopped … cancelled by the client` at 200, against `FAILED …` at 500. The
distinction is the point — `RESCRIPTUM_LOG=problems` should keep the one
that means a machine did not boot, and nothing else.

Watched red by removing the goodbye.
Reported from a real NAS: TFTP stopped working, and there was nothing in
the log at all. Both halves were mine.

**The cause was the cap I had just suggested.** `RESCRIPTUM_TFTP_BLKSIZE=512`
makes the server answer a client asking for 1468 with an OACK naming 512.
RFC 2348 allows that and says the client should accept it; a PXE ROM often
just stops instead. So a machine that had been booting fine stopped, and
the advice that broke it was mine.

**The reason it was invisible was also mine.** Moving the transfer's only
log line to the end — so that a stall could not read as a success — meant
a request that never got going logged nothing whatsoever, which is exactly
the case somebody is trying to diagnose. The machine said it was
downloading, the server said nothing, and both were telling the truth.

Two lines now, not one. One when a request arrives, one for what it got.
`RESCRIPTUM_LOG=problems` keeps only the second, which is the right split
— but `all` is the default, and at `all` a silent request is a bug.

And the option handshake no longer returns without a word: it says which
block size the client wanted, which one it was offered, and names the
setting that caused the disagreement.

Both watched red.
The message printed "the client asked for blksize=1468 and would not take
1468" and pointed at RESCRIPTUM_TFTP_BLKSIZE — a setting that, when both
numbers are equal, provably did not participate. It sent the maintainer
and me after the wrong thing for an hour while the real cause went
unexamined.

When the server granted exactly what was asked and the client still walked
away, the block size is ruled out and the message says what this actually
looks like instead: the reply comes from a fresh port, which is how TFTP
works, so a firewall or a NAT between the two lets the request in, lets the
answer out, and eats the acknowledgement.

A message that suggests a cause it has already ruled out is worse than one
that suggests none.
…allow them

Asked, after a machine that would not boot: can that port not be
controlled. It can, and not being able to was the bug.

A TFTP transfer leaves port 69 immediately — the server answers from a
fresh port and the client acknowledges to *that*. So a firewall told to
allow 69 lets the request in, lets the answer out, and drops the
acknowledgement; the transfer dies at the handshake and the client looks
like it lost interest. It is the hardest failure in this protocol to read,
and every serious TFTP server therefore lets the range be pinned. Ours did
not.

`RESCRIPTUM_TFTP_PORT_RANGE=first-last` does it. Unset keeps today's
behaviour, which is right on a host with no firewall in the way.

**The DSM package pins 30000-30063 and registers it with the firewall**,
because on a NAS the unpinned behaviour is not a default, it is a trap.
The range was chosen against a real DSM rather than picked: below the
kernel's own ephemeral range (32768–60999, so nothing in it can be handed
to something else), clear of every UDP port a stock install uses (68, 123,
137, 138, 161, 323, 1900, 3702, 5353, 9997–9999), claimed by no Synology
`.sc`, and all 64 bind-tested. Sixty-four is MAX_TRANSFERS, so the range
cannot be what runs out first.

Two checks in lifecycle-test.sh (83 → 85): the firewall entry carries the
range, and the server is pinned to the same one — a mismatch between those
two would be exactly the silent failure this fixes.
While chasing a machine that will not boot: the data socket was
`connect`ed to the address the request came from, which makes the kernel
accept datagrams only from that exact address *and port*. A client that
acknowledges from a different source port therefore has its packets
dropped before any of this code runs — and the transfer dies looking
precisely like a firewall eating the acknowledgements, with nothing
logged, because there is nothing to log.

RFC 1350 says a client keeps its TID for the transfer, and most do. The
ones that do not are UEFI ROMs, which is exactly the population this
serves. A blind spot that cannot be told apart from a network fault is
worse than a rule that bends, so the socket now accepts from the same
address whatever port it comes from, and logs when the port moves.

Not claimed as the cause of the failure being chased — the measurement to
settle that is still a packet capture. It is a hole either way, and one
that would have made the real answer unreadable.

Watched red by reinstating the port filter. Four other tests broke on the
way and said so immediately: the refusal paths still used `send`, which
needs a connected socket.
Found on the wire, from a packet capture on the NAS, after I had wrongly
blamed Secure Boot, the block size and the firewall in turn.

The option handler echoed `windowsize` back in the OACK — the server
saying "yes, four blocks per acknowledgement" — while the transfer loop
sent one block and waited for its ACK. A client told four waits for four.
So both sides waited, and only the 700 ms retransmit broke the deadlock:
every block cost a resend and 700 ms, which makes a 1.1 MB loader take
**nine minutes**, and firmware gives up long before that. The capture
showed it exactly — block, 700 ms, identical block, then the ACK.

The comment above the constant warned that mishandling the window turns
one lost packet into a stall. The code then did it.

RFC 2347 says an option left out of the OACK is to be treated as never
requested, so declining costs a client one acknowledgement per block —
under a millisecond on a LAN — and nothing else. Implementing RFC 7440
properly is worth doing and is deliberately not this change: correctness
first, and a window is an optimisation.

Two tests, and the second had to be rebuilt to earn its keep. It first
used the ordinary test client, which acknowledges every block whatever was
negotiated — so the deadlock could not occur and it passed with the bug
reintroduced. It now honours the window it was granted, like the ROM it
stands in for: 0.5 s healthy, 63 s with the bug back.
A Lenovo ROM asks for `tsize 0 blksize 1468 windowsize 4`, refuses the
reply, and retries fifteen seconds later without `tsize` — which succeeds.
Its first attempt therefore logs a failure that resolves itself, and which
option it objected to was visible only in a packet capture. That is where
it was actually found.

The line names them now. A client that refuses one option refuses the
whole reply, so seeing `tsize=1164800 blksize=1468` beside a second
attempt that worked is the diagnosis, without tcpdump.

Deliberately not "fixing" it by declining tsize: our answer is what RFC
2349 prescribes — the file's real size for a read request — and three
wrong guesses today were enough. It costs fifteen seconds and recovers on
its own; a change here would be a fourth guess, and this makes the next
person's evidence better instead.
…ramfs

Found on the machine, in the line that ruled out three days of wrong
theories: `Freeing initrd memory: 1720768K`. The kernel unpacked 1.7 GB
without a single complaint — so zstd, gzip and every other compression
guess was beside the point. It unpacked an initramfs and found no `/init`.

In iPXE, `initrd <uri> <name>` gives the image a cpio header and lands it
as a *file* in the initramfs; `initrd <uri>` is appended raw and therefore
*is* the initramfs. We named Proxmox's real initrd `initrd.img`, so the
kernel got an initramfs holding `/initrd.img` and `/proxmox.iso`, nothing
to execute, and fell through to mounting a root filesystem —
`VFS: Unable to mount root fs on unknown-block(0,0)`.

The comment that stood there justified the name as making it "match the
`initrd=` above". That was my misreading: `initrd=` is a bootloader
directive pxelinux consumes, not a name the kernel resolves, and
upstream's own example passes the initrd with no name at all.

**The ISO keeps its name**, for the opposite reason — the installer opens
it by that name, so it has to be a file. The asymmetry between the two
lines is the thing to preserve, and the test now says so.

That test previously asserted the bug, under the name "matches what the
assistant itself emits". It matched what I had believed the assistant
emits.
Found on the machine, at the last link in the chain, after everything
before it had been made to work:

    ERROR: Failed to parse '/cdrom/auto-installer-mode.toml'
    unknown field `partition-label`, expected one of `mode`,
    `partition_label`, `http`
    Installation aborted

We wrote `partition-label` and `cert-fingerprint`. `AutoInstSettings` is
`deny_unknown_fields`, so one wrong key is a *rejected document*, not a
warning: the automated install stops and asks a human who is not coming.

The doc comment above the writer said precisely that — "one key this does
not know about is a rejected file … Only the five keys upstream defines
are ever written" — while the code beneath it wrote two that upstream does
not define. And the test that was meant to pin the names enumerated the
hyphenated ones, so it guarded the bug.

Both fixed, and the names now come from the installer's own refusal rather
than from my reading of anything. `cert_fingerprint` is pinned too: it is
the path nobody exercises, because it only appears when somebody pins a
certificate, and it would have failed the same way at the same place.
…e way

The goal this project is written against, reached on hardware: a Lenovo
vPro machine powered on and installed itself with Proxmox VE 9.2,
unattended, from a DS416j. DHCP handoff, TFTP, branded iPXE, the answer
that claimed it, kernel and initrd and a 1.6 GB image over HTTP, the
injected mode file, and the machine's own answer.toml.

Recorded here is the last one, which is not ours and cost the evening
anyway: **Intel AMT with a static address on a NIC it shares with the
host.** The installer's dhclient gives up after about eleven seconds, and
with the Management Engine holding the interface statically no offer
arrives — so the install aborts on `Network is unreachable` while
`dhclient -v eno1` from its own shell succeeds instantly afterwards. The
network is fine; the timing is not. Setting AMT to DHCP fixes it, and
nothing here can widen that window.

The guide gains a section for the class: when everything is right and the
machine still will not install, the installer's root shell is the fastest
diagnosis there is.

Worth stating plainly in the plan: seven defects stood between "every
harness green" and a machine that installs, and **not one of them was
reachable by the rig**, which boots BIOS under TCG.
Numbers counted rather than remembered: 571 tests (545 → 571 over the
day's fixes), and the package harnesses at 28 / 85 / 52 where the page
still said 26 / 58 / 47. The per-suite table gains `src/installed.rs` and
the two suites that grew most — `tests/tftp.rs` 21 → 30, which is where
most of today's defects were caught, and `tests/integration.rs` 45 → 48.

The machine harness's row says what it now owns: the only route to port
69, and whether the NAS can reach a vendor's image index — the one part of
this package that talks to the internet.

And the plan records the half of the milestone I had not checked before
calling it unproven: the machine **disarmed itself**. The installer called
`POST /installed` before rebooting, the server renamed its `.ipxe` claim
out of the way, and it came back up on its own disk. Unattended, first
time, with nobody watching for it.
A machine's answers were files sharing a stem: `98fa9b50d810.toml` beside
`98fa9b50d810.preseed`. The stem was the identity, the extension the format,
and nothing held the two together — a machine's documents were only adjacent
by sorting. They are a directory now:

    answers/98-fa-9b-50-d8-10/proxmox.toml
                             /debian.preseed
                             /boot.ipxe

The directory name is the identity; **the extension is the format and the stem
is nothing at all**. `proxmox.toml` and `answer.toml` are one document to this
server, so the name is free for whoever opens the folder. `canonical_stem`
picks a readable one for a document nobody has named, and a write overwrites an
existing document *where it stands* — an operator's name survives.

Two documents of one format in one directory is a **reported problem**, never a
silent choice: there is no tiebreak anyone could have predicted. Sorted order
decides which answers, so it does not depend on readdir, and the loser is named.

Groups and the fallback take the same shape, so there is one rule rather than
three — `groups/rack-a/proxmox.toml`, `default/proxmox.toml`. Both names are
reserved as machine ids in **both** stores: a database that accepted `groups`
would export into a directory that cannot hold it, and `export` has to stay a
way out.

A servable document left flat is reported with its destination and **not
served**. Half-reading the old layout would mean a machine whose answer moved
silently between two files, which is the failure this server exists to prevent.
`rescriptum migrate` shows what it would move and `--apply` moves it; one taken
destination aborts the whole run rather than leaving a half-migrated directory.

Disarming stays a sibling directory (`installed-<id>/`) rather than a prefixed
file inside the machine's own. The machine's directory keeps meaning "this
machine's configuration", no new exclusion rule is needed — the directory name
identifies nothing — and `installed.rs` did not change.

Measured cost, on an M1 Pro at 2,000 machines: a full reload goes from 28.6 ms
to 63.5 ms, a `readdir` per identity on top of the file already opened. It is
syscalls, not allocation — removing the allocations moved nothing. Amortised
over a second of requests by the listing cache, and end-to-end throughput did
not move measurably. The mtime also sees less: a document added *inside* a
machine's directory is one level below what is watched, so the backstop catches
it rather than the version token. Tests pin both halves.

BREAKING CHANGE: answer documents must live in a directory named after their
identity. Documents left at the top of the answers directory are reported and
no longer served; run `rescriptum migrate --apply` to move them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
Two failures the local toolchain could not see, both older than this branch.

**Clippy 1.98 gained five lints** this code trips, and the pinned local
toolchain was 1.93 — so `cargo clippy` was green here and red in CI, which is
the worst arrangement. All five are mechanical: an `.into_iter()` an array does
not need, a `sort_by` that is a `sort_by_key` over `Reverse`, two `loop` +
`let … else break` that are `while let`, and a zero check that is `checked_div`.
None changes behaviour; the descending version sort keeps its `Reverse` so
`9.10` still outranks `9.9`.

**`--no-default-features` had stopped compiling**, and it is a CI gate. The
`RESCRIPTUM_TFTP_BLKSIZE` accessor added with the block-size cap reaches for
`boot::tftp`'s constants, which are not there when the feature is off. Its only
caller is the TFTP server, behind that same feature, so the accessor moves
behind it too. All three combinations build again: neither feature, each alone,
and both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
macOS lets an unprivileged process bind UDP 69; Linux does not. `boot check`
treats an obtainable-but-silent TFTP port as a note and an unbindable one as a
problem — the right rule, and precisely what made these tests take a different
branch on each platform. They passed on the development machine and failed on
the first CI run, on a verdict that had nothing to do with what they assert.

Both tests that set `RESCRIPTUM_BOOT_DIR` now set `RESCRIPTUM_TFTP_ADDR=off`,
so each measures only its subject: the loader table for one, the media port
warning for the other. The unbindable port keeps its coverage in
`tests/tftp.rs`, on a high port, where it is the subject rather than the noise.

Both traps recorded — this one, and the more general one behind it: a branch
that accumulates 57 commits before its first push has never met the CI, and
finds out about the toolchain gap and the platform gap at the same time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
@wamxx
wamxx merged commit 0272f39 into develop Aug 29, 2026
6 checks passed
wamxx added a commit that referenced this pull request Aug 30, 2026
Since v0.2.0: the whole boot chain and a directory per identity (#2), a TOML
configuration file (#3), and four defects that were live in shipped code — a
rollback blind over the file store, a write that could widen the permissions of
a document holding a root password hash, a log target that stopped every CLI
command for anyone but the service user, and a group `.ipxe` that arms
machines nothing can disarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQnAk5r4fLKcWuAwUY6Pa6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant