Gate a release plan's first machine and roll a failed build back there (hq ADR 0236)

A build that reported applied was sent everywhere; one that then did nothing, served
no tools or broke its machine's word reached every machine. Now the first machine is
judged by the component's health (the core's definitions, as doctor probes H-*, or a
module's own) three times over two minutes within ten; a failing gate puts the previous
build back there once, marks the build, and says it as a condition and an event.
Upgrades roll out by default; the bus is a planned step; a module deleted at its
source is not built (the public-acme plan failure).
This commit is contained in:
jochen
2026-10-06 18:56:54 +02:00
parent 81f497e5dd
commit d9289ef6d4
41 changed files with 3297 additions and 61 deletions
+6
View File
@@ -453,6 +453,11 @@ type Manifest struct {
// unassignment retires and what the self-check measures are all derived from it.
Data *Data `json:"data,omitempty"`
// Upgrade is how this module's new builds reach its machines (novox/hq ADR 0235): rolled out one
// machine first and gated when unsaid; `together`, or `record` — wait for a person's push — with
// why. A person's choice through the `upgrade` verb stands over it; the bus records whatever it says.
Upgrade *UpgradePolicy `json:"upgrade,omitempty"`
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
Reads []string `json:"reads,omitempty"`
@@ -2018,6 +2023,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
problems = append(problems, m.contributionPlaceholderProblems()...)
problems = append(problems, m.seatContributionProblems()...)
problems = append(problems, m.dataProblems()...)
problems = append(problems, m.upgradeProblems()...)
for i, r := range m.Resources {
id, _ := r["id"].(string)
+3 -1
View File
@@ -89,7 +89,9 @@ var defaultSeats = append([]Seat{
// A value given by hand, replaced after its module's first good start (novox/hq ADR 0228).
"secret-replaced",
// Every act a healer takes (novox/hq to-be 45 §7).
"healer-acted"},
"healer-acted",
// A build put back after its gate failed (novox/hq ADR 0235, to-be 45 §8).
"rolled-back"},
Serves: ControllerVerbs},
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
// served by whichever module holds the seat with tools of these names.
+120
View File
@@ -0,0 +1,120 @@
package catalogue
import (
"fmt"
"strings"
)
// What the mesh does when a module's build moves (novox/hq ADR 0235, extending ADR 0162 §3 and ADR
// 0218 §2).
//
// **Rolled out by default, one machine first and gated.** With the gate on the first machine and the
// rollback after it (to-be 45 §8), a build that moves is sent to one machine, judged there by its own
// health, and only then to the rest — or put back there, said, and sent nowhere else. Recording an
// upgrade and waiting for a person to push it is kept where a module says why, and where the mesh knows
// a rollback cannot undo what a new build does.
// The policies a module may declare.
const (
// PolicyRoll sends one machine first, judges it at the gate, then the rest.
PolicyRoll = "roll"
// PolicyTogether sends every machine running the module at once — for a module that must change
// everywhere in the same minute. Still judged, on every machine, after.
PolicyTogether = "together"
// PolicyRecord builds and sends nothing: the machines running it are behind until a person pushes.
PolicyRecord = "record"
)
// UpgradePolicy is what a module says about how its new builds reach its machines: `upgrade` in its
// manifest.
type UpgradePolicy struct {
Policy string `json:"policy"`
// Why is required for anything but roll: a person reading the catalogue sees why this module waits
// for them, or why it changes everywhere at once.
Why string `json:"why,omitempty"`
}
func (m Manifest) upgradeProblems() []string {
if m.Upgrade == nil {
return nil
}
switch m.Upgrade.Policy {
case PolicyRoll:
case PolicyTogether, PolicyRecord:
if strings.TrimSpace(m.Upgrade.Why) == "" {
return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+
"names the reason a person reads", m.Upgrade.Policy)}
}
default:
return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", PolicyRoll, PolicyTogether, PolicyRecord,
m.Upgrade.Policy)}
}
return nil
}
// Where a policy came from, as `upgrade` says it.
const (
FromPerson = "person"
FromModule = "module"
FromBus = "the bus"
FromData = "irreplaceable data"
FromDefault = "default"
)
// DerivedUpgrade is the policy a module's manifest gives it when no person has chosen one, with where
// it came from and why (ADR 0235):
//
// - **the bus is never rolled**: a module that provides the mesh's bus records, whatever it says — its
// upgrade is a planned step a person starts (to-be 45 §8);
// - a module that says its policy has it;
// - a module that keeps irreplaceable data records — sending the previous build cannot undo what a new
// one did to data that cannot be had again, so a person takes it, after a backup;
// - everything else rolls out, one machine first.
func DerivedUpgrade(m Manifest) (policy, from, why string) {
if ProvidesBus(m) {
return PolicyRecord, FromBus, "the bus is replaced only as a planned step a person starts (`bus upgrade`): " +
"its streams are snapshotted first and checked after"
}
if m.Upgrade != nil && m.Upgrade.Policy != "" {
return m.Upgrade.Policy, FromModule, m.Upgrade.Why
}
if item := m.irreplaceable(); item != "" {
return PolicyRecord, FromData, "it keeps irreplaceable data (" + item + "): a rollback cannot undo what a new " +
"build does to it, so a person takes each build, after a backup"
}
return PolicyRoll, FromDefault, ""
}
// ProvidesBus is whether a manifest provides the mesh's bus.
func ProvidesBus(m Manifest) bool {
for _, o := range m.Provides {
if o.Name == "mesh-bus" {
return true
}
}
return false
}
// irreplaceable names the first irreplaceable data the module keeps, its own or its consumers', or
// nothing.
func (m Manifest) irreplaceable() string {
if m.Data == nil {
return ""
}
for _, it := range m.Data.Own {
if it.Class == ClassIrreplaceable {
return it.ID
}
}
for provision, c := range m.Data.Consumers {
if c.Class == ClassIrreplaceable {
return "its consumers' " + provision
}
}
for provision, k := range m.Data.KeptBy {
if k.Class == ClassIrreplaceable {
return "what it keeps with " + provision
}
}
return ""
}
+52
View File
@@ -0,0 +1,52 @@
package catalogue
import (
"strings"
"testing"
)
// A module rolls out by default; it records where it says so with why, where it keeps irreplaceable
// data, and always where it is the bus (novox/hq ADR 0236).
func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
cases := []struct {
name string
m Manifest
policy, from string
}{
{"default", Manifest{Module: "app"}, PolicyRoll, FromDefault},
{"says record", Manifest{Module: "db", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}},
PolicyRecord, FromModule},
{"says together", Manifest{Module: "dns", Upgrade: &UpgradePolicy{Policy: PolicyTogether, Why: "one zone"}},
PolicyTogether, FromModule},
{"irreplaceable data", Manifest{Module: "media", Data: &Data{Own: []DataItem{{ID: "library", Class: ClassIrreplaceable}}}},
PolicyRecord, FromData},
{"valuable data rolls", Manifest{Module: "notes", Data: &Data{Own: []DataItem{{ID: "db", Class: ClassValuable}}}},
PolicyRoll, FromDefault},
{"irreplaceable but says roll", Manifest{Module: "photos", Upgrade: &UpgradePolicy{Policy: PolicyRoll},
Data: &Data{Own: []DataItem{{ID: "originals", Class: ClassIrreplaceable}}}}, PolicyRoll, FromModule},
{"the bus, whatever it says", Manifest{Module: "nats", Provides: []Offer{{Name: "mesh-bus"}},
Upgrade: &UpgradePolicy{Policy: PolicyRoll}}, PolicyRecord, FromBus},
}
for _, c := range cases {
policy, from, _ := DerivedUpgrade(c.m)
if policy != c.policy || from != c.from {
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
}
}
}
// A policy other than roll says why, and an unknown one is refused.
func TestAnUpgradePolicySaysWhy(t *testing.T) {
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
t.Errorf("%+v was accepted", u)
}
}
if problems := (Manifest{Module: "m", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}).upgradeProblems(); len(problems) != 0 {
t.Errorf("a record with why was refused: %v", problems)
}
if _, err := ParseManifest([]byte(`{"module":"m","upgrade":{"policy":"record"}}`)); err == nil ||
!strings.Contains(err.Error(), "says why") {
t.Errorf("a manifest recording without why parsed: %v", err)
}
}
+25
View File
@@ -267,6 +267,31 @@ var ControllerVerbs = []Verb{
"probes": "\"true\": the registry — what each probe asserts, and the condition it raises",
"signals": "\"true\": the signals table, each row with the age of its newest signal",
}, nil, "run", "probes", "signals")},
// How a module's new builds reach its machines, and the bus's planned step (novox/hq ADR 0235).
{Name: "upgrade", Description: "How each module's new builds reach its machines (novox/hq ADR 0235): rolled " +
"out one machine first and judged there at the gate, then the rest — or recorded, waiting for a person's " +
"push — with where that comes from (a person, the module, the bus, its irreplaceable data, the default) and " +
"why. With module, that one; with policy, a person's choice for it — roll-out, record (with why) or default " +
"to take the choice back. The bus is never rolled out.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll-out, record or default",
"together": "\"true\": with roll-out, every machine at once instead of one machine first",
"why": "with policy: why — required for record, kept and said with the policy",
}, nil, "together")},
{Name: "bus", Description: "The bus as a planned step (novox/hq to-be 45 §8, ADR 0235): what a bus upgrade " +
"would do — the bus's build on each machine against the one the mesh holds — and how the last step went. " +
"With upgrade, start one: a person's act with why, after the streams are snapshotted (snapshot-taken says " +
"where, while the mesh takes none itself), saying first whether it can be reverted; bus-maintenance is open " +
"while it runs and every stream, consumer and a round trip are checked after.",
Input: schema(map[string]string{
"upgrade": "\"true\": start the bus's upgrade",
"why": "with upgrade: why — required, recorded in the hand-act log",
"cause": "with upgrade: the cause in a word (default bus-upgrade)",
"reversible": "\"true\": with upgrade, the new version can be undone by putting the old one back",
"irreversible": "\"true\": with upgrade, it cannot — your explicit word that it runs anyway",
"snapshot-taken": "with upgrade: where the streams' snapshot you took is",
}, nil, "upgrade", "reversible", "irreversible")},
// A consumer the mesh stopped asking for: retired, waiting for a person, deleted only by one
// (novox/hq ADR 0230).
{Name: "retire", Description: "A consumer the mesh stops asking for is retired by its provider — access " +