Per-system bundles, and Android's start problem closed by narrowing it

Two gaps.

The bundle's contents are per system even though its mechanism is not, so there
are now three: substrate-arch.lock, substrate-alpine.lock and
substrate-android.lock. All three are embedded and a host reads only the one it
was built for. Arch and Alpine remain placeholders -- the closure for a one-node
mesh is still research 011/012's open question, and inventing it here would be
worse than an honest placeholder.

Android's is not a placeholder. It says a partial host cannot raise a mesh and
why: every step of a bootstrap is a package, a container, or an action against
one, and those are exactly the shapes it refuses. So a partial host can JOIN a
mesh and cannot BE the first node. That belongs where somebody looking for the
android bundle will find it.

Also separated two things that were being conflated: "this system has no
bundle" and "this system was never built". Loading a bundle for debian is not
ErrEmpty, and the test asserts they differ.

0062 -- a host may be episodic. There is no way to keep a process running on an
ordinary Android device: init needs root, a foreground service can be killed
for memory. The answer is not to fight that. It is that being killed IS
disconnection, which ADR 0036 already made an ordinary situation -- and
everything the design does for a laptop that closes is what an episodic host
needs, at a shorter period. An authoritative local store, reconcile on start,
last-heard-from reported without an alarm.

So the gap closes by requiring less rather than building something. No keep-
alive, no Android daemon, no fighting the platform's process management.

Two consequences recorded rather than glossed. Last-heard-from is a much weaker
signal on an episodic host, so a healthy phone reads as a dead server unless
the reader knows which kind it is looking at. And a declaration may take a long
time to land, which makes 0058's separation of outstanding from failed
load-bearing rather than tidy.

Left open deliberately: how an episodic host is actually started, and -- first --
what an Android node is for. Building the start mechanism before deciding that
would be building it for nobody.
This commit is contained in:
2026-08-28 01:24:06 +02:00
parent 02f1fcc865
commit ebba16ce4a
7 changed files with 111 additions and 27 deletions
+34 -15
View File
@@ -12,33 +12,49 @@ package bundle
import (
_ "embed"
"errors"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// substrate is the pinned tier-1 descriptor, appliable with no mesh present.
// One bundle per operating system, because its CONTENTS are per system even though its
// mechanism is not: package names, unit names and service names all differ
// (novox/hq ADR 0060). All three are embedded and the host applies the one it was built for —
// an arch host never reads the alpine bundle.
//
// A host built without one carries the placeholder below, and says so rather than applying
// A host whose bundle is only comments carries nothing, 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
//go:embed substrate-arch.lock
var archLock []byte
// ErrEmpty means this host was built without a bundle.
//go:embed substrate-alpine.lock
var alpineLock []byte
//go:embed substrate-android.lock
var androidLock []byte
var locks = map[string][]byte{
"arch": archLock,
"alpine": alpineLock,
"android": androidLock,
}
// ErrEmpty means this host carries no 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")
"this host carries no bundle. A host 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 }
func Raw(system string) []byte { return locks[system] }
// 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") {
// empty for this purpose: a placeholder is a comment, and treating it as content would mean a
// host claims to carry a substrate it does not.
func IsEmpty(system string) bool {
for _, line := range strings.Split(string(locks[system]), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "//") {
return false
@@ -52,14 +68,17 @@ func IsEmpty() bool {
// 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() {
func Load(system string) (*declaration.Declaration, error) {
if _, known := locks[system]; !known {
return nil, fmt.Errorf("no bundle is built for %q", system)
}
if IsEmpty(system) {
return nil, ErrEmpty
}
// ParseTrusted: the bundle arrives with the binary, so it may carry actions the link may
// not (novox/hq ADR 0047). The bootstrap needs them — creating the control plane's database
// happens before there is any mesh to ask for one.
return declaration.ParseTrusted(stripComments(substrate))
return declaration.ParseTrusted(stripComments(locks[system]))
}
// stripComments removes whole-line `//` comments so a bundle can be annotated.