apply: a run-once container is a step the host runs to completion (ADR 0052)

A module can declare state but not a step that runs at first boot. This adds
`run-once: true` to the container shape: the host runs it in the foreground,
requires it to exit 0, and records that it did — as the digest of the
declaration, so a re-apply does not re-run it unless the declaration changed.

Because the declaration is applied in order and a failed run-once step gates the
apply the way a failed action does, whatever is declared after the step starts
only once it has completed. That is how "before the broker starts" is enforced,
with no dependency graph the host must resolve (ADR 0005): the step is declared
first, and the container that needs it is never reached until it is done.

No new host shape and no arbitrary host command — a run-once container is
strictly less powerful than an action. Validation refuses run-once with
restart-on (contradictory lifecycles). Six unit tests; go test ./... green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-05 23:55:45 +02:00
parent 91e0d6a4c7
commit 19e5dd83ea
3 changed files with 339 additions and 3 deletions
+17
View File
@@ -523,6 +523,16 @@ type Container struct {
// recreates the container when one of these resources changed this pass, even if the spec
// matches.
RestartOn []string `json:"restart-on,omitempty"`
// RunOnce marks a container the host runs to completion rather than leaves running: a step,
// not a service (novox/hq ADR 0052). The host runs it, requires it to exit 0, and records that
// it did — and because the declaration is applied in order and a failed step halts the apply,
// whatever is declared after a run-once container starts only once the step has finished. It is
// how a module runs its own code at first boot — seed a store, migrate, health-gate — under its
// own account (ADR 0047), before the container that depends on it. The record that it ran is
// the digest of this declaration, so a re-apply does not re-run it unless the declaration
// changed.
RunOnce bool `json:"run-once,omitempty"`
}
func (c *Container) Identity() string { return c.ID }
@@ -534,6 +544,13 @@ func (c *Container) validate(where string, _ bool) []string {
if c.Name == "" {
problems = append(problems, where+": a container needs a name")
}
// restart-on brings a *running* container back when a file it read changed; a run-once step
// does not stay running to be brought back. Declaring both asks for two contradictory
// lifecycles at once, so it is refused rather than silently resolved to one of them.
if c.RunOnce && len(c.RestartOn) > 0 {
problems = append(problems, where+": a run-once container cannot also declare restart-on; "+
"it runs to completion rather than staying running to be restarted")
}
return append(problems, checkImage(where, c.Image)...)
}