H-21: the manifest invokes revision (none-yet) and withheld, and config view-waits-minutes

The long-form invokes entry is read by mesh-sdk's second reader release (09d6070e): packaging's manifest
tests pass with it placed and fail before it is joined.

Builds: design 47 G §558, §617, §633, §635
This commit is contained in:
2026-11-12 05:00:14 +01:00
parent ac3422f914
commit 732def66b0
6 changed files with 95 additions and 6 deletions
+21
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
@@ -54,3 +55,23 @@ func TestTheEnginesOwnNameIsReadFromItsAppliedRecordOrItsFirstComposing(t *testi
t.Fatal("the writer was never made after the name was known")
}
}
// §633: the engine's bound for the first seat list is the file the manifest's `view-waits-minutes` feeds, five minutes
// when it holds nothing usable.
func TestTheViewWaitsBoundIsTheManifestsSetting(t *testing.T) {
was := viewWaitsFile
defer func() { viewWaitsFile = was }()
for content, want := range map[string]time.Duration{"2\n": 2 * time.Minute, "": 5 * time.Minute, "0": 5 * time.Minute, "x": 5 * time.Minute} {
viewWaitsFile = filepath.Join(t.TempDir(), "w")
if err := os.WriteFile(viewWaitsFile, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if got := viewWaitsBound(); got != want {
t.Errorf("the file %q gives %v, want %v", content, got, want)
}
}
viewWaitsFile = filepath.Join(t.TempDir(), "missing")
if got := viewWaitsBound(); got != 5*time.Minute {
t.Errorf("no file gives %v", got)
}
}
+21 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -163,7 +165,7 @@ func startSeatList(ctx context.Context, session *link.Session, node string, find
return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err)
}
st.reader = reader
st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx,
st.waiter = &seatwait.Waiter{Node: node, ObserverOf: findings.observer, Reader: reader, Writer: findings, Ctx: ctx, Bound: viewWaitsBound(),
Clock: seatWaitClock, Say: say}
told := func() {
st.waiter.Changed()
@@ -259,3 +261,21 @@ func (f *lateFindings) Held() []condseat.Finding {
}
return nil
}
// viewWaitsFile is the file the manifest declares with the content ${config:view-waits-minutes} (design 47 G §633), read at
// the engine's start as recoveryWaitFile is.
var viewWaitsFile = "/var/lib/mesh-host/view-waits-minutes"
// viewWaitsBound is how long the reader of the seat list may wait before the engine says it: the file's minutes, the design's
// five (seatwait.Bound) when the file is missing, empty or not a positive number.
func viewWaitsBound() time.Duration {
raw, err := os.ReadFile(viewWaitsFile)
if err != nil {
return seatwait.Bound
}
n, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || n <= 0 {
return seatwait.Bound
}
return time.Duration(n) * time.Minute
}
+10 -1
View File
@@ -71,6 +71,8 @@ type Waiter struct {
Reader Reader
Writer Writer
Clock Clock
// Bound is how long the reader may wait; zero is the default Bound (the manifest's config `view-waits-minutes`, §633).
Bound time.Duration
// Ctx is the run's context for the writer's acts; nil is the background.
Ctx context.Context
// Say tells the node's journal; nil says nothing.
@@ -121,7 +123,7 @@ func (w *Waiter) Changed() {
return // an earlier process wrote it and the list is still unread: it stands until the first full read
}
w.since = w.Clock.Now()
w.timer = w.Clock.AfterFunc(Bound, w.expired)
w.timer = w.Clock.AfterFunc(w.bound(), w.expired)
}
// Waiting says whether the bound is running or the finding stands, and since when.
@@ -172,3 +174,10 @@ func observerOf(fixed string, of func() string) string {
}
return fixed
}
func (w *Waiter) bound() time.Duration {
if w.Bound > 0 {
return w.Bound
}
return Bound
}
+18
View File
@@ -233,9 +233,27 @@ func caseAnEntryNotFoundIsAFindingOfTheFingerprint(t *testing.T) {
}
// ADR 0636 §1, the node's finding of a seat list unread.
// §633: the bound is the manifest's `view-waits-minutes` when one is given: a finding at two minutes, none before.
func caseAGivenBoundReplacesTheFiveMinutes(t *testing.T) {
reader := &fakeReader{st: seatlist.Status{Heard: false, LastError: "no answer"}}
clock := &fakeClock{now: time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)}
findings := &fakeFindings{}
w := &Waiter{Node: "ace", Observer: "node-engine", Reader: reader, Writer: findings, Clock: clock, Bound: 2 * time.Minute}
w.Changed()
clock.advance(2*time.Minute - time.Second)
if len(findings.put) != 0 {
t.Fatalf("a finding was written before the given bound: %+v", findings.put)
}
clock.advance(time.Second)
if len(findings.put) != 1 {
t.Fatalf("no finding at the given bound: %+v", findings.put)
}
}
func TestTheSeatListWait(t *testing.T) {
for name, run := range map[string]func(*testing.T){
"five minutes write one finding and the first full read clears it": caseTheNodeWritesOneFindingAfterFiveMinutesAndClearsItAtTheFirstFullRead,
"a given bound replaces the five minutes": caseAGivenBoundReplacesTheFiveMinutes,
"a read within the bound writes nothing": caseAReadWithinTheBoundWritesNothing,
"a finding that cannot be written is said and tried again": caseAFindingThatCannotBeWrittenIsSaidAndTriedAgain,
"a finding of an earlier process is cleared at the first full read": caseAFindingOfAnEarlierProcessIsClearedAtTheFirstFullRead,
+15
View File
@@ -41,7 +41,9 @@
],
"invokes": [
"seat:mesh-catalog.entry",
{"verb": "seat:mesh-catalog.revision", "fields": ["none-yet"]},
"seat:mesh-catalog.seat-definitions",
"seat:mesh-grants.withheld",
"seat:mesh-module-assigner.assignments",
"seat:mesh-recovery.kept-points",
"seat:mesh-recovery.restore-kept",
@@ -91,6 +93,12 @@
"minimum": 1,
"default": 90
},
"view-waits-minutes": {
"type": "number",
"description": "How long the node-engine waits for the first complete seat list or catalog view before it writes the finding that it waits, in minutes (design 47 G §633, ADR 0636 §1).",
"minimum": 1,
"default": 5
},
"operator-signers": {
"type": "string",
"description": "The operator's public keys, ed25519 in base64, one a line: the node applies a composition made by hand only when one of these signed it (ADR 0539 clause 3). Public keys, no secret. Empty trusts no operator key.",
@@ -132,6 +140,13 @@
"path": "/var/lib/mesh-host/recovery-wait-minutes",
"mode": "0644",
"content": "${config:recovery-wait-minutes}\n"
},
{
"id": "view-waits",
"type": "file",
"path": "/var/lib/mesh-host/view-waits-minutes",
"mode": "0644",
"content": "${config:view-waits-minutes}\n"
}
]
}
+10 -4
View File
@@ -3,6 +3,7 @@ package packaging_test
import (
"encoding/json"
"os"
"reflect"
"slices"
"strings"
"testing"
@@ -62,7 +63,7 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
}
var m struct {
Consumes []string `json:"consumes"`
Invokes []string `json:"invokes"`
Invokes []any `json:"invokes"` // a verb, or the long form {verb, fields} (§558)
Config map[string]struct {
Default any `json:"default"`
} `json:"config"`
@@ -71,11 +72,16 @@ func TestTheManifestConsumesTheBackupEvent(t *testing.T) {
t.Fatal(err)
}
// Design 47 G §630, §633, A-77: the recovery module's three verbs it asks, and its bound as a config key of 90.
for _, v := range []string{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept"} {
if !slices.Contains(m.Invokes, v) {
t.Errorf("invokes %v lacks %s", m.Invokes, v)
// §635, §558, §617: it invokes `withheld` and names no `uses`; the catalog's revision in the long form with the field none-yet.
for _, v := range []any{"seat:mesh-recovery.kept-points", "seat:mesh-recovery.restore-kept", "seat:mesh-recovery.snapshot-kept",
"seat:mesh-grants.withheld", map[string]any{"verb": "seat:mesh-catalog.revision", "fields": []any{"none-yet"}}} {
if !slices.ContainsFunc(m.Invokes, func(have any) bool { return reflect.DeepEqual(have, v) }) {
t.Errorf("invokes %v lacks %v", m.Invokes, v)
}
}
if !strings.Contains(string(raw), `"content": "${config:view-waits-minutes}\n"`) || m.Config["view-waits-minutes"].Default != float64(5) {
t.Errorf("module.json declares no file with the content ${config:view-waits-minutes} or its default is not 5 (§633)")
}
if !strings.Contains(string(raw), `"content": "${config:recovery-wait-minutes}\n"`) {
t.Errorf("module.json declares no file with the content ${config:recovery-wait-minutes} (design 47 G A-112)")
}