What the source backend still cannot build, and which unit proves it¶
D-014 says a backend is justified by measurement and every backend names the unit requiring it. This applies the same rule one level down, to features of the source and git backends: each gap below is named by a real unit that hits it, and each was found by attempting the build rather than by reading the code.
Measured 2026-08-28 in Debian 13 containers unless stated otherwise.
Closed this round¶
| Gap | Unit that proved it | Resolution |
|---|---|---|
| Build system with no install rule | coil64, cwwav, ardopcf |
provides_install_target: false installs the declared binaries instead. |
| Qt6 projects | qlog |
qmake6 is its own build system. Debian 13 with only qt6-base-dev has no /usr/bin/qmake. |
| Compiler-flag-fragile builds | cwwav, ardopcf, glfer |
compiler_flags — already present, now exercised by three units. |
Open¶
1. A custom make target — linrad — CLOSED 2026-08-30¶
build_args now reach make on the autotools path too; linrad's
make xlinrad64 built and installed on the Parrot VM.
The autotools path runs make with no target. Linrad's build is
./configure followed by make xlinrad64; a bare make prints usage and
stops. Nothing in the schema can say which target to build for an autotools
project. (build_args exists on SourceInstall but is only passed by the
make build system, which has no configure step.)
2. In-tree patching — linrad, and nearly Fl_MoxGen — CLOSED 2026-08-30¶
Declared unified diffs stage and apply with patch(1) before configure; linrad ships with its six baked-in -Werror occurrences patched out, VM-built. A patch with only a description is still refused by name.
patches is in the schema and is a measured zero: the backend refuses it
by name rather than implementing it speculatively. Linrad ends that.
Its Makefile hardcodes -Werror inside its own FLAGS variable, and on
Debian 13 the build stops at hid.c:1340 with
error: 'strncpy' specified bound depends on the length of the source argument
[-Werror=stringop-truncation]. Passing CFLAGS does not reach it — measured,
make xlinrad64 CFLAGS="-Wno-stringop-truncation" still fails at the same
line — because the Makefile builds its own flag string and never consults
CFLAGS. Removing -Werror from the Makefile builds cleanly and produces
xlinrad64.
So our existing compiler_flags mechanism, which sets CFLAGS/CXXFLAGS in
the environment, cannot fix a build whose Makefile ignores them. That is
the first thing a patch feature would exist for. AHRL independently patches
gsmc's Makefile in place for what looks like the same reason — and that turned
out to be avoidable: at gsmc's own tag v1.2.1 the build is clean with no flags
at all, and AHRL only needs the patch because it builds an unversioned master
snapshot instead.
Fl_MoxGen is the same illness with a cure. Its rule is
@$(CC) -c -o write_pdf.o write_pdf.c, which never mentions $(CFLAGS) —
but it does mention $(CC), so build_args: ["CC=cc -Wno-implicit-function-declaration"]
reaches it. That works and is expressible today. It is worth recording as the
cheaper answer to try first: a Makefile that ignores CFLAGS may still honour
an override of whatever variable it does use, and only when nothing is
overridable does a patch become necessary. Linrad's -Werror is baked into a
literal flag string with no variable to override, which is why it is the one
that forces the feature.
3. autoreconf before configure — kalibrate-rtl (and gsmc-from-git) — CLOSED 2026-08-30¶
autoreconf: true on a source/git block runs autoreconf -fi before
configure and the planner injects autoconf/automake/libtool. kalibrate-rtl
was the first manifest to actually hit the gap — the Parrot grind failed it
at './configure is not on PATH'.
Not a gap that blocks anything today, because there is a way round it, but the shape is worth recording.
Git does not preserve timestamps. A checkout of an autotools project therefore
has configure and aclocal.m4 looking older than configure.ac, so make
enters maintainer mode and tries to regenerate them — which needs the exact
autotools version the project was released with and fails as
Makefile:357: aclocal.m4 Error 127 when it is absent. autoreconf -fi first
fixes it, and this backend does not run one.
The tarball of the same tag builds cleanly, because its timestamps are
uniform and maintainer mode never triggers. So gsmc is a source manifest
pointing at the tag's archive rather than a git manifest pointing at the
tag — and the rule that produces is a useful one: for an autotools project,
prefer the tarball; the git route needs a bootstrap step nothing here performs.
4. Architecture-dependent configure arguments — linrad — CLOSED 2026-08-30¶
Arch-gated install blocks carry per-arch build_args; linrad's manifest
gates x86_64 and leaves ARM unmeasured rather than guessed.
Linrad's configure looks for libX11.so and libasound.so at paths that do
not match Debian's multiarch layout, and says so without failing:
Not present: libX11.so (64bit) or headers (./configure --with-x11-64)
Not present: libasound.so (64bit) (./configure --with-ALSA-64)
...
Normal End. You can now run make
configure exits 0 and the compile line then carries -DHAVE_X11=0. The
result is a Linrad with no graphical interface and no sound, built
successfully. AHRL runs a bare ./configure here and checks no exit status, so
this is what its users get, silently.
Passing --with-x11-64=/usr/lib/x86_64-linux-gnu/libX11.so and the ALSA
equivalent fixes detection — and those paths carry the architecture triplet, so
expressing them needs either per-arch install blocks or a substitution the
schema does not have.
5. No build step at all — wordsworth — CLOSED 2026-08-30¶
The binary backend's tarball format installs the two scripts; reading the code softened the 'data file' half — the word lists are embedded, the txt is sample input. VM-verified emitting practice words.
wordsworth_0.3.tar.gz contains two Perl scripts, a README, a COPYING and a
5000-word list. There is nothing to compile and no Makefile, so the make
build system — the only one that skips configure — fails immediately with
"No targets specified and no makefile found".
Two things are missing, not one. A build system that builds nothing, and a way
to install a data file: binaries puts executables in <prefix>/bin and
QSO_Words_5000.txt is not one. AHRL copies the two scripts to
/usr/local/bin and leaves the word list in the source tree.
6. Installing a tree, not a binary — mshv — CLOSED 2026-08-30¶
install_tree lands the whole build under
/usr/local/share/hammunition/<name>; MSHV built and runs from its tree via
the generated launcher, after two build-deps AHRL only had by install-order
accident (libpulse-dev, libfftw3-dev) were measured in.
provides_install_target: false copies declared binaries into the prefix. MSHV
reads settings, resources and logs from directories beside its executable in
bin/, so copying the executable alone produces a program that starts and
cannot find its own data. It needs the tree installed somewhere and a launcher
that runs it from there — which is M3's launcher-generation work, and MSHV is
one of its 14 units.
The tree is the operator's, and since 2026-09-07 it says so (D-043,
issue #38). MSHV writes its settings and logs beside bin/, so it only runs
because the tree is writable by whoever launches it; the first version got
that by accident — cp -a under root preserved the owner of the build tree
the operator had unpacked — and nothing in the log recorded it. The copy now
runs --no-preserve=ownership and the hand-over is its own logged step,
chown -R -h -- <operator>: <tree>. docs/packages/mshv.md discloses it
under "What it changes on your machine".
7. A JavaScript build — openhamclock — CLOSED 2026-09-02 (D-037)¶
This one blocks a decision that has already been made. Q-006 resolved on
2026-08-29 to default to accius/openhamclock, on the strength of its activity
and community — 456 stars, head 47d4ac14ccc4 dated 2026-08-27, MIT by its
LICENSE file even though GitHub's licence field reports NOASSERTION.
Nobody checked how it builds. It is not the C++ program the original HamClock
was: at tag v26.6.0 the tree carries package.json, package-lock.json,
vite.config.mjs, server.js, an electron/ directory, a Dockerfile and a
wasm-build/. It is a Node and Vite web application, and its latest release
publishes no binary assets.
So it needs npm, which is not a backend this project has, has never measured
a need for, and would be the first one justified by a single unit. Until then
hamclock-next is what the propagation profile ships — a cmake build we can
actually perform — and the profile says why rather than silently substituting.
This is the D-018 and D-025 shape a third time: a decision resting on a property nobody verified, which only became decisive when someone tried to act on it. Activity and community size were measured correctly. Buildability was not measured at all.
Measured 2026-09-01, on the Debian 13 VM, tag v26.7.0 (tarball sha256
0c179ab1cf1e42bddda53933fc0417d18c5b3ff5c09ae9ffa747714670d2c943), using
Debian's own nodejs 20.19.2 and npm 9.2.0 — nothing from NodeSource:
| Step | Result |
|---|---|
npm ci --ignore-scripts --no-audit --no-fund |
728 packages, 8 s, 534 MB. Every one is pinned by a sha512 integrity field in package-lock.json (797 entries, one per resolved URL), and the lock file lives inside the sha256-pinned tarball — so the whole dependency closure is transitively verified from one manifest hash. |
npm run build --ignore-scripts |
Vite 6.4.3 build, 6 s, dist/ 11 MB. |
npm prune --omit=dev --ignore-scripts |
Runtime tree 142 MB, 200 packages. |
node server.js |
HTTP 200 on :3001 three seconds after start, serving the built dashboard. |
| Native modules | None — no .node files, no binding.gyp — so --ignore-scripts (no third-party lifecycle code ever runs) costs nothing. |
| Node floor | Vite 6.4 declares ^18 \|\| ^20 \|\| >=22, and that number is wrong — see below. Measured 20.19. Debian 13 and Parrot ship 20.19, Ubuntu 26.04 22.22, Kali 24.19; Ubuntu 24.04 ships 18.19 and is refused. |
Three properties the manifest and any backend must handle, all measured:
prebuildfetches from a moving tag.scripts/fetch-wasm.jsdownloads P.533 WASM from thewasm-latestGitHub release with a checksum file from the same release — self-attested, not a pin. It exits 0 and skips when it cannot, and the server then uses its built-in propagation model ("Standalone mode").npm run build --ignore-scriptssuppresses the pre-script, and the build above ran without it. A pinned WASM could be carried later as a verified artefact if the P.533 model matters to an operator.- It writes into its own directory. First start creates
.envfrom.env.examplebesideserver.js, and wants a station callsign and locator there (D-035's values). So it installs per-user, the way venv units andmshv's tree do, never under/optor/usr/local. - The code's default bind is
0.0.0.0.server/config.jsfalls back to all interfaces whenHOSTis unset (.env.examplesayslocalhost, so a generated.envfixes it); it also opens a WSJT-X UDP listener on0.0.0.0:2237. A launcher must setHOST=127.0.0.1— on a machine that also holds security tooling, a dashboard listening on every interface is not an acceptable default.
The shape that fell out is a node backend (src/hammunition/backends/node.py)
rather than a build system inside the source backend, because none of the
source backend's shape applies — no prefix, no make install, no binaries
list. npm ci → npm run build → npm prune --omit=dev, every step with
scripts ignored, then the pruned tree copies per-user to
$XDG_DATA_HOME/hammunition/node/<name> with .env preserved across
rebuilds, and a wrapper on ~/.local/bin runs node server.js from it with
HOST=127.0.0.1. The launcher composes that wrapper through a {node}
placeholder and opens the browser. What it introduces that no other build
does is registry access at build time: 728 tarballs from
registry.npmjs.org, verified by the lock file rather than by a manifest
sha256 each. Q-016 closed on 2026-09-02 as D-037: acceptable when
disclosed as a requirement and refused when Node is absent or too old. The
backend does three things the measurement did not: it checks the lock file
really carries an integrity for every resolved package before npm ci
runs (the property the whole argument rests on, so it is checked rather than
assumed); it gates at plan time on apt-cache policy's nodejs version
against the manifest's node_min_version, preferring the installed version
over the candidate; and it prints the requirement and the registry fetch in
the plan before anything runs. Carried in catalog/packages/openhamclock.yaml
at v26.7.0.
Three more things carrying it found, which the measurement above did not. Each is the D-025 shape again — a claim re-verified when it became decisive:
- Point 3's premise was false: the code ignores
HOSTaltogether.server/config.jsreads it and the startup banner prints it, andserver.js:364then callsapp.listen(PORT, '0.0.0.0', ...)regardless. The first real install through the engine, with the wrapper'sHOST=127.0.0.1in force, measured00000000:0BB9in/proc/net/tcp— every interface — and that is the only reason it was caught. Upstream main has the same line at 2026-09-02. SoNodeInstallgainedpatches, the samePatchmodel the source backend takes, applied after extraction and before the lock-file check; the manifest carries a one-token diff ('0.0.0.0'→HOST) with its evidence in the description, andpatchjoins the build dependencies. Re-measured after the patch: tcp4 nothing, tcp6[::1]:3001only. .envoverrides the wrapper, andlocalhostis::1.config.jswrites every key of the.envit generates intoprocess.envover the environment, so the operator's file wins and the wrapper'sHOSTis a default, not a guarantee. The template saysHOST=localhost, which Node 20 binds as::1only —curl http://127.0.0.1:3001gets nothing andhttp://localhost:3001gets 200 — so the launcher openslocalhost, where a browser tries both families. One edit widens the bind and that is the operator's to make;API_WRITE_KEYis the thing to set alongside it. UDP 2237 (WSJT-X) binds0.0.0.0whateverHOSTsays; onlyWSJTX_ENABLED=falsestops it.- The Node floor is 20.19, not 18, and it is a minor. On the Ubuntu 24.04
VM (Node 18.19.1) the build succeeds end to end and the server dies at its
first start:
server/routes/satellites.jsdoesrequire('axios-cookiejar-support'), version 6.0.5 of which is"type": "module", andrequire()of an ES module is Node 20.19+ (ERR_REQUIRE_ESMbelow it). Vite'senginesrange described the bundler, not the server, andpackage.jsondeclares none.node_min_major: 18becamenode_min_version: "20.19"— aMAJOR.MINORstring, because a major-only floor would admit 20.18 — and the plan-time gate compares both numbers. Ubuntu 24.04 is now refused at plan time by name, which is exactly the outcome D-037 asks for: the requirement is disclosed, and Node is not fetched to meet it.
All three are in the manifest's known_problems and the D-037 amendment.
8. Git submodules, a build-time Python and an upstream prepare script — comaps — fields added 2026-09-30 (D-069); closed when the engine's build of CoMaps runs on the bench¶
CoMaps was built by hand on Parrot 7 (Debian 13) on 2026-09-29, from the tag
v2026.08.31-14 at commit 72632e4, and four things it needed were things
the git backend could not express:
- Submodules. The backend fetched the superproject alone (
git fetch --depth 1 origin <ref>), and CoMaps vendors 22 top-level submodules, 193 entries recursively (boost, ICU, protobuf, freetype, harfbuzz and more), pinned by gitlinks in the superproject commit.submodules: truenow runsgit submodule update --init --recursive --depth 1after the pin check — upstream's ownconfigure.shruns exactly that — and then readsgit submodule status --recursive, refusing unless there is at least one entry and every one is at its gitlink. The spike's clone was full history, 9.7 GB of which ICU is 5.6 GB; the shallow fetch is unmeasured. - A Python the build needs that the archive does not have. CoMaps' CMake
refuses Debian's protobuf (4.21,
__version__4.21.12; it wants ≥ 3.20, < 4.0).build_pythontakes hash-pinned requirement lines; the engine makes a venv beside the tree and puts it first on thePATHof the prepare, configure and compile steps, never the install. The venv backend was not used: it installs a program for the operator, and this is a build dependency discarded with the build. - An upstream script before configure.
configure.shgenerates the strings, categories, symbols and drawing rules the build reads.preparenames it with its arguments and environment, andproducesnames what it must leave behind: CoMaps'generate_symbols.shcalls a bareexitwhen optipng is missing, so configure exits 0 with no symbols (D-031).--skip-map-downloadkeeps it from fetching the World maps with plain curl;SKIP_PYTHON_VENV=1keeps it from pip-installing an unpinned protobuf. - Files the install rule leaves out. With
--skip-map-download,install_resourcesinqt/CMakeLists.txtskips the World maps (a tree that had them installed two symlinks into a directory it does not install);categories_brands.txtis not installed either.extra_filesinstalls each, a sha256-pinned download or a file of the built tree, afterrm -fso a symlink is replaced rather than written through, and the effect check requires a regular file.
A tag may also name its commit, so a re-cut tag is refused rather than
only recorded; the weekly check_pin_reviews.py --verify-refs compares it
too.
Not yet closed. Every step above is exercised against fakes in the test
suite, and the same steps built CoMaps by hand on 2026-09-29; the engine has
not yet built it. The shallow submodule fetch, the build's time and disk
through hammunition install, and the install's effect check are owed by
the bench, and this entry closes when they are recorded.
Still open, and not a build gap: CoMaps reads its position from GeoClue2 by
the plugin's name (libs/platform/location_service/). Qt's nmea position
plugin is installed and would read the GPS directly, and reaching it would
take a patch to CoMaps; that is carried by nobody and is not proposed here.
The route that needs no patch is GeoClue's network-NMEA source, unmeasured
(D-069).
9. A build that fetches its own dependencies at moving refs — FreeDV 2.x (freedv-rade) — open, 2026-10-01¶
FreeDV v2.4.0 (drowe67/freedv-gui, tag commit d4acddf) was built in a
rootless debian:13 container the way a git block with build_system:
cmake would run it: cmake --fresh -S -B, then cmake --build --parallel
2, unprivileged. It exited 0 after 49 minutes at the lowest priority, 792
MiB peak for the largest process, and the 58 MB freedv started under Xvfb
("FreeDV version 2.4.0 starting", "Using RADE API version 2"). It is not
carried, for what the build did on its own:
- CMake fetches source the manifest never names.
FetchContentclonestmiw/freedv-backendat tagv1.0.0. That project'sExternalProjectsteps then clonefreedv/rade_candxiph/rnnoiseatGIT_TAG main, and download Opus as a GitHub archive of commit940d4e5with noURL_HASH. Opus's and RNNoise'sautogen.shthen fetch their model tarballs from xiph.org; those two are checked against a sha256 by the projects' own scripts. Two of the fetches are moving branches. The security rules refuse an unpinned source, and the git backend's pin check (verify-pin) covers only the tree the manifest names. - The install rule is incomplete.
cmake --installinto a DESTDIR wrote the executable, the desktop entry, icons, the manual and sample WAVs, and notlibrade.so.0.1, which the executable links from the build tree.
The backend has no field for "these nested fetches, at these commits, with
these hashes". One shape would be pre-fetching each pinned source and
handing it to CMake with FETCHCONTENT_SOURCE_DIR_<NAME>. That reaches
freedv-backend only; rade_c and RNNoise are ExternalProject steps
inside it with the branch written into its CMake, so it would also need a
patch to a tree the manifest does not check out. D-014 says one unit does
not oblige a backend feature, and this one is not proposed here. The routes
that need nothing from the engine: a distribution packages FreeDV 2.x (no
target does: all seven carry 1.8.11, which is the catalog's freedv), or
upstream pins the two branches and hashes the Opus archive.
10. A build that fetches a dependency at a tag, unchecked — nrsc5 — closed by a patch, 2026-10-01¶
nrsc5 v3.2.0 links a patched FAAD2 for HD Radio's audio, and upstream's CMake
gets it by an ExternalProject step that clones knik0/faad2 at GIT_TAG
2.11.2 while building. It is #9's shape with one difference that decides it:
the ref is a tag, not a branch, and it is written in the one tree the
manifest checks out. So the manifest carries a two-line patch that
replaces the clone with the GitHub archive of that tag and a URL_HASH
SHA256, and CMake itself refuses the archive unless it matches. The plan
prints the patch's description, so the download is disclosed before anything
runs.
Measured in a rootless debian:13 container: with the patch the build logged
"verifying file" and exited 0 in 40 s; with one digit of the hash changed it
stopped at "Hash mismatch" and exited 2 (D-031). nixpkgs pins the same FAAD2
tag by hash too: it fetches it ahead of the build and deletes the clone lines.
This is the route for a nested fetch the engine cannot pin from outside: a
patch to a hashed URL, when the fetch is in the checked-out tree and names an
immutable-looking ref. A define is tried first: radio_tool's CMake fetches
SimpleBLE for Bluetooth LE cloning, and -DBUILD_BLE=OFF removes the fetch
without a patch.