15. Distribution is one model, and every surface that ships the tool is a projection of it
Context
Majordomus had no installation. The quick start was git clone, the MCP server needed a
Rust toolchain to build itself on first use, and nothing in the repository knew which
platforms the executable could even be built for. Anyone who wanted to try the tool had to
become a contributor first.
The obvious fix — write an install.sh, add a release workflow — reproduces, in the part of
the repository nobody reads, exactly the failure this project exists to catch. A supported
platform would have to be true in five places: the build matrix in a workflow file, a case
statement in the installer, a table in the documentation, a block on the website, and
whatever the tool says about itself. Five copies of one fact, none of them checked against
the others, all of them edited by a person who is in a hurry because they are cutting a
release. The failure is not hypothetical: the first symptom is a release that builds five
artifacts while the installer offers six, and the person who discovers it is the person who
cannot install the tool.
The second problem is what curl … | sh deserves to be criticised for. The script is
unread, the archive is unverified, a failure halfway through leaves a broken binary where a
working one was, and an unsupported machine gets a confusing error rather than an answer.
None of that is inherent to the pattern; all of it is what happens when the installer is
written as a convenience rather than as a program with a contract.
Decision
Distribution has one canonical machine-readable model, share/distribution.yaml, and every
surface that ships the tool derives from it or is mechanically checked against it.
The model declares the binary, the repository releases are published from, the installer's canonical URL and defaults, the archive naming function, and every target the project has an opinion about — supported, experimental, or unavailable with the reason recorded. A target it does not declare does not exist, for anything.
Generated from it: the release build matrix the workflow reads, the platform table inside
the published installer, the platform table in the installation guide, the dataset the
website renders, and the public metadata of every recorded release. Artifact names come
from one function that every caller reaches through majordomus distribution artifact;
no script, workflow or document composes one. majordomus generate --check refuses a tree
in which any projection has fallen behind.
The model is shipped in share/ rather than under .ai/, for the reason
share/commands.yaml is: it is a statement about how the tool behaves, not context for a
worker, and it travels with an installed copy so an installed executable answers the same
questions a checkout does.
A release is recorded rather than described. .ai/repo/releases/<tag>.yaml is written by
the pipeline from the artifacts that were actually uploaded, with every digest and size
read off the file; the public metadata is generated from those records, and the stable
pointer is derived on every generation as the highest version among the stable, unwithdrawn
ones. A release that is missing a supported target cannot be recorded, and therefore cannot
be published.
The installer is a program with a contract: HTTPS only, every value read out of the
metadata matched against the shape it must have, the artifact's digest and size verified
before anything is unpacked, the archive refused if it carries an absolute path, a
traversal, a link or an entry outside its own directory, the unpacked executable run once
to prove it reports the version that was resolved, and the launcher replaced by a rename so
that any earlier failure leaves the previous installation exactly as it was. No sudo, no
toolchain, no build-from-source fallback.
Alternatives rejected
A hand-written workflow matrix and a hand-written installer. Faster to write, and it is the arrangement whose drift this repository was built to detect. Rejected on its own doctrine.
Generating the whole installer from Rust. A six-hundred-line shell program embedded in
a string literal cannot be linted by shellcheck, cannot be read in a diff, and cannot be
run from a checkout. The split is behaviour in a hand-written template, facts in a
generated region inside it — which is what shellcheck and the behavioural cases then see,
because they run against the generated output rather than the template.
Shipping only the Rust executable. The command a person runs is bash: majordomus init is lib/init.sh, and the executable explicitly does not implement the task
lifecycle. An archive of just the executable would install something that cannot do the
thing the landing page tells people to do next. The archive carries both, which also means
an installed copy serves MCP without a Rust toolchain — the toolchain requirement that was
the second reason adoption stalled.
Letting the installer scrape GitHub's API or releases page. It makes the installer depend on a surface this project does not control and cannot test against. It reads two JSON documents this repository generates instead.
Deferring Windows by leaving it out of the model. Silence reads as an oversight. It is
declared unavailable with its reason, the installer refuses it by name, and the
documentation says why — which is the difference between a decision and a gap.
Consequences
Adding a platform is one edit to the model and just derive. The installer offers it, the
release builds it on the runner the model named, the guide and the website list it, and the
completeness invariant now requires an artifact for it in every future release. Nothing
else is edited, and test/cases/84_distribution_model.sh proves that by adding a target to
a copy of the model and requiring it to appear in all three projections.
Two versions must be kept in step by a check rather than by derivation: the crate's and
bin/majordomus's. Neither can read the other at run time — an installed tree has no
Cargo.toml, and the crate is compiled before the shell tool exists — so
scripts/release-version --check is what makes them one fact, and it runs before a release
builds anything.
Amended by ADR 0085 (proposed), 2026-09-24. Not two versions kept in step any more: one authored, one projected. The crate manifest is the one place the version is written;
share/version.txtis its generated projection, shipped in every archive beside the tool, which reads it at start-up.generate --checkrefuses a stale projection, andscripts/release-version --checkstill runs before a release builds anything — now proving that the tool prints the authority.
The installed layout is a versioned tree plus two generated launchers rather than a single
file in ~/.local/bin. That is what makes the swap atomic and the rollback trivial, and it
costs one extra process per invocation and a slightly longer answer to "what does
uninstalling remove".
Publication is a two-step: the pipeline commits the record and the regenerated metadata to the default branch, and the existing Pages workflow deploys it. The smoke test therefore waits for the site to name the new tag before installing from it. One publication path, some latency, and no second deployment pipeline.
Provenance signing is not done. Checksums bind an artifact to its metadata and do not prove
who wrote the metadata; that is stated in docs/DISTRIBUTION.md as a deferred improvement
rather than implied by silence.