Skip to content

Distribution is one model, and every surface that ships the tool is a projection of it

Distribution is one model, and every surface that ships the tool is a projection of it

majordomus://adr/adr-0019proposed distributionrelease

it references

Nothing. This object declares no reference.

referenced by

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.txt is its generated projection, shipped in every archive beside the tool, which reads it at start-up. generate --check refuses a stale projection, and scripts/release-version --check still 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.

Where this comes from

canonical file
.ai/repo/adrs/0019-distribution-is-one-model-and-every-surface-that-ships-the-t.md
identity
adr-0019
also served as
MCP resource majordomus://adr/adr-0019 · majordomus entity show adr/adr-0019 · GET /api/v1/entity?uri=majordomus://adr/adr-0019
in the Cockpit
/cockpit/objects/adr/adr-0019

This page is generated from the file above by scripts/generate-site-data; editing the page changes nothing, editing the file changes every surface. The relations above are edges of docs/generated/graph.json, which the executable writes from the same index.

Searches the titles and descriptions of every page this site publishes.