One kind for the module's own code, with three modes

The first cut of this added a `daemon` for the long-running case alone. That
would have meant a new vocabulary entry for each of the others — a scheduled
task, a run-once migration, a health check — when they are one thing run at
different cadences. That is a field, not four entries in a vocabulary where every
entry widens what a compromised control plane can express.

So it mirrors a container exactly, because it IS a container's twin: the same
intent, hosted by the machine's own supervisor instead of a runtime. Stays up,
runs once, or runs on a schedule.

Tools, hooks and event consumers are not further modes. They are loaded by a tool
host, which is itself a process that stays up — so the generic case already
covers them, which is the test of whether it is generic.

A scheduled process gets a timer and a unit that finishes; a long-running one
gets a unit that is restarted when it exits. Getting that wrong either way is a
second copy running continuously between fires, or a schedule that never fires.
The modes are exclusive and validation says so near the author: something that
runs once does not run on a schedule, and something not running between fires
cannot be restarted when a file changes.

A missed fire happens when the machine comes back rather than being skipped,
which is the difference between a machine that was down and a schedule that
quietly stopped.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-15 10:26:31 +02:00
parent 1f0fb85128
commit f5cf9510c1
10 changed files with 442 additions and 215 deletions
+67 -30
View File
@@ -60,19 +60,24 @@ const (
// access is ordinary, because none of them owns it.
TypeAccess Type = "access"
// TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather
// than by how it is hosted.
// TypeProcess is the module's own code, run on the machine, in one of three modes.
//
// **The intent, not the mechanism.** Until this, an author decided the hosting before they
// could declare anything: code of their own meant a `container` built from an image, a script
// meant a `service` and a unit somebody else had to install. Same intent — run this and keep
// it running — expressed two unrelated ways, and the choice baked into which kind was picked.
// meant a `service` and a unit somebody else had to install. Same intent — run this — with
// the choice baked into which kind was picked.
//
// A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps
// such things, writes the unit, and puts it in the state asked for. One module may declare
// several, in different languages, because a module is one piece of software and not one
// process (novox/hq ADR 0040).
TypeDaemon Type = "daemon"
// **And three modes rather than three kinds**, exactly as a container has. A first draft of
// this added a `daemon` for the long-running case alone, which would have meant a new kind for
// each of the others — a scheduled task, a run-once migration, a health check. They are one
// thing run at different cadences, and that is a field, not a vocabulary entry. Every addition
// to this vocabulary widens what a compromised control plane can express.
//
// What a module declares is a bundle and a command. The mesh unpacks the bundle where it keeps
// such things and runs it — as a unit that stays up, as a step that must finish, or on a
// cadence. Tools, hooks and event consumers are not separate modes: they are loaded by a tool
// host, which is itself a process that stays up.
TypeProcess Type = "process"
)
// Resource is one thing that should be true of the machine.
@@ -407,17 +412,22 @@ func (a *Archive) validate(where string, _ bool) []string {
return problems
}
// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for.
// Process is the module's own code, run on the machine, in one of three modes.
//
// The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state
// and deliberately does not install one, which is right for software that ships its own. A Daemon
// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and
// and deliberately does not install one, which is right for software that ships its own. This is
// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and
// nothing else will.
//
// The difference from Container is the hosting, and a module should not have to choose: what a
// daemon says is what to run, and the machine's own process supervisor is how. A module whose code
// genuinely needs a container's isolation declares a container and says so.
type Daemon struct {
// The difference from Container is the hosting, and a module should not have to choose: what this
// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a
// container's isolation declares a container and says so.
//
// **Three modes, matching a container's**, because they are the same thing at different cadences:
// stays up, runs once, runs on a schedule. A module's scheduled task, its run-once migration, its
// health check and its tool host are all this — and each being its own resource kind would be four
// entries in a vocabulary where every entry widens what a compromised control plane can express.
type Process struct {
ID string `json:"id"`
Type Type `json:"type"`
// Name is what the unit is called, and what an operator will see in the process table.
@@ -444,41 +454,68 @@ type Daemon struct {
// configuration, so replacing a file and finding the process already up leaves the machine
// behaving the way it did before while every check passes.
RestartOn []string `json:"restart-on,omitempty"`
// RunOnce marks code the host runs to completion rather than leaves running: a migration, a
// seed, a first-boot step. What follows it is gated on it finishing, because a step that did
// not make the machine ready must not be followed by the thing that needed it.
RunOnce bool `json:"run-once,omitempty"`
// Schedule runs it on a cadence — a five-field cron expression (novox/hq ADR 0053). The
// recurring twin of RunOnce: the same code, run again rather than left running.
//
// Exclusive with RunOnce and with RestartOn, for the same reason a container's is: something
// that runs once does not run on a schedule, and something that is not running cannot be
// restarted when a file changes.
Schedule string `json:"schedule,omitempty"`
}
func (d *Daemon) Identity() string { return d.ID }
func (d *Daemon) Kind() Type { return TypeDaemon }
func (d *Daemon) Target() string { return d.Name }
func (d *Process) Identity() string { return d.ID }
func (d *Process) Kind() Type { return TypeProcess }
func (d *Process) Target() string { return d.Name }
func (d *Daemon) validate(where string, _ bool) []string {
func (d *Process) validate(where string, _ bool) []string {
var problems []string
if d.Name == "" {
problems = append(problems, where+": a daemon needs a name, which is what its unit is called")
problems = append(problems, where+": a process needs a name, which is what its unit is called")
}
if strings.ContainsAny(d.Name, "/ \t") {
// It becomes a unit name and a file on disk. A name with a separator in it would write
// somewhere nobody meant.
problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+
problems = append(problems, where+": a process name becomes a unit name, so it cannot "+
"contain a path separator or a space")
}
if d.Source == "" {
problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from")
problems = append(problems, where+": a process needs somewhere to fetch its bundle from")
}
if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 {
// The same rule an archive follows, and for the same reason: this crosses a network the
// mesh does not control, and a reference that can be made to point elsewhere is not one.
problems = append(problems, where+
": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>")
": a process bundle is pinned by digest, as sha256:<64 hex characters>")
}
if len(d.Run) == 0 {
problems = append(problems, where+": a daemon needs to say what to run")
problems = append(problems, where+": a process needs to say what to run")
}
for _, part := range d.Run {
if part == "" {
problems = append(problems, where+": a daemon's command has an empty element")
problems = append(problems, where+": a process command has an empty element")
break
}
}
if d.Schedule != "" {
if d.RunOnce {
problems = append(problems, where+
": a process runs once or on a schedule, not both")
}
if len(d.RestartOn) > 0 {
problems = append(problems, where+
": a scheduled process is not running between its fires, so there is nothing to "+
"restart when something it reads changes")
}
if _, err := ParseCron(d.Schedule); err != nil {
problems = append(problems, where+": "+err.Error())
}
}
return problems
}
@@ -743,8 +780,8 @@ func newOf(t Type) Resource {
return &Archive{}
case TypeAccess:
return &Access{}
case TypeDaemon:
return &Daemon{}
case TypeProcess:
return &Process{}
}
return nil
}
@@ -752,8 +789,8 @@ func newOf(t Type) Resource {
// Vocabulary is every kind this host speaks.
func Vocabulary() []Type {
return []Type{
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile,
TypeNetwork, TypePackage, TypeService, TypeUser,
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile,
TypeNetwork, TypePackage, TypeProcess, TypeService, TypeUser,
}
}