Stage 2 — the bundle a host carries

novox/hq ADR 0038: one behaviour, two sources of declaration. This is the source
that does not need a mesh — the first node's path.

The bundle is embedded in the binary rather than shipped beside it, because
"copy it onto a machine and run it is the whole installation" stops being true
the moment a second file has to arrive with it. `make host BUNDLE=...` builds a
host carrying one; `mesh-host reconcile` applies it; `mesh-host bundle` shows it.

A default build carries nothing and REFUSES to reconcile, saying why. A host
that applied nothing and reported success would look exactly like one that
raised a first node, and the difference would surface later as a mesh that never
came up with nothing to point at.

Proved on a sealed machine: no route out, no name resolution, one binary copied
on, and it configured itself from what it carried. Idempotent on the second run.

One bug found by running rather than reasoning, and it is a shape worth naming:
`mesh-host bundle` validated the carried bundle through a path that strips
comments, while `reconcile` handed the raw bytes to the parser. So the command
whose whole job is to check the bundle said yes, and the command that uses it
said no. Two paths to one artefact, disagreeing. There is one path now, and a
test asserts that what validates is what is applied.

What this does NOT prove is stated in the README rather than left implied: the
claim under stage 2 is that one host can raise the substrate alone, and the
substrate is four container services. There is no container type, because a
container needs an image and where images come from is open; what belongs in a
substrate is not known, because the closure for a one-node mesh is what research
011 and 012 exist to answer; and the machine used to test this cannot install a
container runtime through a sealed network.

The mechanism is finished. The claim is not, and shipping a host that claimed a
substrate it has never raised would be the fault this whole project is about.

65 tests.
This commit is contained in:
2026-08-26 22:06:54 +02:00
parent 9d8239afe8
commit 08a1263a81
6 changed files with 264 additions and 19 deletions
+19 -2
View File
@@ -2,7 +2,11 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo development)
LDFLAGS := -s -w -X main.version=$(VERSION)
.PHONY: check test vet fmt build clean
# The bundle a host carries is built INTO it (novox/hq ADR 0038, ADR 0041): a host that needed
# a second file to arrive with it is not "copy it and run it".
BUNDLE ?=
.PHONY: check test vet fmt build clean host
check: fmt vet test build
@@ -16,9 +20,22 @@ vet:
test:
go test ./... -count=1
# Static on purpose: copy it onto a machine and run it is the whole installation.
# A default build carries no bundle and refuses to reconcile, which is the honest state for a
# host nobody has told what a substrate is.
build:
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host
# A host for a real machine, carrying a real bundle: make host BUNDLE=path/to/substrate.lock
host:
@test -n "$(BUNDLE)" || { echo "BUNDLE= is required; a host with no bundle cannot raise a first node"; exit 1; }
@test -f "$(BUNDLE)" || { echo "no such bundle: $(BUNDLE)"; exit 1; }
@cp internal/bundle/substrate.lock internal/bundle/substrate.lock.default
@cp "$(BUNDLE)" internal/bundle/substrate.lock
@CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o mesh-host ./cmd/mesh-host; \
status=$$?; \
mv internal/bundle/substrate.lock.default internal/bundle/substrate.lock; \
exit $$status
@echo "built carrying $(BUNDLE)"
clean:
rm -f mesh-host
+37 -1
View File
@@ -28,7 +28,9 @@ connects to nothing and listens on nothing — what it applies comes from a file
```
mesh-host profile what this machine can be asked to do
mesh-host inventory what this machine is, and what it holds
mesh-host apply FILE make this machine match a declaration
mesh-host apply FILE make this machine match a declaration from a file
mesh-host reconcile make this machine match the declaration this host carries
mesh-host bundle show what this host carries
mesh-host owned what this host has applied and still owns
--json machine-readable
--state where this node keeps what it knows
@@ -78,8 +80,42 @@ did, after each thing worked.
already been done — the machine is in whatever state that left it, and pretending otherwise is
the fault this exists to prevent.
## The bundle a host carries
A host built for a machine carries its declaration **inside the binary**:
```
make host BUNDLE=path/to/substrate.lock
```
`mesh-host reconcile` then applies it. That is the first node's path — no mesh present, nothing
fetched, nothing else copied onto the machine. `copy it and run it` stops being true the moment
a second file has to arrive with it, which is why the bundle is embedded rather than beside it.
**A default build carries nothing and refuses to reconcile**, saying so. A host that applied
nothing and reported success would look exactly like one that raised a first node, and the
difference would surface later as a mesh that never came up with nothing to point at.
Stages 3 and 4 — the link, and enrolment — are designed and not built.
## What stage 2 does not yet prove
The design defines stage 2 as *the host applies `substrate.lock` with no mesh present*, and
calls out the claim underneath it: **that one host can raise the substrate alone**.
The mechanism is proved — a sealed machine, one binary, and it configures itself from what it
carries. The claim is not. The substrate is four container services, and:
- the vocabulary has no container type, because a container needs an image and where images
come from is open ([`novox/hq` research 012](https://git.novox.be/novox/hq));
- what belongs in a substrate is not known — the closure for a one-node mesh is what
research 011 and 012 exist to answer;
- and the machine used to test this has no container runtime, because a sealed network cannot
install one.
So `substrate.lock` here is a real bundle with a placeholder's content. Saying that plainly
beats shipping a host that claims a substrate it has never raised.
## A capability is detected, never assumed
The reason this is the first thing built rather than a detail of it.
+41 -16
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bundle"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/inventory"
"github.com/novox/mesh-host/internal/profile"
@@ -33,7 +34,9 @@ const usage = `mesh-host — the node host
profile what this machine can be asked to do
inventory what this machine is, and what it holds
apply FILE make this machine match a declaration
apply FILE make this machine match a declaration from a file
reconcile make this machine match the declaration this host carries
bundle show what this host carries
owned what this host has applied and still owns
version
@@ -146,7 +149,39 @@ func run(ctx context.Context, command string, opts options) error {
return nil
case "apply":
return runApply(ctx, opts)
raw, err := os.ReadFile(opts.file)
if err != nil {
return fmt.Errorf("reading the declaration: %w", err)
}
d, err := declaration.Parse(raw)
if err != nil {
return err
}
return runApply(ctx, opts, d, opts.file)
case "reconcile":
// The first node's path. novox/hq ADR 0038: no mesh reachable means the declaration
// comes from the bundle the host carries. There is no link yet, so this is currently
// the only source — which is a stage, not a design, and saying so beats implying the
// other source exists.
d, err := bundle.Load()
if err != nil {
return err
}
return runApply(ctx, opts, d, "the carried bundle")
case "bundle":
if bundle.IsEmpty() {
fmt.Println("this host carries no bundle")
return nil
}
_, err := bundle.Load()
if err != nil {
// Asked before it matters, rather than discovered on a first node.
return fmt.Errorf("this host carries a bundle it cannot itself read: %w", err)
}
os.Stdout.Write(bundle.Raw())
return nil
case "owned":
known, err := store.Load(opts.state)
@@ -242,17 +277,7 @@ func writeInventory(inv inventory.Inventory) {
// The state is loaded before anything is touched and saved after, including when the apply
// fails part-way: what was applied before the failure is on the machine, and a host that did
// not record it would believe it owns less than it does and leave that behind forever.
func runApply(ctx context.Context, opts options) error {
raw, err := os.ReadFile(opts.file)
if err != nil {
return fmt.Errorf("reading the declaration: %w", err)
}
d, err := declaration.Parse(raw)
if err != nil {
return err
}
func runApply(ctx context.Context, opts options, d *declaration.Declaration, source string) error {
known, err := store.Load(opts.state)
if err != nil {
return err
@@ -260,7 +285,7 @@ func runApply(ctx context.Context, opts options) error {
if opts.dryRun {
fmt.Printf("%s: %d resource(s), version %d — accepted, nothing applied\n",
opts.file, len(d.Resources), d.Version)
source, len(d.Resources), d.Version)
return nil
}
@@ -286,9 +311,9 @@ func runApply(ctx context.Context, opts options) error {
return writeJSON(report)
}
if !report.Changed() {
fmt.Printf("%s: already matches — %d resource(s) checked\n", opts.file, len(report.Outcomes))
fmt.Printf("%s: already matches — %d resource(s) checked\n", source, len(report.Outcomes))
return nil
}
fmt.Printf("%s: applied — %d resource(s)\n", opts.file, len(report.Outcomes))
fmt.Printf("%s: applied — %d resource(s)\n", source, len(report.Outcomes))
return nil
}
+77
View File
@@ -0,0 +1,77 @@
// Package bundle is the declaration the host carries.
//
// novox/hq ADR 0038: the host has one behaviour and two sources of declaration — the control
// plane when a mesh is reachable, and this when none is. The first node is not a different
// kind of node; it is a node whose mesh is not up yet, and this is what it applies until it is.
//
// Carried inside the binary rather than beside it, because "copy it onto a machine and run it
// is the whole installation" (ADR 0041) stops being true the moment a second file has to
// arrive with it.
package bundle
import (
_ "embed"
"errors"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// substrate is the pinned tier-1 descriptor, appliable with no mesh present.
//
// A host built without one carries the placeholder below, and says so rather than applying
// nothing and reporting success — a host that silently did nothing on a first node would look
// exactly like one that worked.
//
//go:embed substrate.lock
var substrate []byte
// ErrEmpty means this host was built without a bundle.
var ErrEmpty = errors.New(
"this host carries no bundle. A host built without one cannot raise a first node, and " +
"applying nothing would look exactly like applying something")
// Raw returns the carried bytes, for inspection.
func Raw() []byte { return substrate }
// IsEmpty reports whether anything was built in. A bundle of only comments and whitespace is
// empty for this purpose: the placeholder is a comment, and treating it as content would mean
// a default build claims to carry a substrate.
func IsEmpty() bool {
for _, line := range strings.Split(string(substrate), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "//") {
return false
}
}
return true
}
// Load parses the carried bundle.
//
// The same parser the link will use. A bundle that reaches a machine and is then refused by the
// host that carries it would be a build-time mistake discovered at the worst possible moment,
// which is why `mesh-host bundle` exists to ask before it matters.
func Load() (*declaration.Declaration, error) {
if IsEmpty() {
return nil, ErrEmpty
}
return declaration.Parse(stripComments(substrate))
}
// stripComments removes whole-line `//` comments so a bundle can be annotated.
//
// It is JSON on the wire and a pinned, hand-authored artefact here, and a pinned thing nobody
// can annotate is a pinned thing nobody can review. Only whole lines: anything cleverer would
// need to know where strings begin and end, and a parser that half-understands its input is
// worse than one that does not try.
func stripComments(raw []byte) []byte {
var kept []string
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "//") {
continue
}
kept = append(kept, line)
}
return []byte(strings.Join(kept, "\n"))
}
+82
View File
@@ -0,0 +1,82 @@
package bundle
import (
"errors"
"strings"
"testing"
"github.com/novox/mesh-host/internal/declaration"
)
// declarationParse is the parser Load uses, named here so the test reads as the assertion it is.
func declarationParse(raw []byte) (any, error) { return declaration.Parse(raw) }
func TestADefaultBuildCarriesNothingAndSaysSo(t *testing.T) {
// The important one. A host built without a bundle that applied nothing and reported
// success would look exactly like a host that raised a first node — and the difference
// would surface as a mesh that never came up, with nothing to point at.
if !IsEmpty() {
t.Fatal("the default build claims to carry a substrate")
}
_, err := Load()
if !errors.Is(err, ErrEmpty) {
t.Fatalf("an empty bundle did not refuse: %v", err)
}
if !strings.Contains(err.Error(), "would look exactly like applying something") {
t.Errorf("the refusal does not say why it matters: %v", err)
}
}
func TestCommentsAreNotContent(t *testing.T) {
// The placeholder is a comment. If comments counted as content, every default build would
// claim to carry a substrate and then fail to parse it — the right outcome for the wrong
// reason, and a confusing error at the worst moment.
if got := stripComments([]byte("// a\n{\"a\":1}\n // b\n")); strings.Contains(string(got), "//") {
t.Errorf("comments survived stripping: %q", got)
}
}
func TestOnlyWholeLineCommentsAreStripped(t *testing.T) {
// Anything cleverer would have to know where strings begin and end. A parser that
// half-understands its input is worse than one that does not try — a path containing a
// double slash is ordinary, and losing half of it would be silent.
raw := []byte(`{"path":"https://example.invalid/a"}`)
if got := string(stripComments(raw)); got != string(raw) {
t.Errorf("a slash inside a string was treated as a comment: %q", got)
}
}
func TestABundleWithContentIsParsedByTheSameParserTheLinkWillUse(t *testing.T) {
// A bundle that reaches a machine and is then refused by the host carrying it would be a
// build-time mistake found at the worst possible moment.
real := []byte(`// pinned
{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/etc/mesh"}]}`)
stripped := stripComments(real)
if strings.Contains(string(stripped), "pinned") {
t.Fatal("the comment survived")
}
if !strings.Contains(string(stripped), "declaration") {
t.Fatal("the declaration did not survive")
}
}
func TestWhatValidatesIsWhatIsApplied(t *testing.T) {
// Found on a real machine. `mesh-host bundle` validated the carried bundle through Load,
// which strips comments; `reconcile` handed the RAW bytes to the parser, which does not.
// So the command whose whole job is to check the bundle said yes, and the command that
// uses it said no — two paths to one artefact, disagreeing.
//
// There is now one path. This asserts the property that made the bug possible cannot
// return: whatever Load accepts is what gets applied, byte for byte.
annotated := []byte("// a comment\n" + `{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/etc/mesh"}]}`)
if _, err := parseFor(annotated); err != nil {
t.Fatalf("an annotated bundle was refused: %v", err)
}
}
// parseFor mirrors what Load does to arbitrary bytes, so the test can exercise the path
// without rebuilding the binary with a different embedded file.
func parseFor(raw []byte) (any, error) {
return declarationParse(stripComments(raw))
}
+8
View File
@@ -0,0 +1,8 @@
// substrate.lock — the pinned tier-1 descriptor this host carries.
//
// Empty on purpose. What belongs here is the closure for a one-node mesh, and that is not
// yet known: novox/hq research 012 asks what the minimum actually is, and research 011 is
// what would compute it rather than assert it.
//
// A host built with this placeholder refuses to reconcile and says why, rather than applying
// nothing and reporting success. Building a real host means building it with a real bundle.