From 3f89c96d205773877a8ddf0765bd001703d09493 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:00:03 +0100 Subject: [PATCH 01/16] E-1: the tool runner's manifest names no waits and keeps its approvals (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit module.json drops waits and its five families, uses operator-channel for the asks of marked waits, and keeps the per-machine state approvals (48 hours) in its old state section; the manifest test holds each change. Builds: design 34 every-event §11, §19 (the manifest test); design 46 7-approval-mark §7, §13 (manifest entry) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../internal/console/manifest_waits_test.go | 58 +++++++++++++------ node-tools/internal/provide/provide_test.go | 13 ++++- node-tools/module.json | 15 ++--- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index f80ec5f..da628ff 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -8,19 +8,28 @@ import ( "testing" ) -// novox/hq ADR 0367 §5: the tool runner's manifest names the families a session waits on today, one by one, -// and the asker's `decided` for the claude-code module alone. Adding one is a reviewed change to this file -// (§6), so this list changes with it. The delivery's transitions are waited on under the gate's name alone (ADR 0450 §8, issues 1350 -// and 1406): today's module is forgotten. -func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { +// The tool runner's manifest names no events to wait on (novox/hq ADR 0670 §2): a session waits on any event by its +// name. It holds each change of design 34 every-event §11 (E-1): no `waits`, `operator-channel` in `uses` for the asks of +// marked waits, the per-machine state `approvals` in the old `state` section with its 48 hours and none in `requires` +// (design 46 7-approval-mark §13), and the finding stream (design 47 G §387). +func TestTheManifestNamesNoWaitsAndKeepsItsApprovals(t *testing.T) { raw, err := os.ReadFile("../../module.json") if err != nil { t.Fatal(err) } + var keys map[string]json.RawMessage + if err := json.Unmarshal(raw, &keys); err != nil { + t.Fatal(err) + } + if _, ok := keys["waits"]; ok { + t.Error("the manifest still carries waits (novox/hq ADR 0670 §2)") + } var m struct { - Module string `json:"module"` - Waits []string `json:"waits"` - Consumes []string `json:"consumes"` + Module string `json:"module"` + Uses []string `json:"uses"` + State []json.RawMessage `json:"state"` + Requires []json.RawMessage `json:"requires"` + Consumes []string `json:"consumes"` Streams []struct { Name string `json:"name"` Events []string `json:"events"` @@ -29,20 +38,33 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } + if m.Module != "node-tools" || !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) { + t.Errorf("%s uses %v, want operator-channel (design 46 7-approval-mark §7)", m.Module, m.Uses) + } + type state struct { + Name string `json:"name"` + PerMachine bool `json:"per-machine"` + TTLSeconds int `json:"ttl-seconds"` + } + var approvals []state + for _, r := range m.State { + var s state + if json.Unmarshal(r, &s) == nil && s.Name == "approvals" { + approvals = append(approvals, s) + } + } + if want := []state{{Name: "approvals", PerMachine: true, TTLSeconds: 172800}}; !reflect.DeepEqual(approvals, want) { + t.Errorf("the old state section's approvals is %+v, want %+v (design 46 7-approval-mark §13)", approvals, want) + } + for _, r := range m.Requires { + if strings.Contains(string(r), `"approvals"`) { + t.Errorf("approvals is asked in requires too: %s", r) + } + } if len(m.Streams) != 1 || m.Streams[0].Name != "node-tools-findings" || !reflect.DeepEqual(m.Streams[0].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) { t.Errorf("the finding stream is %+v, want node-tools-findings with the three finding events (design 47 G §387)", m.Streams) } - want := []string{"mesh-knowledge.reviewed", "mesh-delivery-gate.transition", "gitea.forge.status", - "mesh-self-check.checked", "operator-channel.decided.claude-code"} - if m.Module != "node-tools" || !reflect.DeepEqual(m.Waits, want) { - t.Errorf("%s waits on %v, want %v", m.Module, m.Waits, want) - } - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - t.Errorf("a family named by a wildcard: %s", w) - } - } // A wait is no consumer (ADR 0367 §2): what it consumes is the assigner's two views families (ADR 0615 §5, issue 1966 // TLS-5), the events of the catalog half's branch follower (design 47 G §196, §614: `catalog-revision`, // `registration-changed`, and the gate's `source-moved`; never the indexer's `views.modules` or `views.manifests`), the two diff --git a/node-tools/internal/provide/provide_test.go b/node-tools/internal/provide/provide_test.go index eb35808..0d41e7a 100644 --- a/node-tools/internal/provide/provide_test.go +++ b/node-tools/internal/provide/provide_test.go @@ -126,7 +126,7 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) { var m struct { Serves map[string]map[string]any `json:"serves"` Moved bool `json:"moved"` - State []string `json:"state"` + State []json.RawMessage `json:"state"` Reads []string `json:"reads"` Provides []struct { Name string `json:"name"` @@ -172,9 +172,18 @@ func TestTheOfferServesThePortTheManifestServes(t *testing.T) { // Every bucket the sdk's helper reaches is a state the manifest keeps or reads, by the sdk's names: the provider's // own and the seat mesh-provisioner's provisions and plans (issue 1954 L6-T1); the controller's assignments no more. declared := map[string]bool{} - for _, n := range append(m.State, m.Reads...) { + for _, n := range m.Reads { declared[n] = true } + for _, r := range m.State { // an old state entry is its name, or an object naming it (approvals, per machine) + var e struct { + Name string `json:"name"` + } + if json.Unmarshal(r, &e.Name) != nil { + _ = json.Unmarshal(r, &e) + } + declared[e.Name] = true + } for bucket, want := range map[string]string{ provider.BucketOf(Module): "bound", provisions.ProvisionsBucketOf(Provisioner): "mesh-provisioner.provisions", diff --git a/node-tools/module.json b/node-tools/module.json index f206a4d..28fae51 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -6,12 +6,8 @@ "invokes": [ "*" ], - "waits": [ - "mesh-knowledge.reviewed", - "mesh-delivery-gate.transition", - "gitea.forge.status", - "mesh-self-check.checked", - "operator-channel.decided.claude-code" + "uses": [ + "operator-channel" ], "provides": [ { @@ -68,7 +64,12 @@ }, "state": [ "bound", - "findings" + "findings", + { + "name": "approvals", + "per-machine": true, + "ttl-seconds": 172800 + } ], "consumes": [ "mesh-catalog.catalog-revision", From cb47de85176cca12e56c0ec0ecdd3131341c3b9b Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:03:13 +0100 Subject: [PATCH 02/16] E-2: a heard event keeps its own time, and a refused wait is never a hot loop (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HearFirst reads the event's x-time as Published, notes a subscription the bus refuses while the others go on waiting, and answers a wait refused whole only after its timeout, in words naming the grants part's grant of every event subject; mesh_wait lists the refused subjects in its answer. Builds: design 34 every-event §12, §16, §18, §19 (bus/wait_test.go) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/wait.go | 74 ++++++++++++++++--------- node-tools/internal/bus/wait_test.go | 82 ++++++++++++++++++++-------- node-tools/internal/console/wait.go | 21 ++++--- 3 files changed, 119 insertions(+), 58 deletions(-) diff --git a/node-tools/internal/bus/wait.go b/node-tools/internal/bus/wait.go index 4dda03b..aa2e961 100644 --- a/node-tools/internal/bus/wait.go +++ b/node-tools/internal/bus/wait.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "slices" "strings" "time" @@ -23,31 +24,42 @@ type WaitIssued struct { Subject string `json:"subject"` } -// Heard is one event a wait heard: the subject it arrived on, its body and when it arrived. +// Heard is one event a wait heard: the subject it arrived on, its body, when it arrived, and when it was published, the +// event's own header `x-time` as its emitter wrote it, empty for an event that carries none (design 34 every-event §12). type Heard struct { - Subject string - Body json.RawMessage - At time.Time + Subject string + Body json.RawMessage + At time.Time + Published string } // HearFirst subscribes the subjects for this call alone and answers the first message whose body match // accepts (every one, when match is nil), or ok false once `within` has passed. Every subscription is -// closed before it returns. A subscription the bus refuses is said as an error naming the subject, at once; -// a caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for nobody. -func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, err error) { +// closed before it returns. A subscription the bus refuses is noted and the others go on waiting; refused lists +// those noted by the time it answers (design 34 every-event §16). When the bus refused every subscription of the +// call, it still waits out `within` and then answers the refusal, so a caller looping on it asks once a timeout and +// never at once again. A caller gone — ctx done — ends the wait at once with ctx's error, so nothing waits on for +// nobody. +func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (heard Heard, ok bool, refused []string, err error) { if len(subjects) == 0 { - return Heard{}, false, fmt.Errorf("a wait names at least one subject") + return Heard{}, false, nil, fmt.Errorf("a wait names at least one subject") + } + var unique []string + for _, subject := range subjects { + if !slices.Contains(unique, subject) { + unique = append(unique, subject) + } } got := make(chan Heard, 1) - refused := make(chan string, len(subjects)) + refusals := make(chan string, len(unique)) var subs []*nats.Subscription defer func() { for _, s := range subs { _ = s.Unsubscribe() } c.mu.Lock() - for _, subject := range subjects { - delete(c.waits[subject], refused) + for _, subject := range unique { + delete(c.waits[subject], refusals) if len(c.waits[subject]) == 0 { delete(c.waits, subject) } @@ -58,14 +70,14 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur if c.waits == nil { c.waits = map[string]map[chan string]bool{} } - for _, subject := range subjects { + for _, subject := range unique { if c.waits[subject] == nil { c.waits[subject] = map[chan string]bool{} } - c.waits[subject][refused] = true + c.waits[subject][refusals] = true } c.mu.Unlock() - for _, subject := range subjects { + for _, subject := range unique { sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) { body := json.RawMessage(msg.Data) if !json.Valid(body) { @@ -76,31 +88,39 @@ func (c *Conn) HearFirst(ctx context.Context, subjects []string, within time.Dur return } select { - case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC()}: + case got <- Heard{Subject: msg.Subject, Body: body, At: time.Now().UTC(), Published: msg.Header.Get("x-time")}: default: } }) if err != nil { - return Heard{}, false, fmt.Errorf("could not wait on %s: %w", subject, err) + return Heard{}, false, nil, fmt.Errorf("could not wait on %s: %w", subject, err) } subs = append(subs, sub) } // The bus has every subscription — or has said which it refused — once this returns. if err := c.nc.FlushTimeout(5 * time.Second); err != nil { - return Heard{}, false, fmt.Errorf("the bus did not take the wait: %w", err) + return Heard{}, false, nil, fmt.Errorf("the bus did not take the wait: %w", err) } timer := time.NewTimer(within) defer timer.Stop() - select { - case h := <-got: - return h, true, nil - case subject := <-refused: - return Heard{}, false, fmt.Errorf("the bus refused this account a subscription on %s: it is not in the "+ - "account's wait grant (novox/hq ADR 0367 §2)", subject) - case <-timer.C: - return Heard{}, false, nil - case <-ctx.Done(): - return Heard{}, false, ctx.Err() + for { + select { + case h := <-got: + return h, true, refused, nil + case subject := <-refusals: + if !slices.Contains(refused, subject) { + refused = append(refused, subject) + } + case <-timer.C: + if len(refused) == len(unique) { + return Heard{}, false, refused, fmt.Errorf("the bus refused this account a subscription on %s: the "+ + "grants part grants this machine's tool runner every event subject (novox/hq ADR 0670 §1), so its "+ + "grant is not composed yet; read the state from its owner meanwhile", strings.Join(refused, ", ")) + } + return Heard{}, false, refused, nil + case <-ctx.Done(): + return Heard{}, false, refused, ctx.Err() + } } } diff --git a/node-tools/internal/bus/wait_test.go b/node-tools/internal/bus/wait_test.go index 45d3b5a..c12d8d7 100644 --- a/node-tools/internal/bus/wait_test.go +++ b/node-tools/internal/bus/wait_test.go @@ -2,22 +2,24 @@ package bus_test import ( "context" - "encoding/json" "errors" "strings" "testing" "time" "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) -// novox/hq ADR 0367 §2, §5: the bus refuses the tool runner's account any subject its wait grant does not -// name — another asker's `decided` among them — and a wait on one says the bus refused it, at once. +// novox/hq ADR 0670 §1 (design 34 every-event §12, §16): a subscription the bus refuses is noted and the others go on +// waiting, a heard event carries its own time; a wait whose every subscription is refused answers only after its +// timeout, in words naming the grants part's grant of every event subject, never a wait grant (E-2). func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) { granted := "mesh.seat.operator-channel.event.decided.claude-code" + other := "mesh.seat.operator-channel.event.decided.shop" s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{ Publish: &server.SubjectPermission{Allow: []string{"$JS.API.DIRECT.GET.>", "mesh.>"}}, @@ -33,33 +35,65 @@ func TestTheBusRefusesAWaitOutsideTheGrant(t *testing.T) { t.Cleanup(s.Shutdown) c := dial(t, s.ClientURL(), "node-tools", "desk", "desk.node-tools") + // Every subscription refused: answered after the timeout, never at once, in §16's words. start := time.Now() - _, ok, err := c.HearFirst(context.Background(), []string{"mesh.seat.operator-channel.event.decided.shop"}, 5*time.Second, nil) - if ok || err == nil || !strings.Contains(err.Error(), "refused") { - t.Errorf("a wait on another asker's decided: heard %v, %v", ok, err) + _, ok, refused, err := c.HearFirst(context.Background(), []string{other}, 300*time.Millisecond, nil) + if took := time.Since(start); took < 300*time.Millisecond || took > 3*time.Second { + t.Errorf("a wait refused whole answered after %s, want its timeout of 300ms", took) } - if time.Since(start) > 3*time.Second { - t.Errorf("the refusal took %s to be said", time.Since(start)) + if ok || err == nil || !strings.Contains(err.Error(), "the bus refused this account a subscription on "+other) || + !strings.Contains(err.Error(), "every event subject (novox/hq ADR 0670 §1)") || + strings.Contains(err.Error(), "wait grant") || strings.Contains(err.Error(), "0367") || + len(refused) != 1 || refused[0] != other { + t.Errorf("a wait on another asker's decided: heard %v, refused %v, %v", ok, refused, err) } - done := make(chan bus.Heard, 1) - go func() { - h, ok, err := c.HearFirst(context.Background(), []string{granted}, 5*time.Second, func(body json.RawMessage) bool { return true }) - if ok && err == nil { - done <- h - } - }() - time.Sleep(300 * time.Millisecond) - if err := c.Say(granted, []byte(`{"ask":"a1"}`)); err != nil { + // One refused beside one granted: the granted one hears, with the event's own time, and the refused one is listed. + pub, err := nats.Connect(s.ClientURL(), nats.UserInfo("desk.node-tools", "pw")) + if err != nil { t.Fatal(err) } - select { - case h := <-done: - if h.Subject != granted || string(h.Body) != `{"ask":"a1"}` { - t.Errorf("heard %+v", h) + t.Cleanup(pub.Close) + for _, published := range []string{"2026-11-12T03:00:00.5Z", ""} { + type result struct { + h bus.Heard + refused []string + err error + } + done := make(chan result, 1) + go func() { + h, ok, refused, err := c.HearFirst(context.Background(), []string{other, granted}, 5*time.Second, nil) + if !ok && err == nil { + err = errors.New("timed out") + } + done <- result{h, refused, err} + }() + // The event is said again until the wait, subscribed in its own time, hears it. + say := time.NewTicker(20 * time.Millisecond) + var r result + heard: + for { + select { + case r = <-done: + break heard + case <-say.C: + msg := nats.NewMsg(granted) + msg.Data = []byte(`{"ask":"a1"}`) + if published != "" { + msg.Header.Set("x-time", published) + } + if err := pub.PublishMsg(msg); err != nil { + t.Fatal(err) + } + } + } + say.Stop() + if r.err != nil || r.h.Subject != granted || string(r.h.Body) != `{"ask":"a1"}` || r.h.Published != published { + t.Errorf("heard %+v, %v; want published %q", r.h, r.err, published) + } + if len(r.refused) > 1 || len(r.refused) == 1 && r.refused[0] != other { + t.Errorf("refused %v, want at most %s", r.refused, other) } - case <-time.After(5 * time.Second): - t.Fatal("a wait on the granted subject heard nothing") } } @@ -70,7 +104,7 @@ func TestAWaitEndsWhenItsCallerIsGone(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) time.AfterFunc(200*time.Millisecond, cancel) start := time.Now() - _, ok, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil) + _, ok, _, err := c.HearFirst(ctx, []string{"mesh.mod.mesh-knowledge.event.reviewed"}, time.Minute, nil) if ok || !errors.Is(err, context.Canceled) { t.Errorf("a cancelled wait answered %v, %v", ok, err) } diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index 9ee1a41..1194b5b 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -86,18 +86,25 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin family[subject] = name } - heard, ok, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) + heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) if err != nil { return failure("mesh_wait could not wait: " + err.Error()) } + var answer map[string]any if !ok { - return answerText(map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names, - "read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"}) + answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names, + "read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"} + } else { + answer = map[string]any{ + "heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}, + "read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)", + } } - return answerText(map[string]any{ - "heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}, - "read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)", - }) + // A subscription the bus refused while others stood is said (design 34 every-event §16). + if len(refused) > 0 { + answer["refused"] = refused + } + return answerText(answer) } // waitNames is the families a call names: a list of names, or one name. From 827628164ad67b90b0d244a31bc5fffec345a958 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:05:13 +0100 Subject: [PATCH 03/16] A-1 marks: the tool runner derives each served module's marks from the catalog half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate (node-tools/internal/approval) derives a module's marked tools and verbs, and the subjects of its marked events and restricted streams, from its served entry (the build its composed environment's fingerprint names) and the newest, unioned while they differ, and the seat definitions' verb marks, which no holder unmarks. A bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised through the runner's held writer and cleared when a bundle serves it. The first views now start whether or not the console listens, since the gate reads the catalog half too. Builds: design 46 7-approval-mark §5, §4 (at load), §3; design 34 every-event §33; lead answer A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 15 +- node-tools/cmd/node-tools/seatlist.go | 11 ++ node-tools/internal/approval/gate.go | 135 ++++++++++++++++ node-tools/internal/approval/marks.go | 176 +++++++++++++++++++++ node-tools/internal/approval/marks_test.go | 136 ++++++++++++++++ node-tools/internal/firstview/approval.go | 83 ++++++++++ node-tools/internal/firstview/firstview.go | 3 +- node-tools/internal/runtime/gate.go | 36 +++++ node-tools/internal/runtime/runtime.go | 14 ++ 9 files changed, 603 insertions(+), 6 deletions(-) create mode 100644 node-tools/internal/approval/gate.go create mode 100644 node-tools/internal/approval/marks.go create mode 100644 node-tools/internal/approval/marks_test.go create mode 100644 node-tools/internal/firstview/approval.go create mode 100644 node-tools/internal/runtime/gate.go diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 7da745a..bd861b1 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -26,6 +26,7 @@ import ( "time" "github.com/novox/mesh-tools/node-tools/internal/alive" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/console" "github.com/novox/mesh-tools/node-tools/internal/firstview" @@ -51,7 +52,7 @@ func main() { cred := credential(until) conn := connectPatiently(cred, until) // And the seat list it describes the seats by, read through the seat mesh-catalog (novox/hq ADR 0636 §4). - _ = startSeatList(conn) + seatList := startSeatList(conn) served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module) if err != nil { @@ -69,6 +70,14 @@ func main() { defer ledgers.Stop() opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer)) } + // The indexer's and the assigner's states the console indexes the mesh by, and the gate reads its marks from, come to it as + // first views, asked of the owners' own tools and followed on their views families, never read from their state (novox/hq + // ADR 0615 §1; design 46 7-approval-mark §5). + source, stopViews := firstview.Start(context.Background(), conn, log.Printf) + defer stopViews() + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), + Findings: source.Findings(), Logf: log.Printf}) + opts = append(opts, runtime.WithGate(gate)) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { log.Fatalf("node-tools: %v", err) @@ -90,10 +99,6 @@ func main() { } var up *console.Listening if listen != "" { - // The indexer's and the assigner's states the console indexes the mesh by come to it as first views, asked of the owners' - // own tools and followed on their views families, never read from their state (novox/hq ADR 0615 §1). - source, stopViews := firstview.Start(context.Background(), conn, log.Printf) - defer stopViews() node := cred.Node if node == "" { node = "?" diff --git a/node-tools/cmd/node-tools/seatlist.go b/node-tools/cmd/node-tools/seatlist.go index 5d93ec3..a545710 100644 --- a/node-tools/cmd/node-tools/seatlist.go +++ b/node-tools/cmd/node-tools/seatlist.go @@ -71,3 +71,14 @@ func startSeatListOn(b seatlist.Bus, bound <-chan time.Time, logf func(string, . } return reader } + +// definitionsOf is the seat definitions the reader holds, by seat, which carry the verbs' marks (design 46 7-approval-mark +// §2, §5); none while the reader did not start. +func definitionsOf(reader *seatlist.Reader) func() map[string]seatdef.Definition { + return func() map[string]seatdef.Definition { + if reader == nil { + return nil + } + return reader.Definitions() + } +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go new file mode 100644 index 0000000..02f7d07 --- /dev/null +++ b/node-tools/internal/approval/gate.go @@ -0,0 +1,135 @@ +package approval + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The runner's findings of the gate (design 46 7-approval-mark §4, §16, §19), written through its held writer. +const ( + KindMarkUnserved = "mark-unserved" + KindCallUnanswered = "call-unanswered" + KindApprovalsUnreadable = "approvals-unreadable" +) + +// Kinds are the finding kinds the gate writes through the runner's writer. +var Kinds = []string{KindMarkUnserved, KindCallUnanswered, KindApprovalsUnreadable} + +// FindingWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding). +type FindingWriter interface { + Write(ctx context.Context, finding condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// Deps is what the gate reaches the mesh through: the runner's connection in its one place, fakes in a test. +type Deps struct { + // Node is the machine the runner serves; Runner its own module's name, the subject of its findings. + Node, Runner string + // Catalog is the catalog half the marks are read from. + Catalog Catalog + // Findings is the runner's held writer; nil says a finding in the log only. + Findings FindingWriter + Logf func(string, ...any) + Now func() time.Time +} + +// Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). +type Gate struct { + d Deps + mu sync.Mutex + // registered is each module's tools as its bundle registered them at its last load. + registered map[string]map[string]bool + // unserved is each module whose marks name a tool its bundle does not register, with those tools. + unserved map[string][]string +} + +// New is a gate over d. +func New(d Deps) *Gate { + if d.Logf == nil { + d.Logf = func(string, ...any) {} + } + if d.Now == nil { + d.Now = time.Now + } + return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} +} + +// Unserved is, of the module's marked tools, those its bundle does not register (§4): such a bundle is not loaded, its +// finding `mark-unserved` raised; a bundle that serves them, or marks that no longer name them, clear it. Before the marks +// are read nothing is known, and every marked call is refused until they are (§19). +func (g *Gate) Unserved(module, fingerprint string, registered []string) []string { + set := map[string]bool{} + for _, t := range registered { + set[t] = true + } + g.mu.Lock() + g.registered[module] = set + g.mu.Unlock() + if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { + return nil + } + return g.checkServed(Derive(g.d.Catalog, module, fingerprint)) +} + +// checkServed raises or clears the module's `mark-unserved` as its marks and its bundle's registrations stand now, and is +// the tools it marks and does not register. +func (g *Gate) checkServed(m Marks) []string { + g.mu.Lock() + set := g.registered[m.Module] + var missing []string + for t := range m.Tools { + if !set[t] { + missing = append(missing, t) + } + } + sort.Strings(missing) + was := g.unserved[m.Module] + if len(missing) > 0 { + g.unserved[m.Module] = missing + } else { + delete(g.unserved, m.Module) + } + g.mu.Unlock() + switch { + case len(missing) > 0 && strings.Join(was, ",") != strings.Join(missing, ","): + g.finding(m.Module, KindMarkUnserved, true, fmt.Sprintf("%s's marks (%s, catalog revision %s) name %s, which its bundle on %s does not "+ + "register: no tool of %s is served here", m.Module, sourceOf(m, missing[0]), m.Revision, strings.Join(missing, ", "), g.d.Node, m.Module), + fmt.Sprintf("%s is not served on %s", m.Module, g.d.Node)) + case len(missing) == 0 && len(was) > 0: + g.finding(m.Module, KindMarkUnserved, false, "a bundle that serves every marked tool, or marks without the tool, is loaded", "") + } + return missing +} + +func sourceOf(m Marks, tool string) string { return m.Tools[tool] } + +// finding writes or clears one of the gate's findings: scope machine, its id `.` escaped as a key's name is, +// about the runner's module on this machine (lead answer A-154). +func (g *Gate) finding(what, kind string, raised bool, summary, headline string) { + id := keys.EscapeName(g.d.Node + "." + what) + if g.d.Findings == nil { + g.d.Logf("[mesh-tools] %s %s: %s", kind, id, summary) + return + } + var err error + if raised { + err = g.d.Findings.Write(context.Background(), condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, + Severity: condseat.FindingSeverityWarning, Source: "approval", Observer: g.d.Runner, + Subject: condseat.Subject{Scope: "module", ID: g.d.Runner, Machine: g.d.Node}, Summary: summary, Headline: headline, + At: g.d.Now().UTC()}) + } else { + err = g.d.Findings.Clear(context.Background(), scopeMachine, id, kind, summary) + } + if err != nil { + g.d.Logf("[mesh-tools] the finding %s of %s is not kept: %v", kind, id, err) + } +} + +const scopeMachine = "machine" diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go new file mode 100644 index 0000000..a61d59a --- /dev/null +++ b/node-tools/internal/approval/marks.go @@ -0,0 +1,176 @@ +// Package approval is the tool runner's gate on a call that needs the operator's approval (design 46 7-approval-mark): the +// marks it reads from the catalog half, the act it digests, the ask it raises on the seat operator-channel under the +// marked module's name, the judging of the same call again on the router's record, the claim, the recording in the +// hand-act log, and the refusals when a part of the check is missing (§5, §8 to §20). No module's name is written here. +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Catalog is what the marks are read from: the catalog half the runner follows (design 46 7-approval-mark §5, design 47 G +// §196, §443) and the seat definitions it carries (§2). +type Catalog interface { + // Ready is nil once the catalog half's first view is read, else says what it waits for. + Ready() error + // Revision is the catalog revision the marks are read at, as the runner names it. + Revision() string + // Served is the entry of the build a bundle serves, by the fingerprint its composed environment carries; false when + // none is read (not yet, or not found), with why. + Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) + // Newest is the module's entry at the newest revision; false when the catalog half lists none. + Newest(module string) (catalogseat.CatalogEntry, bool) + // Definitions are the seat definitions the catalog half carries, by seat. + Definitions() map[string]seatdef.Definition +} + +// Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and +// restricted streams, each with its source (§18: its module's entry in the catalog half, or its seat's definition). +type Marks struct { + Module string + // Tools are its marked tools by name, Verbs its marked seat verbs as `.`, Subjects the bus subjects of its + // marked events and restricted streams (design 34 every-event §33); each to its source. + Tools, Verbs, Subjects map[string]string + // Revision is the catalog revision read; Served the served entry's fingerprint, empty when it could not be read. + Revision, Served string + // Unread says why the served entry could not be read: every marked call is then refused (§5, §19). + Unread string +} + +// Marked says whether a tool or a seat verb of the module needs the operator's approval, and its source. +func (m Marks) Marked(seat, name string) (bool, string) { + if seat == "" { + src, ok := m.Tools[name] + return ok, src + } + src, ok := m.Verbs[seat+"."+name] + return ok, src +} + +// Any says the module marks anything at all. +func (m Marks) Any() bool { return len(m.Tools)+len(m.Verbs)+len(m.Subjects) > 0 } + +// marked is what an entry's manifest says of its marks: `needs-approval`, its events and its streams. +type marked struct { + NeedsApproval []string `json:"needs-approval"` + Emits []string `json:"emits"` + // Streams are read for their events and whether each carries a `grant`, and no other word, so a word added to a + // stream's declaration later never hides its marks. + Streams []struct { + Events []string `json:"events"` + Grant json.RawMessage `json:"grant"` + } `json:"streams"` +} + +// Derive is a module's marks from its served entry and its newest one, unioned while they differ so a mark is never lost +// before or after the module moves (§5, ADR 0286 §3), and the seat definitions' verb marks for every verb an entry serves, +// which no holder unmarks (§2, §3). fingerprint is the build its bundle serves; empty, or an entry not read, leaves the +// marks of the newest entry and the seats, with Unread saying why. +func Derive(c Catalog, module, fingerprint string) Marks { + m := Marks{Module: module, Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}, + Revision: c.Revision()} + defs := c.Definitions() + var entries []catalogseat.CatalogEntry + if fingerprint == "" { + m.Unread = "its bundle's environment carries no fingerprint, so the build it serves cannot be read" + } else if e, ok, why := c.Served(fingerprint); ok { + entries, m.Served = append(entries, e), fingerprint + } else { + m.Unread = fmt.Sprintf("the catalog half's entry of %s cannot be read: %s", fingerprint, why) + } + if e, ok := c.Newest(module); ok && (len(entries) == 0 || e.Fp != entries[0].Fp) { + entries = append(entries, e) + } + for _, e := range entries { + source := "entry " + e.Module + " " + orNone(e.Fp) + var said marked + if raw, err := json.Marshal(e.Manifest); err == nil { + _ = json.Unmarshal(raw, &said) + } + serves := map[string]bool{} + for _, sv := range e.Serves { + serves[sv] = true + seat, verb, _ := strings.Cut(sv, ".") + for _, v := range defs[seat].Verbs { + if v.Name == verb && v.NeedsApproval { + m.Verbs[sv] = "seat " + seat + } + } + } + events := map[string]bool{} + for _, ev := range said.Emits { + events[ev] = true + } + for _, s := range said.Streams { + for _, ev := range s.Events { + events[ev] = true + } + } + for _, name := range said.NeedsApproval { + switch { + case serves[name]: + if _, bySeat := m.Verbs[name]; !bySeat { + m.Verbs[name] = source + } + case events[name]: + for _, s := range eventSubjects(e.Module, name) { + m.Subjects[s] = source + } + default: + m.Tools[name] = source + } + } + for _, s := range said.Streams { + if len(s.Grant) == 0 || string(s.Grant) == "null" { + continue + } + wire := make([]string, 0, len(s.Events)) + for _, ev := range s.Events { + wire = append(wire, buscompose.WirePattern(ev)) + } + for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { + m.Subjects[sub] = source + if !strings.HasSuffix(sub, ">") { + m.Subjects[sub+".>"] = source + } + } + } + } + return m +} + +// eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its +// own stream, each also with every subject below it. +func eventSubjects(module, event string) []string { + ev := buscompose.WirePattern(event) + out := []string{"mesh.mod." + module + ".event." + ev, buscompose.ModuleStreamSubject(module, ev)} + if !strings.HasSuffix(ev, ">") { + out = append(out, out[0]+".>", out[1]+".>") + } + sort.Strings(out) + return out +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +// keysOf is a set's names, sorted; nil when empty. +func keysOf(set map[string]string) []string { + var out []string + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go new file mode 100644 index 0000000..85053f6 --- /dev/null +++ b/node-tools/internal/approval/marks_test.go @@ -0,0 +1,136 @@ +package approval + +import ( + "context" + "reflect" + "testing" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// fakeCatalog is a catalog half as a fixture: entries by fingerprint, the newest by module, the seat definitions. +type fakeCatalog struct { + ready error + revision string + byFp map[string]catalogseat.CatalogEntry + newest map[string]catalogseat.CatalogEntry + defs map[string]seatdef.Definition +} + +func (c *fakeCatalog) Ready() error { return c.ready } +func (c *fakeCatalog) Revision() string { return c.revision } +func (c *fakeCatalog) Served(fp string) (catalogseat.CatalogEntry, bool, string) { + e, ok := c.byFp[fp] + return e, ok, "not-found" +} +func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { + e, ok := c.newest[m] + return e, ok +} +func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } + +func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry { + return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} +} + +// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from +// fixture catalog halves and served entries. +func TestTheMarks(t *testing.T) { + login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", + Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}} + v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"wipe"}}) + v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}}) + mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{ + "needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"}, + "streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}} + for _, tc := range []struct { + name string + cat *fakeCatalog + module string + fp string + tools []string + verbs map[string]string + subjects []string + unread bool + }{ + {name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}}, + {name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8", + byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9", + newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}, unread: true}, + {name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login}, + module: "shell", unread: true}, + {name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3", + byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}}, + module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>", + "mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Derive(tc.cat, tc.module, tc.fp) + if got := keysOf(m.Tools); !reflect.DeepEqual(got, tc.tools) { + t.Errorf("tools %v, want %v", got, tc.tools) + } + if tc.verbs == nil { + tc.verbs = map[string]string{} + } + if !reflect.DeepEqual(m.Verbs, tc.verbs) { + t.Errorf("verbs %v, want %v", m.Verbs, tc.verbs) + } + if got := keysOf(m.Subjects); !reflect.DeepEqual(got, tc.subjects) { + t.Errorf("subjects %v, want %v", got, tc.subjects) + } + if (m.Unread != "") != tc.unread || m.Revision != tc.cat.revision { + t.Errorf("unread %q, revision %q", m.Unread, m.Revision) + } + }) + } + // §4: a bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised; a bundle that + // serves it clears it, and a catalog without the mark too. + t.Run("mark-unserved raised and cleared", func(t *testing.T) { + cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login} + w := &findings{} + g := New(Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: w}) + if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != KindMarkUnserved { + t.Fatalf("unserved %v, findings %v", got, w.raised) + } + if got := g.Unserved("shell", "src2:1", []string{"wipe"}); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a bundle serving the mark: unserved %v, findings %v", got, w.raised) + } + _ = g.Unserved("shell", "src2:1", nil) + cat.byFp["src2:1"], cat.newest["shell"] = entry("src2:1", nil, nil), entry("src2:1", nil, nil) + if got := g.Unserved("shell", "src2:1", nil); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a catalog without the mark: unserved %v, findings %v", got, w.raised) + } + }) +} + +// findings is the runner's held writer as a fixture: each finding raised now, by id. +type findings struct{ raised map[string]string } + +func (f *findings) Write(_ context.Context, x condseat.Finding) error { + if f.raised == nil { + f.raised = map[string]string{} + } + f.raised[x.ID] = x.Kind + return nil +} +func (f *findings) Clear(_ context.Context, _, id, _, _ string) error { + delete(f.raised, id) + return nil +} diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go new file mode 100644 index 0000000..bf317d6 --- /dev/null +++ b/node-tools/internal/firstview/approval.go @@ -0,0 +1,83 @@ +package firstview + +import ( + "context" + "strconv" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "github.com/novox/mesh-tools/node-tools/internal/approval" +) + +// Findings is the runner's held finding writer, the one the gate writes its findings through too (design 46 7-approval-mark +// §4, §16, §19); nil when it was not made. +func (v *Views) Findings() approval.FindingWriter { + if v.deps.Findings == nil { + return nil + } + return v.deps.Findings +} + +// ApprovalCatalog is the catalog half as the gate reads its marks (design 46 7-approval-mark §5): the entries the branch +// follower holds, an entry by fingerprint asked of `mesh-catalog.entry` when no branch lists it, and the seat definitions +// the seat list holds (defs). +func (v *Views) ApprovalCatalog(defs func() map[string]seatdef.Definition) approval.Catalog { + return approvalCatalog{v: v, defs: defs} +} + +type approvalCatalog struct { + v *Views + defs func() map[string]seatdef.Definition +} + +func (c approvalCatalog) Ready() error { + if c.v.branches == nil { + return errNotFollowed + } + return c.v.branches.Ready() +} + +func (c approvalCatalog) Revision() string { + if c.v.branches == nil { + return "" + } + for _, branch := range c.v.branches.Followed() { + if cat, ok := c.v.branches.Catalog(branch); ok { + if half, ok := cat.View(); ok { + return strconv.FormatInt(half.Revision.Revision, 10) + } + } + } + return "" +} + +func (c approvalCatalog) Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false, errNotFollowed.Error() + } + e, state := c.v.branches.Resolve(context.Background(), fingerprint) + return e, state == meshcatalogclient.EntryAnswered, string(state) +} + +func (c approvalCatalog) Newest(module string) (catalogseat.CatalogEntry, bool) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false + } + e, ok, err := c.v.branches.Entry(module) + return e, ok && err == nil +} + +func (c approvalCatalog) Definitions() map[string]seatdef.Definition { + if c.defs == nil { + return nil + } + return c.defs() +} + +type notFollowed struct{} + +func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } + +var errNotFollowed error = notFollowed{} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..c187bcb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -28,6 +28,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" ) @@ -120,7 +121,7 @@ func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps { } w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ Asked: condseat.SubjectFindingsAsked, Module: me, Node: conn.Node(), State: findingsState{conn: conn, module: me}, JS: js, Logf: logf, - Kinds: []string{views.UnansweredKind}}) + Kinds: append([]string{views.UnansweredKind}, approval.Kinds...)}) if err != nil { logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err) return d diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go new file mode 100644 index 0000000..ed7cd5f --- /dev/null +++ b/node-tools/internal/runtime/gate.go @@ -0,0 +1,36 @@ +package runtime + +import ( + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/launch" +) + +// FingerprintWord is the word of a module's composed environment that names the build its bundle serves (design 47 G §424). +const FingerprintWord = "MESH_FINGERPRINT" + +// Gate is the approval gate the runner serves through (design 46 7-approval-mark §4, §20; node-tools/internal/approval). +type Gate interface { + // Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding. + Unserved(module, fingerprint string, registered []string) []string +} + +// WithGate serves every call through the approval gate. +func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } } + +var _ Gate = (*approval.Gate)(nil) + +// unservedMarks is, of the module's marked tools, those its bundle's registrations under its own name do not register. +func (r *runner) unservedMarks(module string, regs []launch.Registration) []string { + if r.events.gate == nil { + return nil + } + var registered []string + for _, reg := range regs { + if reg.Module == module { + for _, t := range reg.Tools { + registered = append(registered, t.Name) + } + } + } + return r.events.gate.Unserved(module, r.fingerprintOf(module), registered) +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index f3bc178..7dae3af 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -230,6 +230,10 @@ type runner struct { of map[string]*loadedModule verbs map[string]func() // each module's `tools` verb, where it is answered + // fingerprintOf is the build a module's bundle serves: the fingerprint its composed environment carries (design 47 G + // §424, §618), never the runner's own. + fingerprintOf func(module string) string + seatsAgain func() stops []func() // the watch, the announcement and the seats, in the order they began } @@ -263,6 +267,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log base := os.Environ() r.envFor = func(module string) []string { return BundleEnv(base, envs[module], module, node) } + r.fingerprintOf = func(module string) string { return envs[module][FingerprintWord] } // The module's events, for every child of it that subscribes (ADR 0198): one consumer per module, // bound the first time any of its children subscribes, each event handed to every child that did. @@ -409,6 +414,13 @@ func (r *runner) load(s Served) *loadedModule { fail(clashed) continue } + // A bundle whose marks name a tool it does not register is not loaded: no tool of its module is served here, rather + // than one served unmarked that the manifest meant to mark (design 46 7-approval-mark §4). + if unserved := r.unservedMarks(module, child.Registrations); len(unserved) > 0 { + child.Stop() + fail(fmt.Sprintf("its marks name %s, which it does not register (design 46 7-approval-mark §4)", strings.Join(unserved, ", "))) + continue + } l.children = append(l.children, child.Stop) for _, reg := range child.Registrations { got := registration{module: reg.Module, owner: module, tools: reg.Tools} @@ -772,6 +784,8 @@ type consumers struct { calls callsInFlight // sessions are the bundles' put and get sessions (sessions.go). sessions sessionTable + // gate is the approval gate every call is served through (gate.go); nil serves every call as before. + gate Gate } type moduleEvents struct { From 662ab2a3b1f54c11a8c8311d75af86ee3ca64216 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:06:36 +0100 Subject: [PATCH 04/16] E-3: mesh_wait takes any event name (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitAnswer no longer reads the membership: each name becomes its subjects through buscompose.EventSubjects, optionally narrowed by from (seat, module or stream), at most 16 names, a subject once; the heard event is named by buscompose.EventName with its namespace and its own time, and a decided event says the operator's answer is read only with claude_code_answer. WaitIssued and Membership.Waits go; the refusals are one table. Builds: design 34 every-event §13, §15, §17, §19, §26 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/bus/bus.go | 3 - node-tools/internal/bus/wait.go | 7 - node-tools/internal/console/wait.go | 87 +++++++----- node-tools/internal/console/wait_test.go | 172 +++++++++++++++-------- 4 files changed, 163 insertions(+), 106 deletions(-) diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 920189e..f430db6 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -75,9 +75,6 @@ type Membership struct { // Hears are the module streams the module hears, each through a durable consumer on that stream the // runtime binds beside the one on EVENTS (ADR 0363 §8). Hears []HeardIssued `json:"hears,omitempty"` - // Waits are the event families a session may wait on through the mesh MCP server (novox/hq ADR 0367 §2): - // the tool runner's alone, composed from its manifest with the grant that lets it subscribe them. - Waits []WaitIssued `json:"waits,omitempty"` // Bindings are the module's bus state in the new form, by local name (novox/hq ADR 0595 §1, mesh-sdk // membership.BusBinding): its object stores, streams, work queues and ledgers, its own alone (bindings.go). Bindings []membership.BusBinding `json:"bindings,omitempty"` diff --git a/node-tools/internal/bus/wait.go b/node-tools/internal/bus/wait.go index aa2e961..c42a667 100644 --- a/node-tools/internal/bus/wait.go +++ b/node-tools/internal/bus/wait.go @@ -17,13 +17,6 @@ import ( // subscribes the subjects when called and unsubscribes them before it answers, so an event published // between two calls is not heard — a session reads the state from its owner after every wake (§4). -// WaitIssued is one event family the mesh issued this account to wait on (ADR 0367 §2): the name a session -// asks for, and the subject it lands on. Only the tool runner's membership carries any. -type WaitIssued struct { - Event string `json:"event"` - Subject string `json:"subject"` -} - // Heard is one event a wait heard: the subject it arrived on, its body, when it arrived, and when it was published, the // event's own header `x-time` as its emitter wrote it, empty for an event that carries none (design 34 every-event §12). type Heard struct { diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index 1194b5b..c35af73 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -2,9 +2,9 @@ package console // mesh_wait: a session waits on the bus for one event instead of polling (novox/hq ADR 0367). // -// **Only the families the tool runner was issued** (§2, §6): its membership names them, composed by the -// mesh from the tool runner's manifest together with the grant that lets this account subscribe them. -// A name not listed there is refused here, before the bus would refuse it; no argument or setting adds one. +// **Any event, by its name** (novox/hq ADR 0670 §1, ADR 0671 §1; design 34 every-event §13, §26): the grants part +// grants this machine's tool runner every event subject, a seat's, a module's and a module stream's, and a name +// becomes its subjects through mesh-sdk's buscompose.EventSubjects; no membership and no manifest names a list. // **An event only wakes the session** (§4): what it says is read again from the verb that owns it. import ( @@ -13,9 +13,11 @@ import ( "encoding/json" "fmt" "math" - "sort" + "slices" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -25,20 +27,29 @@ var ( WaitLongest = 10 * time.Minute ) +// waitMostNames is the most event names one call gives (design 34 every-event §13, §26): each name is six +// subscriptions for the call, so 16 names are 96, far above what a watcher names. +const waitMostNames = 16 + func waitTool() map[string]any { return map[string]any{"name": verbWait, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{ "subjects": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, - "description": "one or more event families to wait on, by name, e.g. `mesh-knowledge.reviewed`; only " + - "those the mesh granted this machine's tool runner — a refusal names them"}, + "description": fmt.Sprintf("one or more event names, `.`, such as "+ + "`mesh-delivery-view.delivered`, at most %d; an event whose subject goes on after the name (an asker, a "+ + "kind, a module) is heard by the name too", waitMostNames)}, + "from": map[string]any{"type": "string", "enum": []string{"seat", "module", "stream"}, + "description": "narrows every name to a seat's events, a module's or a module stream's, for an emitter " + + "whose name is more than one of them (optional)"}, "filter": map[string]any{"type": "object", - "description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367} (optional)"}, + "description": "fields of the event's body that must equal the given values, e.g. {\"kind\": \"decision\", \"number\": 367}: the filter narrows by the body's fields (optional)"}, "timeout": map[string]any{"type": "number", "description": fmt.Sprintf("seconds to wait: %d when absent, %d at most", int(WaitDefault.Seconds()), int(WaitLongest.Seconds()))}, }, "required": []string{"subjects"}}, - "description": "Wait for the first event of the named families that matches the filter, and answer it with its " + - "family, body and time — or say it timed out. Call it in a loop from a background command, and after every " + - "answer read the state from the verb that owns it: an event only wakes you, and one published between two " + - "waits is not heard (novox/hq ADR 0367)."} + "description": "Wait for the first event of the named events that matches the filter, and answer it with its " + + "name, where it came from, body and times — or say it timed out. Call it in a loop from a background command, " + + "and after every answer read the state from the verb that owns it: an event only wakes you, and one " + + "published between two waits is not heard (novox/hq ADR 0367). An operator's answer is read only with " + + "claude_code_answer, never from the event."} } // waitAnswer answers one mesh_wait call. @@ -56,34 +67,25 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return failure("mesh_wait's filter is an object of body fields to the values they must equal") } - issued := map[string]string{} // name → subject - if m := s.conn.Membership(s.conn.Module()); m != nil { - for _, w := range m.Waits { - issued[w.Event] = w.Subject - } + from, _ := args["from"].(string) + if args["from"] != nil && from != "seat" && from != "module" && from != "stream" { + return failure(fmt.Sprintf("mesh_wait's from is seat, module or stream, not %v", args["from"])) } - if len(issued) == 0 { - return failure("this machine's tool runner was issued no event families to wait on: the mesh grants them from " + - "the tool runner's manifest (novox/hq ADR 0367 §2), and its membership names none yet") + if len(names) > waitMostNames { + return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+ + "subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names))) } - byName := make([]string, 0, len(issued)) - for name := range issued { - byName = append(byName, name) - } - sort.Strings(byName) - family := map[string]string{} // subject → name var subjects []string for _, name := range names { - subject, granted := issued[name] - if !granted { - return failure(fmt.Sprintf("mesh_wait may not wait on %q: this machine's tool runner is granted %s alone. "+ - "A family is added only by a reviewed change to the tool runner's manifest (node-tools' module.json, "+ - "novox/hq ADR 0367 §6); no argument, call or setting adds one", name, strings.Join(byName, ", "))) + subs, err := buscompose.EventSubjects(name, from) + if err != nil { + return failure("mesh_wait may not wait on it: " + err.Error()) } - if _, twice := family[subject]; !twice { - subjects = append(subjects, subject) + for _, subject := range subs { + if !slices.Contains(subjects, subject) { + subjects = append(subjects, subject) + } } - family[subject] = name } heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) @@ -95,10 +97,17 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names, "read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"} } else { - answer = map[string]any{ - "heard": map[string]any{"subject": family[heard.Subject], "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)}, - "read": "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)", + name, from, _ := buscompose.EventName(heard.Subject) + h := map[string]any{"subject": name, "from": from, "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)} + if heard.Published != "" { + h["published"] = heard.Published } + read := "an event only wakes you: read the state from the verb that owns it before acting (novox/hq ADR 0367 §4)" + if strings.HasPrefix(name, "operator-channel.decided") { + read = "an event only wakes you: the operator's answer is read only with claude_code_answer, never from " + + "the event's body (novox/hq ADR 0670 §5)" + } + answer = map[string]any{"heard": h, "read": read} } // A subscription the bus refused while others stood is said (design 34 every-event §16). if len(refused) > 0 { @@ -107,7 +116,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return answerText(answer) } -// waitNames is the families a call names: a list of names, or one name. +// waitNames is the event names a call gives: a list of names, or one name. func waitNames(given any) ([]string, error) { var names []string switch v := given.(type) { @@ -117,7 +126,7 @@ func waitNames(given any) ([]string, error) { for _, x := range v { name, ok := x.(string) if !ok { - return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-knowledge.reviewed; %v is not one", x) + return nil, fmt.Errorf("mesh_wait's subjects are event names, such as mesh-delivery-view.delivered; %v is not one", x) } names = append(names, name) } @@ -126,7 +135,7 @@ func waitNames(given any) ([]string, error) { names[i] = strings.TrimSpace(names[i]) } if len(names) == 0 { - return nil, fmt.Errorf("mesh_wait names at least one event family in `subjects`, such as mesh-knowledge.reviewed") + return nil, fmt.Errorf("mesh_wait names at least one event in `subjects`, such as mesh-delivery-view.delivered") } return names, nil } diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 862517f..a19522e 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -3,6 +3,7 @@ package console import ( "context" "encoding/json" + "fmt" "net/http" "os" "reflect" @@ -11,24 +12,22 @@ import ( "testing" "time" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) -// waitingRuntime is the tool runner on a machine, issued the families ADR 0367 §5 names, as the controller -// composes them from its manifest: the surface over its connection, and the mesh to publish on. +// waitingRuntime is the tool runner on a machine, issued no waits (novox/hq ADR 0670 §2): the surface over its +// connection, and the mesh to publish on. func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) { t.Helper() mesh := mt.New(t) - m := mt.MembershipOf("node-tools", "desk", false, nil) - m.Waits = []bus.WaitIssued{ - {Event: "mesh-knowledge.reviewed", Subject: "mesh.mod.mesh-knowledge.event.reviewed"}, - {Event: "operator-channel.decided.claude-code", Subject: "mesh.seat.operator-channel.event.decided.claude-code"}, - } - mesh.Issue(t, m) + mesh.Issue(t, mt.MembershipOf("node-tools", "desk", false, nil)) conn := connect(t, "node-tools", "desk") mt.Until(t, func() error { - if got := conn.Membership("node-tools"); got == nil || len(got.Waits) == 0 { + if conn.Membership("node-tools") == nil { return os.ErrNotExist } return nil @@ -36,6 +35,17 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) { return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh } +// listening waits until the surface's connection holds n more subscriptions than before: a wait has subscribed. +func listening(t *testing.T, s *Surface, before, n int) { + t.Helper() + mt.Until(t, func() error { + if s.conn.Subscriptions() < before+n { + return os.ErrNotExist + } + return nil + }) +} + // callWait is one tools/call of mesh_wait, answered as an agent host reads it. func callWait(s *Surface, args map[string]any) (text string, isError bool) { raw, _ := json.Marshal(map[string]any{"name": verbWait, "arguments": args}) @@ -50,8 +60,9 @@ func callWait(s *Surface, args map[string]any) (text string, isError bool) { return strings.Join(parts, "\n"), isErr } -// novox/hq ADR 0367 §1: mesh_wait answers the first event that matches — by its family and its filter — with -// its subject, body and time, within a second of its publication, and ignores the others. +// novox/hq ADR 0367 §1, ADR 0670 §1 (design 34 every-event §13, §15, §19): mesh_wait answers the first event that +// matches — by its name and its filter — with no membership naming it: its whole name, the namespace it arrived on, its +// body, when it arrived and when it was published, within a second of its publication, and ignores the others. func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) { s, mesh := waitingRuntime(t) type answer struct { @@ -59,18 +70,39 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) { isErr bool at time.Time } + type heardAnswer struct { + Heard struct { + Subject string `json:"subject"` + From string `json:"from"` + Body map[string]any `json:"body"` + At time.Time `json:"at"` + Published *string `json:"published"` + } `json:"heard"` + Read string `json:"read"` + } + nc, err := nats.Connect(mt.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) done := make(chan answer, 1) + before := s.conn.Subscriptions() go func() { - text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, - "filter": map[string]any{"kind": "decision", "number": 367}, "timeout": 20}) + text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, + "filter": map[string]any{"module": "netcheck"}, "timeout": 20}) done <- answer{text, isErr, time.Now()} }() - // Once it listens: another record's verdict, another family's event, then the one it waits for. - time.Sleep(300 * time.Millisecond) - mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 366, "what": "approved"}) - mesh.Emit(t, "mesh.mod.mesh-knowledge.event.proposed", map[string]any{"kind": "decision", "number": 367}) + // Once it listens: another module's delivery, another event, then the one it waits for, with its own time. + listening(t, s, before, 6) + mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.delivered", map[string]any{"module": "grafana"}) + mesh.Emit(t, "mesh.mod.mesh-delivery-view.event.planned", map[string]any{"module": "netcheck"}) + msg := nats.NewMsg("mesh.mod.mesh-delivery-view.event.delivered") + msg.Header.Set("x-time", "2026-11-12T03:00:00.5Z") + msg.Data = []byte(`{"module":"netcheck"}`) published := time.Now() - mesh.Emit(t, "mesh.mod.mesh-knowledge.event.reviewed", map[string]any{"kind": "decision", "number": 367, "what": "approved"}) + if err := nc.PublishMsg(msg); err != nil { + t.Fatal(err) + } var got answer select { case got = <-done: @@ -83,32 +115,32 @@ func TestMeshWaitAnswersTheFirstMatchingEvent(t *testing.T) { if got.at.Sub(published) > time.Second { t.Errorf("answered %s after the event was published, want within a second", got.at.Sub(published)) } - var heard struct { - Heard struct { - Subject string `json:"subject"` - Body map[string]any `json:"body"` - At time.Time `json:"at"` - } `json:"heard"` - } + var heard heardAnswer if err := json.Unmarshal([]byte(got.text), &heard); err != nil { t.Fatalf("not an answer: %s", got.text) } - if heard.Heard.Subject != "mesh-knowledge.reviewed" || heard.Heard.Body["number"] != float64(367) || heard.Heard.At.IsZero() { - t.Errorf("answered %+v, want record 367's verdict with its time", heard.Heard) + if h := heard.Heard; h.Subject != "mesh-delivery-view.delivered" || h.From != "module" || h.Body["module"] != "netcheck" || + h.At.IsZero() || h.Published == nil || *h.Published != "2026-11-12T03:00:00.5Z" || !strings.Contains(heard.Read, "only wakes you") { + t.Errorf("answered %s, want netcheck's delivery from a module with its times", got.text) } - // And the subject it is granted on the asker's own `decided`, by the name a session asks for. + // And a seat's `decided` of one asker, heard by `operator-channel.decided`: its whole name, from the seat, with no + // time of its own, and the answer read only with claude_code_answer. + before = s.conn.Subscriptions() go func() { - text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided.claude-code", "timeout": 20}) + text, isErr := callWait(s, map[string]any{"subjects": "operator-channel.decided", "timeout": 20}) done <- answer{text, isErr, time.Now()} }() - time.Sleep(300 * time.Millisecond) + listening(t, s, before, 6) // A seat's event, said by the router on core NATS here: no stream of the test's bus carries the seat. if err := connect(t, "messenger", "").Say("mesh.seat.operator-channel.event.decided.claude-code", []byte(`{"ask":"a1"}`)); err != nil { t.Fatal(err) } select { case got = <-done: - if got.isErr || !strings.Contains(got.text, `"subject": "operator-channel.decided.claude-code"`) { + heard = heardAnswer{} + _ = json.Unmarshal([]byte(got.text), &heard) + if h := heard.Heard; got.isErr || h.Subject != "operator-channel.decided.claude-code" || h.From != "seat" || + h.Published != nil || !strings.Contains(heard.Read, "claude_code_answer") { t.Errorf("the asker's decided: %s", got.text) } case <-time.After(10 * time.Second): @@ -148,38 +180,64 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) { } } -// novox/hq ADR 0367 §2 and §6: an event outside the granted families is refused, naming the families and how -// one is added; no argument — a wildcard, another asker's `decided`, a subject spelled out — widens it. -func TestMeshWaitRefusesAnEventOutsideTheGrantedFamilies(t *testing.T) { +// novox/hq ADR 0670 §1, §2 (design 34 every-event §13, §16, §19): mesh_wait refuses loudly and never loops: a name with +// a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a +// wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant. +func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { s, _ := waitingRuntime(t) - for _, subjects := range []any{ - []any{"mesh-knowledge.proposed"}, - []any{"mesh-knowledge.reviewed", "gitea.forge.push"}, - []any{"mesh-knowledge.*"}, - []any{"operator-channel.decided.shop"}, - []any{"operator-channel.decided.*"}, - []any{"mesh.mod.mesh-knowledge.event.reviewed"}, - []any{">"}, - []any{}, + var seventeen []any + for i := 0; i < 17; i++ { + seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i)) + } + for _, c := range []struct { + args map[string]any + says string + }{ + {map[string]any{"subjects": []any{"mesh-knowledge.*"}}, "[A-Za-z0-9_-]+"}, + {map[string]any{"subjects": []any{"operator-channel.decided.>"}}, "[A-Za-z0-9_-]+"}, + {map[string]any{"subjects": []any{"mesh-knowledge"}}, "fewer than two tokens"}, + {map[string]any{"subjects": seventeen}, "at most 16"}, + {map[string]any{"subjects": []any{}}, "at least one event"}, + {map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "from": "bench"}, "seat, module or stream"}, } { - text, isErr := callWait(s, map[string]any{"subjects": subjects, "timeout": 1}) - if !isErr { - t.Errorf("%v was waited on: %s", subjects, text) - continue + c.args["timeout"] = 1 + start := time.Now() + text, isErr := callWait(s, c.args) + if !isErr || !strings.Contains(text, c.says) || strings.Contains(text, "manifest") { + t.Errorf("%v: %s, want refused saying %q", c.args, text, c.says) } - if len(subjects.([]any)) > 0 && (!strings.Contains(text, "mesh-knowledge.reviewed") || !strings.Contains(text, "manifest")) { - t.Errorf("%v refused without naming the families and how one is added: %s", subjects, text) + if took := time.Since(start); took > 500*time.Millisecond { + t.Errorf("%v was refused after %s, want at once", c.args, took) } } -} -// A tool runner issued no families waits on nothing, and says so. -func TestMeshWaitWithNoFamiliesIssuedWaitsOnNothing(t *testing.T) { - mt.New(t) - s := NewSurface(connect(t, "node-tools", "desk"), nil, "desk.node-tools") - text, isErr := callWait(s, map[string]any{"subjects": []any{"mesh-knowledge.reviewed"}, "timeout": 1}) - if !isErr || !strings.Contains(text, "no event families") { - t.Errorf("a runtime issued no families: %s", text) + // The bus refusing every subscription of the call: answered after the timeout, in §16's words. + b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, + Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{ + Subscribe: &server.SubjectPermission{Allow: []string{"_INBOX.desk.node-tools.>", "mesh.assignment.desk.*"}}, + }}}}) + if err != nil { + t.Fatal(err) + } + go b.Start() + if !b.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + t.Cleanup(b.Shutdown) + conn, err := bus.Connect(bus.Credential{URL: b.ClientURL(), Module: "node-tools", Node: "desk", User: "desk.node-tools", Password: "pw"}) + if err != nil { + t.Fatal(err) + } + conn.Logf = func(string, ...any) {} + t.Cleanup(conn.Close) + start := time.Now() + text, isErr := callWait(NewSurface(conn, nil, "desk.node-tools"), map[string]any{"subjects": []any{"mesh-delivery-view.delivered"}, "timeout": 1}) + if took := time.Since(start); took < time.Second || took > 3*time.Second { + t.Errorf("a wait the bus refused whole answered after %s, want its timeout of 1s", took) + } + if !isErr || !strings.Contains(text, "the bus refused this account a subscription on mesh.seat.mesh-delivery-view.event.delivered") || + !strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") { + t.Errorf("a wait the bus refused whole: %s", text) } } From 9a8636a8edc1e112824ada387e165c1c7b2fe84f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:09:20 +0100 Subject: [PATCH 05/16] A-2 the first call: a marked call is asked on operator-channel under its module's name and not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate digests a marked call's act (address, node, module and tool or seat and verb, build, each argument as itself or its canonical JSON), refuses at once and asks nothing for a secret's shape or more than 16 KiB of arguments, and otherwise asks through operator-channel's client under the marked module's name, its ask kept in the runner's state approvals under the machine's keys with the act and the whole arguments, the act's key naming it. The ask shows each argument whole only where it may leave the mesh, and the first call answers Not run, with the router's record or without it within 5 seconds. Builds: design 46 7-approval-mark §8, §9, §10, §11, §12, §13 (keys) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/go.mod | 2 + node-tools/internal/approval/act.go | 291 ++++++++++++++++++ node-tools/internal/approval/approval_test.go | 276 +++++++++++++++++ node-tools/internal/approval/ask.go | 230 ++++++++++++++ node-tools/internal/approval/gate.go | 12 + 5 files changed, 811 insertions(+) create mode 100644 node-tools/internal/approval/act.go create mode 100644 node-tools/internal/approval/approval_test.go create mode 100644 node-tools/internal/approval/ask.go diff --git a/node-tools/go.mod b/node-tools/go.mod index 54e4d8b..e9db0f0 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -7,6 +7,8 @@ require ( git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 diff --git a/node-tools/internal/approval/act.go b/node-tools/internal/approval/act.go new file mode 100644 index 0000000..7f9445d --- /dev/null +++ b/node-tools/internal/approval/act.go @@ -0,0 +1,291 @@ +package approval + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves. +type Call struct { + // Address is the address as called: `.` or `.`. + Address string + // Module is the module whose bundle serves it; Seat the seat of a verb, empty for a tool; Name the tool or the verb. + Module, Seat, Name string + // Fingerprint is the build the bundle serves, from its composed environment (§5). + Fingerprint string + // Caller is the caller the bus proved. + Caller string + // Args are the call's arguments, a JSON object. + Args json.RawMessage + // Description is the tool's or verb's own description, whose first line the ask explains it by. + Description string +} + +// MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9). +const MostArguments = 16 * 1024 + +// arguments are a call's arguments by name; a body that is not an object is refused. +func arguments(raw json.RawMessage) (map[string]json.RawMessage, error) { + args := map[string]json.RawMessage{} + if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return args, nil + } + if err := json.Unmarshal(raw, &args); err != nil { + return nil, fmt.Errorf("its arguments are not a JSON object: %v", err) + } + return args, nil +} + +// refusedUnasked says why a marked call is refused at once, asking nothing (§9): its arguments hold a secret's shape, or are +// more than MostArguments. +func refusedUnasked(raw json.RawMessage) string { + if len(raw) > MostArguments { + return fmt.Sprintf("its arguments are %d bytes, more than the %d a call that needs the operator's approval may carry", len(raw), MostArguments) + } + if r, ok := outward.CheckSecret(string(raw)); !ok { + return fmt.Sprintf("its arguments hold %s, which no ask may carry", r.What) + } + return "" +} + +// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and +// `arg.` for each argument given, a string as itself and any other value as its JSON in RFC 8785's canonical form. +func ActOf(c Call, node string) (operatorchannelclient.Act, error) { + act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint} + if c.Seat == "" { + act["module"], act["tool"] = c.Module, c.Name + } else { + act["seat"], act["verb"] = c.Seat, c.Name + } + args, err := arguments(c.Args) + if err != nil { + return nil, err + } + for name, raw := range args { + var s string + if json.Unmarshal(raw, &s) == nil { + act["arg."+name] = s + continue + } + canon, err := Canonical(raw) + if err != nil { + return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err) + } + act["arg."+name] = canon + } + return act, nil +} + +// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16 +// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names. +func Canonical(raw json.RawMessage) (string, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return "", err + } + var b strings.Builder + if err := canonicalValue(&b, v); err != nil { + return "", err + } + return b.String(), nil +} + +func canonicalValue(b *strings.Builder, v any) error { + switch x := v.(type) { + case nil: + b.WriteString("null") + case bool: + b.WriteString(strconv.FormatBool(x)) + case json.Number: + f, err := strconv.ParseFloat(string(x), 64) + if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { + return fmt.Errorf("the number %s is not a double", x) + } + b.WriteString(es6Number(f)) + case string: + canonicalString(b, x) + case []any: + b.WriteByte('[') + for i, e := range x { + if i > 0 { + b.WriteByte(',') + } + if err := canonicalValue(b, e); err != nil { + return err + } + } + b.WriteByte(']') + case map[string]any: + names := make([]string, 0, len(x)) + for k := range x { + names = append(names, k) + } + sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) }) + b.WriteByte('{') + for i, k := range names { + if i > 0 { + b.WriteByte(',') + } + canonicalString(b, k) + b.WriteByte(':') + if err := canonicalValue(b, x[k]); err != nil { + return err + } + } + b.WriteByte('}') + default: + return fmt.Errorf("a value of type %T", v) + } + return nil +} + +// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3). +func es6Number(f float64) string { + if f == 0 { + return "0" + } + abs := math.Abs(f) + if abs >= 1e-6 && abs < 1e21 { + return strconv.FormatFloat(f, 'f', -1, 64) + } + s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07 + mant, exp, _ := strings.Cut(s, "e") + sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0") + return mant + "e" + sign + digits +} + +func canonicalString(b *strings.Builder, s string) { + b.WriteByte('"') + for _, r := range s { + switch { + case r == '"': + b.WriteString(`\"`) + case r == '\\': + b.WriteString(`\\`) + case r == '\b': + b.WriteString(`\b`) + case r == '\f': + b.WriteString(`\f`) + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\t': + b.WriteString(`\t`) + case r < 0x20: + fmt.Fprintf(b, `\u%04x`, r) + default: + b.WriteRune(r) + } + } + b.WriteByte('"') +} + +func utf16Less(a, b string) bool { + ua, ub := utf16Of(a), utf16Of(b) + for i := 0; i < len(ua) && i < len(ub); i++ { + if ua[i] != ub[i] { + return ua[i] < ub[i] + } + } + return len(ua) < len(ub) +} + +func utf16Of(s string) []uint16 { + out := make([]uint16, 0, utf8.RuneCountInString(s)) + for _, r := range s { + if r >= 0x10000 { + r -= 0x10000 + out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF))) + } else { + out = append(out, uint16(r)) + } + } + return out +} + +// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10). +func Fingerprint(digest string) string { + hex := strings.TrimPrefix(digest, "sha256:") + if len(hex) > 24 { + hex = hex[:24] + } + var groups []string + for len(hex) > 4 { + groups, hex = append(groups, hex[:4]), hex[4:] + } + return strings.Join(append(groups, hex), " ") +} + +// The bounds of the arguments shown in an ask (§11). +const ( + ShownBytes, ShownLines = 2000, 40 + PartBytes, PartLines = 1200, 20 +) + +// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole: +// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at +// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL. +func shown(raw json.RawMessage) (string, bool) { + args, _ := arguments(raw) + names := make([]string, 0, len(args)) + for n := range args { + names = append(names, n) + } + sort.Strings(names) + whole := true + var lines []string + for _, n := range names { + var value string + if json.Unmarshal(args[n], &value) != nil { + value = string(args[n]) + } + if r, ok := outward.Check(value); !ok { + value, whole = shapeOf(r), false + } + lines = append(lines, n+": "+value) + } + text := strings.Join(lines, "\n") + if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines { + if len(all) > PartLines { + all = all[:PartLines] + } + text = strings.Join(all, "\n") + if len(text) > PartBytes { + text = cutBytes(text, PartBytes) + } + text += "\nNOT SHOWN IN FULL" + whole = false + } + return text, whole +} + +// shapeOf is what an argument outward.Check refuses is shown as. +func shapeOf(r outward.Refusal) string { + switch r.Class { + case "path": + return "‹path›" + case "address": + return "‹address›" + } + return "‹withheld›" +} + +// cutBytes is s at most n bytes, cut at a character's end. +func cutBytes(s string, n int) string { + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go new file mode 100644 index 0000000..42b25ff --- /dev/null +++ b/node-tools/internal/approval/approval_test.go @@ -0,0 +1,276 @@ +package approval + +import ( + "encoding/json" + "sort" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// mem is the runner's state approvals as a fixture; fail makes every read and write fail, as a bus refusing them. +type mem struct { + mu sync.Mutex + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + fail error +} + +func newMem() *mem { return &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } + +func (m *mem) Get(k string) (*stdio.StateEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return nil, m.fail + } + v, ok := m.vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *mem) Create(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + if _, ok := m.vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *mem) Put(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + return m.put(k, v) +} +func (m *mem) Keys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return nil, m.fail + } + var out []string + for k := range m.vals { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} +func (m *mem) put(k string, v any) (uint64, error) { + raw, ok := v.(json.RawMessage) + if !ok { + raw, _ = json.Marshal(v) + } + m.n++ + m.vals[k], m.revs[k] = raw, m.n + return m.n, nil +} + +// world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's +// messenger, asks.go), the asks published, the clock. +type world struct { + g *Gate + state *mem + cat *fakeCatalog + records map[string]seat.AsksByAskerIDValue // by `.` + takes bool // the router takes an ask as it is published + asked []seat.Ask + now time.Time + runs int + findings *findings +} + +func newWorld() *world { + shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}}) + w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, + newest: map[string]catalogseat.CatalogEntry{"shell": shell}, + defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", + Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} + w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, + Now: func() time.Time { return w.now }, + After: func(d time.Duration) <-chan time.Time { + w.now = w.now.Add(d) + c := make(chan time.Time, 1) + c <- w.now + return c + }, + Publish: func(module, subject string, body any, id string) error { + if subject != seat.AcceptSubjectAsk(module) { + return nil + } + a := body.(seat.Ask) + w.asked = append(w.asked, a) + if w.takes { + w.records[module+"."+a.ID] = seat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", + Headline: a.Headline, Expires: a.Expires, Opened: operatorchannelclient.FormatTime(w.now), OfferedOn: []string{"telegram"}} + } + return nil + }, + Record: func(module, id string) (*stdio.StateEntry, error) { + rec, ok := w.records[module+"."+id] + if !ok { + return nil, nil + } + raw, _ := json.Marshal(rec) + return &stdio.StateEntry{Key: module + "." + id, Value: raw}, nil + }}) + _ = w.g.Unserved("shell", "src2:1", []string{"wipe", "tidy"}) + return w +} + +// call is a fixture call of the marked tool wipe, or of another tool of the module. +func call(name, args string) Call { + return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), + Description: "Wipes a scratch directory.\nMore words."} +} + +// theActAndItsAsk is §8's and §11's rows: the act's fields, and how an ask shows a call's arguments. +func theActAndItsAsk(t *testing.T) { + base := call("wipe", `{"dir":"scratch","n":1}`) + digest := func(c Call) string { + act, err := ActOf(c, "n1") + if err != nil { + t.Fatal(err) + } + d, _ := operatorchannelclient.ActDigest(act) + return d + } + act, _ := ActOf(base, "n1") + want := operatorchannelclient.Act{"address": "shell.wipe", "node": "n1", "module": "shell", "tool": "wipe", "build": "src2:1", + "arg.dir": "scratch", "arg.n": "1"} + if len(act) != len(want) { + t.Fatalf("act %v", act) + } + for k, v := range want { + if act[k] != v { + t.Errorf("act[%s] = %q, want %q", k, act[k], v) + } + } + seen := map[string]string{digest(base): "the call"} + for name, c := range map[string]Call{ + "an omitted argument": call("wipe", `{"dir":"scratch"}`), + "another number": call("wipe", `{"dir":"scratch","n":2}`), + "an object": call("wipe", `{"dir":{"name":"scratch"},"n":1}`), + "a changed build": func() Call { c := base; c.Fingerprint = "src2:2"; return c }(), + "another node's seat": {Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1"}, + } { + d := digest(c) + if other, dup := seen[d]; dup { + t.Errorf("%s gives the digest of %s", name, other) + } + seen[d] = name + } + if got, _ := Canonical(json.RawMessage(`{"b":[1.0,2.5e-7,1e21],"a":"é<"}`)); got != `{"a":"é<","b":[1,2.5e-7,1e+21]}` { + t.Errorf("canonical form %s", got) + } + // §11: a path, an address and a long argument are shown in their shape and cut. + text, whole := shown(json.RawMessage(`{"dir":"/home/someone/scratch","host":"10.0.0.7","ok":"yes"}`)) + if whole || text != "dir: ‹path›\nhost: ‹address›\nok: yes" { + t.Errorf("shown %q whole %v", text, whole) + } + long, _ := json.Marshal(map[string]string{"text": strings.Repeat("word ", 500)}) + if text, whole := shown(long); whole || !strings.HasSuffix(text, "NOT SHOWN IN FULL") || len(text) > PartBytes+len("\nNOT SHOWN IN FULL") { + t.Errorf("a long argument shown whole %v, %d bytes", whole, len(text)) + } + // §10: the headline, cut with … to 60 characters, ending Part shown. + for _, tc := range []struct{ name, node, want string }{ + {"wipe", "n1", "Run wipe on n1?"}, + {strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"}, + {strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"}, + } { + if got := headlineOf(tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { + t.Errorf("headline %q, want %q", got, tc.want) + } + } + if got := headlineOf("wipe", "n1", false); got != "Run wipe on n1? Part shown" { + t.Errorf("headline %q", got) + } +} + +// Design 46 7-approval-mark §22: the refusals and answers of a marked call, on the fixture mesh, the router's record and +// the warrant written as the router writes them. +func TestTheRefusalsAndAnswers(t *testing.T) { + t.Run("§8, §10, §11: the act's fields give each its digest, and the ask shows the arguments", theActAndItsAsk) + for _, tc := range []struct { + name string + run func(t *testing.T, w *world) + }{ + {"§9: a secret in the arguments is refused asking nothing", func(t *testing.T, w *world) { + _, err := w.g.firstCall(call("wipe", `{"token":"ghp_0123456789abcdefghijABCDEFGHIJ012345"}`)) + if err == nil || !strings.Contains(err.Error(), "which no ask may carry") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§9: more than 16 KiB of arguments is refused asking nothing", func(t *testing.T, w *world) { + big, _ := json.Marshal(map[string]string{"text": strings.Repeat("a b ", 5000)}) + _, err := w.g.firstCall(call("wipe", string(big))) + if err == nil || !strings.Contains(err.Error(), "more than the 16384") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§10, §12: the published ask's fields, and Not run with the router's record", func(t *testing.T, w *world) { + _, err := w.g.firstCall(call("wipe", `{"dir":"scratch"}`)) + if err == nil || !strings.HasPrefix(err.Error(), "Not run: this needs the operator's approval. Asked on telegram at ") || + !strings.Contains(err.Error(), "make the same call again within 30 minutes of the approval; the ask expires at ") { + t.Fatalf("answer %v", err) + } + if len(w.asked) != 1 { + t.Fatalf("asked %d", len(w.asked)) + } + a := w.asked[0] + act, _ := ActOf(call("wipe", `{"dir":"scratch"}`), "n1") + d, _ := operatorchannelclient.ActDigest(act) + if a.About != d || a.Who != "operator" || a.OnExpiry != OnExpiry || a.Headline != "Run wipe on n1?" || + a.Expires != operatorchannelclient.FormatTime(time.Date(2026, 11, 12, 11, 0, 0, 0, time.UTC)) || len(a.Options) != 2 || + a.Options[0].ID != "approve" || a.Options[1].ID != "decline" || a.Options[0].Binds != d || a.Options[1].Binds != d || + a.Options[1].Level != "approve" { + t.Errorf("ask %+v", a) + } + if !strings.HasPrefix(a.Explanation, "Wipes a scratch directory.\nOn n1, called through the mesh's tools, which cannot tell who called.\ndir: scratch\nFingerprint ") { + t.Errorf("explanation %q", a.Explanation) + } + var k operatorchannelclient.Kept + _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.vals["n1.act."+d]) != `"`+a.ID+`"` { + t.Errorf("kept %+v, act key %s", k, w.state.vals["n1.act."+d]) + } + }}, + {"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) { + w.takes = false + start := w.now + _, err := w.g.firstCall(call("wipe", `{}`)) + if err == nil || !strings.HasPrefix(err.Error(), "Not run, asked; not yet taken by the router (ask ") || w.now.Sub(start) > RecordWithin { + t.Fatalf("answer %v after %s", err, w.now.Sub(start)) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) + } +} diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go new file mode 100644 index 0000000..a183f3f --- /dev/null +++ b/node-tools/internal/approval/ask.go @@ -0,0 +1,230 @@ +package approval + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// StateName is the runner's own state its asks and claims are kept in, per machine (§13). +const StateName = "approvals" + +// Store is the runner's state `approvals` (§13): a key's value, a create that refuses a key that exists and an update by +// compare-and-set, each failing with stdio.ErrChanged when another write came first; a put, and its keys. +type Store interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) + Put(key string, value any) (uint64, error) + Keys() ([]string, error) +} + +// The bounds of an ask (§10, §12, §13, §14, §17). +const ( + AskLasts = 60 * time.Minute + RecordWithin = 5 * time.Second + RunWithin = 30 * time.Minute + DeclinedFor = 10 * time.Minute + MostKeptValue = 256 * 1024 + OnExpiry = "nothing runs; call again to ask anew" +) + +// The options every ask offers (§10), both at level approve, both binding the act's digest. +const ( + OptionApprove = "approve" + OptionDecline = "decline" +) + +// client is operator-channel's client asking under the marked module's name, its asks kept in the runner's state under +// this machine's keys, its publish and the router's record on the runner's own account (§7, §10). +func (g *Gate) client(module string) *operatorchannelclient.Client { + return &operatorchannelclient.Client{Module: module, State: g.d.State, KeyPrefix: g.d.Node + ".", Now: g.d.Now, + Publish: func(subject string, body any, id string) error { return g.d.Publish(module, subject, body, id) }, + Record: func(id string) (*stdio.StateEntry, error) { return g.d.Record(module, id) }} +} + +// actKey is the key naming the newest ask of an act (§13). +func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + digest } + +// askKey is the key the client keeps an ask under (§13). +func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id } + +// newID is an ask's id: 128 random bits. +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// askFor is the ask a marked call raises (§10, §11). +func (g *Gate) askFor(c Call, digest, id string) seat.Ask { + args, whole := shown(c.Args) + name := c.Name + if c.Seat != "" { + name = c.Seat + "." + c.Name + } + headline := headlineOf(name, g.d.Node, whole) + first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n") + if first == "" { + first = name + } + lines := []string{first, "On " + g.d.Node + ", called through the mesh's tools, which cannot tell who called."} + if args != "" { + lines = append(lines, args) + } + if !whole { + lines = append(lines, "The whole is read with mesh_approvals on "+g.d.Node+", whose fingerprint must be the one on the phone.") + } + lines = append(lines, "Fingerprint "+Fingerprint(digest)) + return seat.Ask{ID: id, About: digest, Who: operatorchannelclient.Operator, Headline: headline, + Explanation: strings.Join(lines, "\n"), Expires: operatorchannelclient.FormatTime(g.d.Now().Add(AskLasts)), OnExpiry: OnExpiry, + Options: []seat.Option{ + {ID: OptionApprove, Label: "Approve", Does: "the same call, made again within 30 minutes, runs once", + Level: operatorchannelclient.Approve, Binds: digest}, + {ID: OptionDecline, Label: "Decline", Does: "nothing runs; the same call is not asked again for 10 minutes", + Level: operatorchannelclient.Approve, Binds: digest}}} +} + +// headlineOf is "Run on ?", "Run ?" when that is longer than the router allows, and the names cut with +// "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). +func headlineOf(name, node string, whole bool) string { + suffix := "" + if !whole { + suffix = " Part shown" + } + max := operatorchannelclient.HeadlineLength + fits := func(s string) bool { return len([]rune(s)) <= max } + if h := "Run " + name + " on " + node + "?" + suffix; fits(h) { + return h + } + if h := "Run " + name + "?" + suffix; fits(h) { + return h + } + room := max - len([]rune("Run …?"+suffix)) + r := []rune(name) + if room < 1 { + room = 1 + } + if len(r) > room { + r = r[:room] + } + return "Run " + string(r) + "…?" + suffix +} + +// firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else +// asked (§10, §12). +func (g *Gate) firstCall(c Call) (any, error) { + if why := refusedUnasked(c.Args); why != "" { + return nil, g.refused(c, "", why) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + return nil, g.raise(c, act, digest, "") +} + +// raise raises the ask of a marked call whose act has none open (§10): kept in the runner's state with the act and the whole +// arguments, published under the module's name, its act's key naming it; then the 5 seconds for the router's record and +// the first answer (§12), always an error, never a result. +func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error { + id := newID() + a := g.askFor(c, digest, id) + cl := g.client(c.Module) + if size := keptSize(a, act, c.Args); size > MostKeptValue { + return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName)) + } + if err := cl.Ask(a); err != nil { + return g.refused(c, digest, "the ask could not be raised: "+err.Error()) + } + if err := g.keepCall(id, act, c.Args); err != nil { + return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) + } + if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) + } + return g.firstAnswer(c, id, digest, markedSince) +} + +// keptSize is the bytes the kept ask would take. +func keptSize(a seat.Ask, act operatorchannelclient.Act, args json.RawMessage) int { + raw, _ := json.Marshal(operatorchannelclient.Kept{Ask: a, Act: act, Arguments: args}) + return len(raw) +} + +// keepCall writes the act and the whole arguments beside the ask the client kept (§13). +func (g *Gate) keepCall(id string, act operatorchannelclient.Act, args json.RawMessage) error { + e, err := g.d.State.Get(g.askKey(id)) + if err != nil || e == nil { + return fmt.Errorf("the kept ask %s cannot be read: %v", id, err) + } + var k operatorchannelclient.Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return err + } + k.Act, k.Arguments = act, args + _, err = g.d.State.Update(g.askKey(id), k, e.Revision) + return err +} + +// firstAnswer waits up to RecordWithin for the router's record of the ask and says the call was not run (§12). +func (g *Gate) firstAnswer(c Call, id, digest, markedSince string) error { + rec, _ := g.awaitRecord(c.Module, id) + since := "" + if markedSince != "" { + since = "; marked since revision " + markedSince + } + if rec == nil { + return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make the same call again later%s", + id, Fingerprint(digest), since) + } + return fmt.Errorf("Not run: this needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make the same call "+ + "again within 30 minutes of the approval; the ask expires at %s%s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, + id, Fingerprint(digest), rec.Expires, since) +} + +// record is the router's record of an ask under the module's name, nil when it holds none. +func (g *Gate) record(module, id string) (*seat.AsksByAskerIDValue, error) { + e, err := g.d.Record(module, id) + if err != nil || e == nil { + return nil, err + } + var rec seat.AsksByAskerIDValue + if err := json.Unmarshal(e.Value, &rec); err != nil { + return nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err) + } + return &rec, nil +} + +// awaitRecord reads the router's record of a new ask until it is there, within RecordWithin: the seat answers a key's +// value and says nothing when the router takes an ask, so it is read again after a growing pause (§12). +func (g *Gate) awaitRecord(module, id string) (*seat.AsksByAskerIDValue, error) { + until := g.d.Now().Add(RecordWithin) + pause := 50 * time.Millisecond + for { + rec, err := g.record(module, id) + if rec != nil || g.d.Now().Add(pause).After(until) { + return rec, err + } + <-g.d.After(pause) + pause *= 2 + } +} + +// refused is a marked call refused, run nothing, written in the runner's log with the ask's fingerprint and never its +// arguments (§19). +func (g *Gate) refused(c Call, digest, why string) error { + g.d.Logf("[mesh-tools] %s on %s is refused (fingerprint %s): %s", c.Address, g.d.Node, orNone(Fingerprint(digest)), why) + return fmt.Errorf("Not run: %s needs the operator's approval and %s", c.Address, why) +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 02f7d07..0aa5e32 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -9,6 +9,7 @@ import ( "time" condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" ) @@ -38,6 +39,14 @@ type Deps struct { Findings FindingWriter Logf func(string, ...any) Now func() time.Time + // After is the clock's pause; nil is time.After. + After func(time.Duration) <-chan time.Time + // State is the runner's state approvals; nil when its membership binds none, so every marked call is refused (§13). + State Store + // Publish says an accept on a seat under the module's name, on the runner's own account (§7). + Publish func(module, subject string, body any, id string) error + // Record reads the router's record of an ask, `.` in the seat's state asks, on the runner's own account (§14). + Record func(module, id string) (*stdio.StateEntry, error) } // Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). @@ -58,6 +67,9 @@ func New(d Deps) *Gate { if d.Now == nil { d.Now = time.Now } + if d.After == nil { + d.After = time.After + } return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} } From 0b0f879e86f1225e7d0a8428989fdc5f31a90308 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:11:52 +0100 Subject: [PATCH 06/16] E-4: a misspelt event name is refused, not waited on for ever (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before it subscribes, mesh_wait checks each name's emitter against the catalog half the tool runner holds (its seats' definitions and its modules' entries), refusing one neither knows and naming the half and its revisions; a known emitter's undeclared event is waited on and its timed-out answer says so; while the half is not yet read the answer says the names were unchecked. The first views answer the halves by branch for it. Builds: design 34 every-event §14, §19 (rows of the table of refusals) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/wait.go | 110 +++++++++++++++++- node-tools/internal/console/wait_test.go | 35 +++++- node-tools/internal/firstview/firstview.go | 19 +++ .../internal/firstview/firstview_test.go | 4 + 4 files changed, 166 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index c35af73..a93a16d 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -17,7 +17,9 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -75,12 +77,29 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return failure(fmt.Sprintf("mesh_wait names at most %d events in one call, not %d: each name is six "+ "subscriptions for the call (design 34 every-event §13)", waitMostNames, len(names))) } - var subjects []string + known, unchecked := s.emitters() + var subjects, undeclared []string for _, name := range names { subs, err := buscompose.EventSubjects(name, from) if err != nil { return failure("mesh_wait may not wait on it: " + err.Error()) } + // A misspelt emitter is refused, never waited on for ever; a known emitter's undeclared event is waited on and + // its timed-out answer says so (design 34 every-event §14). + if unchecked == "" { + emitter, event, _ := strings.Cut(name, ".") + seatEvents, isSeat := known.seats[emitter] + moduleEvents, isModule := known.modules[emitter] + isSeat = isSeat && from != "module" && from != "stream" + isModule = isModule && from != "seat" + if !isSeat && !isModule { + return failure(fmt.Sprintf("mesh_wait may not wait on %q: %s is neither a seat nor a module of %s", + name, emitter, known.source)) + } + if !(isSeat && declares(seatEvents, event)) && !(isModule && declares(moduleEvents, event)) { + undeclared = append(undeclared, name) + } + } for _, subject := range subs { if !slices.Contains(subjects, subject) { subjects = append(subjects, subject) @@ -96,6 +115,10 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin if !ok { answer = map[string]any{"timed-out": true, "waited": timeout.String(), "subjects": names, "read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"} + if len(undeclared) > 0 { + answer["undeclared"] = fmt.Sprintf("no declaration names %s: no seat definition and no module's entry in %s "+ + "declares that event", strings.Join(undeclared, ", "), known.source) + } } else { name, from, _ := buscompose.EventName(heard.Subject) h := map[string]any{"subject": name, "from": from, "body": heard.Body, "at": heard.At.Format(time.RFC3339Nano)} @@ -109,6 +132,9 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin } answer = map[string]any{"heard": h, "read": read} } + if unchecked != "" { + answer["unchecked"] = "the names were not checked: " + unchecked + } // A subscription the bus refused while others stood is said (design 34 every-event §16). if len(refused) > 0 { answer["refused"] = refused @@ -116,6 +142,88 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin return answerText(answer) } +// halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait +// checks an event name's emitter against it (design 34 every-event §14). +type halfReader interface { + Halves() (map[string]meshcatalogclient.Half, error) +} + +// knownEmitters is what the catalog half says emits what: each seat's declared events and each module's, and the +// source a refusal names, with its revisions. +type knownEmitters struct { + seats, modules map[string][]string + source string +} + +// emitters is the catalog half's emitters, or why they are not checked: the half not yet read (its first view, +// mesh_first_views), or no half held at all. +func (s *Surface) emitters() (known knownEmitters, unchecked string) { + hr, ok := s.src.(halfReader) + if !ok { + return known, "this tool runner holds no catalog half" + } + halves, err := hr.Halves() + if err != nil { + return known, "the catalog half is not yet read (" + err.Error() + "); its first view is in mesh_first_views" + } + seatEvents := map[string][]string{} // a seat's events, as its definition in the seat list declares them + for _, st := range catalogue.Seats() { + seatEvents[st.Name] = st.Emits + } + known.seats, known.modules = map[string][]string{}, map[string][]string{} + branches := make([]string, 0, len(halves)) + for b := range halves { + branches = append(branches, b) + } + slices.Sort(branches) + var revisions []string + for _, b := range branches { + half := halves[b] + revisions = append(revisions, fmt.Sprintf("revision %d of %s", half.Revision.Revision, b)) + for _, d := range half.Revision.Definitions { + known.seats[d.Seat] = seatEvents[d.Seat] + } + for m, entry := range half.Entries { + if _, taken := known.modules[m]; !taken { + known.modules[m] = eventsDeclared(entry.Manifest) + } + } + } + known.source = "the catalog half (mesh-catalog.revision, " + strings.Join(revisions, "; ") + ")" + return known, "" +} + +// eventsDeclared is the events a module's manifest declares: its `emits` and its streams' `events`. +func eventsDeclared(manifest map[string]any) []string { + var out []string + add := func(list any) { + items, _ := list.([]any) + for _, x := range items { + if e, ok := x.(string); ok { + out = append(out, e) + } + } + } + add(manifest["emits"]) + streams, _ := manifest["streams"].([]any) + for _, st := range streams { + if m, ok := st.(map[string]any); ok { + add(m["events"]) + } + } + return out +} + +// declares says whether one of the declared events is the event a name gives, or one its name is followed by. +func declares(declared []string, event string) bool { + for _, d := range declared { + if buscompose.Overlaps(d, event) || buscompose.Overlaps(d, event+".>") { + return true + } + } + return false +} + // waitNames is the event names a call gives: a list of names, or one name. func waitNames(given any) ([]string, error) { var names []string diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index a19522e..21bd105 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -35,6 +37,14 @@ func waitingRuntime(t *testing.T) (*Surface, *mt.Mesh) { return NewSurface(conn, busSource{conn}, "desk.node-tools"), mesh } +// halfSource is a Source that also holds a catalog half, as the tool runner's first views do. +type halfSource struct { + Source + halves map[string]meshcatalogclient.Half +} + +func (h halfSource) Halves() (map[string]meshcatalogclient.Half, error) { return h.halves, nil } + // listening waits until the surface's connection holds n more subscriptions than before: a wait has subscribed. func listening(t *testing.T, s *Surface, before, n int) { t.Helper() @@ -157,7 +167,8 @@ func TestMeshWaitSaysItTimedOutAndKeepsNothing(t *testing.T) { if took := time.Since(start); took < time.Second || took > 3*time.Second { t.Errorf("a one-second wait took %s", took) } - if isErr || !strings.Contains(text, `"timed-out": true`) { + // Before the catalog half is read, names are not checked, and the answer says so (design 34 every-event §14). + if isErr || !strings.Contains(text, `"timed-out": true`) || !strings.Contains(text, `"unchecked"`) { t.Errorf("a wait that heard nothing: %s", text) } if after := s.conn.Subscriptions(); after != before { @@ -211,6 +222,28 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { } } + // E §14: against the catalog half, a misspelt emitter is refused naming the half and its revision, a known emitter's + // undeclared event is waited on and its timeout says so, and a declared one says nothing of it. + half := NewSurface(s.conn, halfSource{busSource{s.conn}, map[string]meshcatalogclient.Half{"main": { + Revision: catalogseat.CatalogRevision{Revision: 7, Definitions: []catalogseat.CatalogRevisionDefinitionsItem{{Seat: "mesh-catalog"}}}, + Entries: map[string]catalogseat.CatalogEntry{"mesh-delivery-view": {Module: "mesh-delivery-view", + Manifest: map[string]any{"emits": []any{"delivered"}}}}, + }}}, "desk.node-tools") + for _, c := range []struct { + name, says string + refused bool + }{ + {"mesh-delivry-view.delivered", "mesh-delivry-view is neither a seat nor a module of the catalog half (mesh-catalog.revision, revision 7 of main)", true}, + {"mesh-delivery-view.planned", "no declaration names mesh-delivery-view.planned", false}, + {"mesh-catalog.catalog-revision", "", false}, + } { + text, isErr := callWait(half, map[string]any{"subjects": []any{c.name}, "timeout": 1}) + if isErr != c.refused || c.says != "" && !strings.Contains(text, c.says) || + c.says == "" && (strings.Contains(text, "undeclared") || strings.Contains(text, "unchecked")) { + t.Errorf("%s against the catalog half: %s", c.name, text) + } + } + // The bus refusing every subscription of the call: answered after the timeout, in §16's words. b, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoLog: true, NoSigs: true, Users: []*server.User{{Username: "desk.node-tools", Password: "pw", Permissions: &server.Permissions{ diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..ff5b5eb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -437,6 +437,25 @@ func (w wiring) Drop(owner, state string) error { return nil } +// Halves is the catalog half of each followed branch, by branch: its revision, with the seat definitions it was judged +// with, and its entries; mesh_wait checks an event name's emitter against them (design 34 every-event §14). An error says +// the half has no first view yet, never that no module exists. +func (v *Views) Halves() (map[string]meshcatalogclient.Half, error) { + if v.branches == nil { + return nil, fmt.Errorf("the tool runner does not follow the catalog half") + } + if err := v.branches.Ready(); err != nil { + return nil, err + } + out := map[string]meshcatalogclient.Half{} + for _, branch := range v.branches.Followed() { + if c, ok := v.branches.Catalog(branch); ok { + out[branch], _ = c.View() // a branch at none-yet is ready and empty (§614) + } + } + return out, nil +} + // Modules is every module the catalog half holds, by name, with its entry: the entries of the watched branches' newest trunk // revisions, the branch followed first naming a module more than one hold (design 47 G §196, §594). An error says the half has // no first view yet, never that no module exists. diff --git a/node-tools/internal/firstview/firstview_test.go b/node-tools/internal/firstview/firstview_test.go index 997203b..18c7698 100644 --- a/node-tools/internal/firstview/firstview_test.go +++ b/node-tools/internal/firstview/firstview_test.go @@ -158,6 +158,10 @@ func TestTheRunnerHoldsTheFourStatesFromTheOwnersFirstViewsAndTheirEvents(t *tes t.Fatal(err) } eventually(t, "the catalog-revision was not taken", func() bool { m, err := v.Modules(); return err == nil && len(m) == 2 }) + // And the halves by branch, with their revision, which mesh_wait names checking an emitter (design 34 every-event §14). + if h, err := v.Halves(); err != nil || h["main"].Revision.Revision != 4 || len(h["main"].Entries) != 2 { + t.Errorf("the halves: %+v, %v", h, err) + } // Events: the next revision is applied; an old one passed over; a deletion removes; none of it asks again. asked := func(tool string) int { o.mu.Lock(); defer o.mu.Unlock(); return o.asked[tool] } From e0cfcabe60e52775bf4097a541fd24eaf2e91ac3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:13:18 +0100 Subject: [PATCH 07/16] A-3 judge and claim: the same call again runs once on the router's record of its Approve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate sits on both handlers the runner serves, a module's tools (its ledger repairs among them) and the seat verbs its modules hold, before the bundle. The same call again is judged on the router's own record of its act's newest ask: held to the warrant (operatorchannelclient.For), the option and the act it bound (Performs), within 30 minutes of the choice, and claimed by compare-and-set (Client.Take) before it runs, so one approval runs one call. A later call is told it still waits, was declined (not asked again for 10 minutes), or asks anew after expired, approved too long ago or already run. A runner's own tool marked is refused unasked; with no state approvals bound a marked call is refused and an unmarked one runs. The gate's reach (state, publish, the router's record) is the runner's own connection, in one place; it waits for the catalog half's first view on the events the runner hears. Builds: design 46 7-approval-mark §13, §14, §15, §17, §20 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 101 ++++++++++ node-tools/cmd/node-tools/main.go | 5 +- node-tools/internal/approval/act.go | 2 + node-tools/internal/approval/approval_test.go | 162 ++++++++++++++++ node-tools/internal/approval/gate.go | 4 + node-tools/internal/approval/run.go | 183 ++++++++++++++++++ node-tools/internal/firstview/approval.go | 74 +++++++ node-tools/internal/firstview/firstview.go | 6 +- node-tools/internal/runtime/gate.go | 14 ++ node-tools/internal/runtime/runtime.go | 16 +- 10 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 node-tools/cmd/node-tools/approvals.go create mode 100644 node-tools/internal/approval/run.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go new file mode 100644 index 0000000..3b3b4ed --- /dev/null +++ b/node-tools/cmd/node-tools/approvals.go @@ -0,0 +1,101 @@ +package main + +import ( + "encoding/json" + "errors" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/bus" +) + +// The gate's reach through the runner's own connection, in this one place (design 46 7-approval-mark §7, §13, §14): its +// state approvals, its publish of an ask under the marked module's name, and its read of the router's record. + +// approvalsState is the runner's state approvals over its connection: a lost compare-and-set is stdio.ErrChanged, as +// operator-channel's client reads it. +type approvalsState struct { + conn *bus.Conn + module string +} + +func (s approvalsState) Get(key string) (*stdio.StateEntry, error) { + e, err := s.conn.StateGet(s.module, approval.StateName, key) + if err != nil || e == nil { + return nil, err + } + return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil +} + +func (s approvalsState) Create(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return changedAs(s.conn.StateCreate(s.module, approval.StateName, key, raw)) +} + +func (s approvalsState) Update(key string, value any, revision uint64) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return changedAs(s.conn.StateUpdate(s.module, approval.StateName, key, raw, revision)) +} + +func (s approvalsState) Put(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + return s.conn.StatePut(s.module, approval.StateName, key, raw) +} + +func (s approvalsState) Keys() ([]string, error) { + return s.conn.StateKeys(s.module, approval.StateName) +} + +func changedAs(rev uint64, err error) (uint64, error) { + if errors.Is(err, bus.ErrStateChanged) { + return 0, stdio.ErrChanged + } + return rev, err +} + +// Bound says the runner's membership binds its state approvals now (§13): a membership issued later binds it with no restart. +func (s approvalsState) Bound() bool { + m := s.conn.Membership(s.module) + if m == nil { + return false + } + for _, st := range m.State { + if st.Name == approval.StateName && st.Writes { + return true + } + } + return false +} + +// publishAsk says an accept on a seat under the module's name, on the runner's own account (§7). +func publishAsk(conn *bus.Conn) func(module, subject string, body any, id string) error { + return func(module, subject string, body any, id string) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + _, err = conn.SeatPublish(module, subject, raw, id) + return err + } +} + +// routerRecord reads the router's record `.` of the seat's state asks, on the runner's own account (§14). +func routerRecord(conn *bus.Conn) func(module, id string) (*stdio.StateEntry, error) { + return func(module, id string) (*stdio.StateEntry, error) { + e, err := conn.SeatRecord(module, "", id) + if err != nil || e == nil { + return nil, err + } + return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil + } +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index bd861b1..78e15cd 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -75,8 +75,9 @@ func main() { // ADR 0615 §1; design 46 7-approval-mark §5). source, stopViews := firstview.Start(context.Background(), conn, log.Printf) defer stopViews() - gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), - Findings: source.Findings(), Logf: log.Printf}) + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, + Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, + Publish: publishAsk(conn), Record: routerRecord(conn)}) opts = append(opts, runtime.WithGate(gate)) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { diff --git a/node-tools/internal/approval/act.go b/node-tools/internal/approval/act.go index 7f9445d..5ad9df8 100644 --- a/node-tools/internal/approval/act.go +++ b/node-tools/internal/approval/act.go @@ -28,6 +28,8 @@ type Call struct { Args json.RawMessage // Description is the tool's or verb's own description, whose first line the ask explains it by. Description string + // Own says it is one of the runner's own tools served under the module's name (its ledger repairs), not its bundle's. + Own bool } // MostArguments is the most a marked call's arguments may hold: more is refused, asking nothing (§9). diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 42b25ff..95ed780 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -145,6 +145,24 @@ func newWorld() *world { return w } +// decide writes the router's record of the newest ask as the router writes a person's choice: its state and warrant. +func (w *world) decide(option string, at time.Time) seat.Ask { + a := w.asked[len(w.asked)-1] + key := "shell." + a.ID + rec := w.records[key] + rec.State = "chosen" + rec.Warrant = &seat.Warrant{Ask: a.ID, Asker: "shell", Outcome: operatorchannelclient.OutcomeChosen, Option: option, + Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(at), AskDigest: operatorchannelclient.Digest(a), + By: &seat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} + w.records[key] = rec + return a +} + +// do makes a call through the gate, counting the runs. +func (w *world) do(c Call) (any, error) { + return w.g.Run(c, func() (any, error) { w.runs++; return "done", nil }) +} + // call is a fixture call of the marked tool wipe, or of another tool of the module. func call(name, args string) Call { return Call{Address: "shell." + name, Module: "shell", Name: name, Fingerprint: "src2:1", Caller: "agent", Args: json.RawMessage(args), @@ -270,6 +288,150 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("answer %v after %s", err, w.now.Sub(start)) } }}, + {"§10, §17: a second identical call publishes nothing and is told it still waits", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + _, err := w.do(call("wipe", `{"dir":"scratch"}`)) + if len(w.asked) != 1 || err == nil || !strings.HasPrefix(err.Error(), "Not run: still waiting for the operator's answer") || w.runs != 0 { + t.Fatalf("asked %d, %v, runs %d", len(w.asked), err, w.runs) + } + }}, + {"§17: an ask the router holds back as the fourth open one still waits", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State, rec.HeldSince = "held-back", operatorchannelclient.FormatTime(w.now) + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "still waiting") || len(w.asked) != 1 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14, §15: approved, the same call runs once; made again it is already run and asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + w.decide(OptionApprove, w.now.Add(time.Minute)) + w.now = w.now.Add(2 * time.Minute) + if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got != "done" || w.runs != 1 { + t.Fatalf("%v %v, runs %d", got, err, w.runs) + } + _, err := w.do(call("wipe", `{"dir":"scratch"}`)) + if err == nil || !strings.Contains(err.Error(), "was already run; asked again") || w.runs != 1 || len(w.asked) != 2 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§15: two calls at the same moment run it once", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionApprove, w.now) + var wg sync.WaitGroup + var mu sync.Mutex + runs := 0 + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = w.g.Run(call("wipe", `{}`), func() (any, error) { mu.Lock(); runs++; mu.Unlock(); return "done", nil }) + }() + } + wg.Wait() + if runs != 1 { + t.Fatalf("ran %d times", runs) + } + }}, + {"§14: approved for other arguments runs nothing", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + a := w.decide(OptionApprove, w.now) + rec := w.records["shell."+a.ID] + other, _ := ActOf(call("wipe", `{"dir":"home"}`), "n1") + d, _ := operatorchannelclient.ActDigest(other) + a.Options[0].Binds = d // the ask the person was shown bound another act + rec.Warrant.AskDigest = operatorchannelclient.Digest(a) + w.records["shell."+a.ID] = rec + var k operatorchannelclient.Kept + _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + k.Ask = a + raw, _ := json.Marshal(k) + w.state.vals["n1.ask."+a.ID] = raw + if _, err := w.do(call("wipe", `{"dir":"scratch"}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "nothing is done") { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§14: approved 31 minutes ago asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionApprove, w.now) + w.now = w.now.Add(31 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "approved too long ago") || w.runs != 0 || len(w.asked) != 2 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§14, §17: declined, the same call is not asked again for 10 minutes", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + w.decide(OptionDecline, w.now) + w.now = w.now.Add(9 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "declined") || len(w.asked) != 1 || w.runs != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + w.now = w.now.Add(2 * time.Minute) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "asked again") || len(w.asked) != 2 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14, §17: expired asks anew", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State = "expired" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "ended expired; asked again") || len(w.asked) != 2 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§14: another asker's warrant runs nothing", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.decide(OptionApprove, w.now) + rec := w.records["shell."+a.ID] + rec.Warrant.Asker = "mail" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "its warrant does not hold") { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§17: the router's refusal is said with its words", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.asked[0] + rec := w.records["shell."+a.ID] + rec.State, rec.Answer = "refused", "the asker has too many open asks" + w.records["shell."+a.ID] = rec + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the asker has too many open asks") { + t.Fatalf("%v", err) + } + }}, + {"§20: a runner's own tool marked is refused unasked, never run", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Own = true + if _, err := w.do(c); err == nil || w.runs != 0 || len(w.asked) != 0 { + t.Fatalf("%v, runs %d, asked %d", err, w.runs, len(w.asked)) + } + }}, + {"§13: with no binding, an unmarked call runs and a marked one is refused", func(t *testing.T, w *world) { + w.g.d.State = nil + if got, err := w.do(call("tidy", `{}`)); err != nil || got != "done" { + t.Fatalf("unmarked: %v %v", got, err) + } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "binds no state approvals") || w.runs != 1 { + t.Fatalf("marked: %v, runs %d", err, w.runs) + } + }}, + {"§13: an ask over 256 KiB is refused naming the cause", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Description = strings.Repeat("w", 300*1024) + if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "more than the 262144 a value of the state approvals holds") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§3, §2: a seat verb its seat marks is asked for its holder", func(t *testing.T, w *world) { + c := Call{Address: "node-login-shell.execute", Module: "shell", Seat: "node-login-shell", Name: "execute", Fingerprint: "src2:1", Args: json.RawMessage(`{"command":"ls"}`)} + if _, err := w.do(c); err == nil || !strings.HasPrefix(err.Error(), "Not run") || len(w.asked) != 1 || w.asked[0].Headline != "Run node-login-shell.execute on n1?" { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 0aa5e32..a8c8498 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -35,6 +35,8 @@ type Deps struct { Node, Runner string // Catalog is the catalog half the marks are read from. Catalog Catalog + // Wait waits, within the bound, for the catalog half's first view, and is nil once it is read or why not. + Wait func(within time.Duration) error // Findings is the runner's held writer; nil says a finding in the log only. Findings FindingWriter Logf func(string, ...any) @@ -57,6 +59,8 @@ type Gate struct { registered map[string]map[string]bool // unserved is each module whose marks name a tool its bundle does not register, with those tools. unserved map[string][]string + // unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12). + unmarkedAt map[string]bool } // New is a gate over d. diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go new file mode 100644 index 0000000..b1d3bef --- /dev/null +++ b/node-tools/internal/approval/run.go @@ -0,0 +1,183 @@ +package approval + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// Run is a call through the gate (§20): an unmarked call runs; a marked one runs only on the operator's approval of that +// one call, judged on the router's record and claimed once (§14, §15), and is otherwise answered as an error saying why +// (§12, §17, §19). run is called at most once. +func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { + m, err := g.marksOf(c) + if err != nil { + return nil, err + } + if missing := g.unservedOf(c.Module); len(missing) > 0 { + return nil, fmt.Errorf("Not run: %s's marks name %s, which its bundle on %s does not register, so none of its tools is "+ + "served here (design 46 7-approval-mark §4)", c.Module, strings.Join(missing, ", "), g.d.Node) + } + marked, _ := m.Marked(c.Seat, c.Name) + since := g.markedSince(c, marked, m.Revision) + if !marked { + return run() + } + if c.Own { + return nil, g.refused(c, "", "it is one of the tool runner's own tools, which no ask covers") + } + if m.Unread != "" { + return nil, g.refused(c, "", m.Unread) + } + if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { + return nil, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in") + } + if why := refusedUnasked(c.Args); why != "" { + return nil, g.refused(c, "", why) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.refused(c, "", err.Error()) + } + named, err := g.d.State.Get(g.actKey(digest)) + if err != nil { + return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) + } + if named == nil { + return nil, g.raise(c, act, digest, since) + } + var id string + if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { + return nil, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)) + } + return g.judge(c, act, digest, id, since, run) +} + +// Binding is a Store that says whether the runner's membership binds the state now (§13). +type Binding interface{ Bound() bool } + +// marksOf is the module's marks as the catalog half has them now; before its first view, the call waits for it within +// RecordWithin and is then judged, refused naming mesh_first_views when it is still unread (§19). +func (g *Gate) marksOf(c Call) (Marks, error) { + if g.d.Catalog == nil { + return Marks{}, g.refused(c, "", "the tool runner reads no catalog half, so its marks are not read") + } + if err := g.d.Catalog.Ready(); err != nil { + if g.d.Wait != nil { + err = g.d.Wait(RecordWithin) + } + if err != nil { + return Marks{}, fmt.Errorf("Not run: the marks of %s are not read yet (%v); mesh_first_views says what the tool runner "+ + "waits for", c.Module, err) + } + } + m := Derive(g.d.Catalog, c.Module, c.Fingerprint) + g.checkServed(m) + return m, nil +} + +func (g *Gate) unservedOf(module string) []string { + g.mu.Lock() + defer g.mu.Unlock() + return g.unserved[module] +} + +// markedSince is the revision a call was marked at when the same caller last called it unmarked (§12), empty otherwise. +func (g *Gate) markedSince(c Call, marked bool, revision string) string { + key := c.Caller + " " + c.Address + g.mu.Lock() + defer g.mu.Unlock() + if g.unmarkedAt == nil { + g.unmarkedAt = map[string]bool{} + } + was := g.unmarkedAt[key] + if marked { + delete(g.unmarkedAt, key) + if was { + return revision + } + return "" + } + g.unmarkedAt[key] = true + return "" +} + +// judge is the same call made again, judged on the router's own record of its act's newest ask (§14 to §17). +func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since string, run func() (any, error)) (any, error) { + rec, err := g.record(c.Module, id) + if err != nil { + return nil, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()) + } + if rec == nil { + return nil, g.firstAnswer(c, id, digest, since) // §17: not yet taken by the router + } + again := func(why string) (any, error) { + err := g.raise(c, act, digest, since) + return nil, fmt.Errorf("The ask %s %s; asked again. %w", id, why, err) + } + switch rec.State { + case "open", "held-back": + return nil, fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", + id, Fingerprint(digest), rec.Expires) + case "refused": + return nil, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)) + case "chosen": + default: // expired, cancelled, replaced: each asks again (§15, §17) + return again("ended " + rec.State) + } + w := rec.Warrant + if w == nil { + return nil, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant") + } + at, _ := operatorchannelclient.Time(w.At) + if w.Option == OptionDecline { + if until := at.Add(DeclinedFor); g.d.Now().Before(until) { + return nil, fmt.Errorf("Not run: the operator declined ask %s; the same call is not asked again until %s", + id, operatorchannelclient.FormatTime(until)) + } + return again("was declined more than 10 minutes ago") + } + if g.d.Now().After(at.Add(RunWithin)) { + return again("was approved too long ago (more than 30 minutes)") + } + var answer any + var ran error + h, err := g.client(c.Module).Take(*w, func(o seat.Option, _ seat.Warrant) error { + if err := operatorchannelclient.Performs(o, act); err != nil { + ran = err + return err + } + answer, ran = run() + return nil + }) + switch { + case h == operatorchannelclient.Done: + return again("was already run") + case h == operatorchannelclient.Refused: + return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err)) + case h == "" && err != nil: + return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()) + case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used + return nil, g.refused(c, digest, err.Error()) + } + return g.ran(c, id, rec, answer, ran) +} + +// ran is a call run on the operator's approval: the tool's own answer. +func (g *Gate) ran(_ Call, _ string, _ *seat.AsksByAskerIDValue, answer any, err error) (any, error) { + return answer, err +} + +func errText(err error) string { + if err == nil { + return "no reason given" + } + return err.Error() +} diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index bf317d6..587612e 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -3,6 +3,7 @@ package firstview import ( "context" "strconv" + "time" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" @@ -81,3 +82,76 @@ type notFollowed struct{} func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } var errNotFollowed error = notFollowed{} + +// heardOne wakes every wait on the picture. +func (v *Views) heardOne() { + v.mu.Lock() + defer v.mu.Unlock() + if v.heard != nil { + close(v.heard) + } + v.heard = make(chan struct{}) +} + +// heardNext is closed at the next event heard. +func (v *Views) heardNext() <-chan struct{} { + v.mu.Lock() + defer v.mu.Unlock() + if v.heard == nil { + v.heard = make(chan struct{}) + } + return v.heard +} + +// WaitCatalog waits within the bound for the catalog half's first view of every watched branch (design 46 7-approval-mark +// §19), waking at each event the runner hears and at each first view answered; nil once it is read, else why not. +func (v *Views) WaitCatalog(within time.Duration) error { + c := approvalCatalog{v: v} + timer := time.NewTimer(within) + defer timer.Stop() + for { + next := v.heardNext() + err := c.Ready() + if err == nil { + return nil + } + var ready []<-chan struct{} + if v.branches != nil { + for _, branch := range v.branches.Followed() { + if cat, ok := v.branches.Catalog(branch); ok { + select { + case <-cat.Ready(): // read already: waits on nothing + default: + ready = append(ready, cat.Ready()) + } + } + } + } + if !waitAny(next, timer.C, ready) { + return c.Ready() + } + } +} + +// waitAny waits for next or one of ready, true, or for the bound, false. +func waitAny(next <-chan struct{}, bound <-chan time.Time, ready []<-chan struct{}) bool { + woke := make(chan struct{}, 1) + stop := make(chan struct{}) + defer close(stop) + for _, r := range ready { + go func(r <-chan struct{}) { + select { + case <-r: + woke <- struct{}{} + case <-stop: + } + }(r) + } + select { + case <-next: + case <-woke: + case <-bound: + return false + } + return true +} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index c187bcb..1251cac 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -67,6 +67,8 @@ type Views struct { cancel context.CancelFunc wg sync.WaitGroup deps Deps + // heard is closed and made again at every event heard, so a wait for the picture wakes on events, never on a timer. + heard chan struct{} } // FindingsState is the runner's own state the findings are written in. @@ -205,8 +207,10 @@ func StartWith(parent context.Context, d Deps, logf func(string, ...any)) (*View return v, func() { stop(); cancel(); v.wg.Wait(); stopFindings() } } -// hear applies one views event; a gap asks the first view once more. +// hear applies one views event; a gap asks the first view once more. Every event heard wakes what waits on the picture +// (heardOne). func (v *Views) hear(env bus.Envelope) error { + defer v.heardOne() v.mu.Lock() hs := append([]*adoption(nil), v.hearers[env.Key]...) v.mu.Unlock() diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index ed7cd5f..649bec5 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -12,6 +12,20 @@ const FingerprintWord = "MESH_FINGERPRINT" type Gate interface { // Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding. Unserved(module, fingerprint string, registered []string) []string + // Run is a call through the gate: run is called only when the call may run now, once. + Run(call approval.Call, run func() (any, error)) (any, error) +} + +// gatedRun is how a handler calls a tool: through the gate where there is one. +type gatedRun func(call approval.Call, t launch.Tool, caller string) (any, error) + +// gated calls the tool through the gate, or straight where the runner has none. +func (c *consumers) gated(call approval.Call, t launch.Tool, caller string) (any, error) { + run := func() (any, error) { return t.Run(caller, call.Args) } + if c.gate == nil { + return run() + } + return c.gate.Run(call, run) } // WithGate serves every call through the approval gate. diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 7dae3af..5bbea33 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -17,6 +17,7 @@ import ( "time" "github.com/novox/mesh-tools/node-tools/internal/announce" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" ) @@ -303,7 +304,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -464,8 +465,11 @@ func (r *runner) serve(l *loadedModule) { for _, t := range reg.tools { t := t // Told who called, from the subject the call arrived on, never from its body (novox/hq issue 365). + // Through the approval gate, before the bundle or the runner's own tool (design 46 7-approval-mark §20). + module, own := reg.module, reg.repairs stop, err := r.conn.HandleCalled(reg.module+"."+t.Name, func(caller string, body json.RawMessage) (any, error) { - return t.Run(caller, argsOf(body)) + return r.events.gated(approval.Call{Address: module + "." + t.Name, Module: module, Name: t.Name, Own: own, + Fingerprint: r.fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller) }) if err != nil { r.logf("[mesh-tools] cannot serve %s.%s: %v", reg.module, t.Name, err) @@ -703,7 +707,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again // whenever a membership changes, and when again is called because a bundle was loaded again. Whether // this machine holds the seat is the bus's to decide. It answers its stop and again. -func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf func(string, ...any)) (func(), func()) { +func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, fingerprintOf func(string) string, + logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -742,8 +747,11 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) continue } + // Through the approval gate, before the bundle (design 46 7-approval-mark §20). + module, v := module, v stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, body json.RawMessage) (any, error) { - return t.Run(caller, argsOf(body)) + return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb, + Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller) }) if err != nil { logf("[mesh-tools] cannot serve %s's %s on %s: %v", v.Seat, v.Verb, v.Subject, err) From 0fdec2bd606c3b41a41e2ddf19f2c7add29a8e37 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:15:43 +0100 Subject: [PATCH 08/16] A-6 the capability: the tool runner states approval-mark/1 in its announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its gate sits on every handler it serves, so it says it honours the mark, and the grants part, reading its node's readings, composes no deny of its modules' marked subjects. Builds: design 46 7-approval-mark §6 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/runtime/announce_test.go | 2 +- node-tools/internal/runtime/busstate.go | 5 ++++- node-tools/internal/runtime/ledgers_test.go | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index cc67d5b..53de9c5 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -140,7 +140,7 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Fatalf("ping of the instance: %v", err) } var ping micro.Ping - if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index 5ac8bbd..c74f9af 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -22,6 +22,9 @@ const ( CapLedgerCalls = "ledger-calls" // CapPutSessions is the put and get sessions' word (design 47 G §518, §622), mesh-sdk's. CapPutSessions = stdio.CapPutSessions + // CapApprovalMark says the runner honours the approval mark: its gate sits on every handler it serves (design 46 + // 7-approval-mark §6, §20), so the grants part composes no deny of its modules' marked subjects. + CapApprovalMark = "approval-mark/1" ) // callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. @@ -36,7 +39,7 @@ var callFamilies = map[string][]string{ // CallCapabilities is every capability of the calls this runner answers, sorted. The delayed settle of a pulled seat // message names a fate, not a method, so it stands beside the method families (novox/hq ADR 0664 §5). func CallCapabilities() []string { - out := []string{stdio.CapSeatSettleDelay} + out := []string{stdio.CapSeatSettleDelay, CapApprovalMark} for c := range callFamilies { out = append(out, c) } diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 96bc96e..3d50b3a 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -204,7 +204,7 @@ func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) { // no other name; none answers "the runtime answers no ...". func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) { m, _, _ := aLedger(t) - if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { + if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Fatalf("the runner states %s", got) } for capability, methods := range callFamilies { From 3640447711e1dd6b905b9a9fd5c1b3c17fc9884a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:18:42 +0100 Subject: [PATCH 09/16] A-4 record: a run on the operator's approval answers its approval and is recorded in the hand-act log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call run on its warrant answers {answer, approval}, the tool's own answer and the line naming the approval, as a process holder's gated-ended says it. The runner then calls mesh-hand-acts.warranted, each try a kept call open while the ask lacks recorded, and on its answer writes recorded; past the kept call's bound it is asked again when the hand-act seat's holder is heard again and at each start, for every acted, unrecorded ask within its 48 hours. Two bound keys per run in the runner's key-ttl state bound raise call-unanswered at five minutes and say an unrecorded run an hour before its key's end; the answer clears the finding. Builds: design 46 7-approval-mark §16, §13 (the log line an hour before 48 hours); lead answers A-157, A-158 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 48 ++++ node-tools/cmd/node-tools/main.go | 12 +- node-tools/go.mod | 1 + node-tools/internal/approval/approval_test.go | 69 +++++- node-tools/internal/approval/gate.go | 11 + node-tools/internal/approval/record.go | 220 ++++++++++++++++++ node-tools/internal/approval/run.go | 5 - node-tools/internal/firstview/approval.go | 25 ++ node-tools/internal/firstview/firstview.go | 3 + 9 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 node-tools/internal/approval/record.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index 3b3b4ed..d048b7c 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -3,6 +3,11 @@ package main import ( "encoding/json" "errors" + "log" + "time" + + handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" + "git.novox.be/novox/mesh-sdk/go/busretry" stdio "git.novox.be/novox/mesh-sdk/go" @@ -99,3 +104,46 @@ func routerRecord(conn *bus.Conn) func(module, id string) (*stdio.StateEntry, er return &stdio.StateEntry{Key: e.Key, Value: e.Value, Revision: e.Revision, At: e.At}, nil } } + +// boundState is the runner's key-ttl state bound (lead answers A-157, A-158). +type boundState struct { + conn *bus.Conn + module string +} + +// BoundState is the runner's state its bound keys live in. +const BoundState = "bound" + +func (b boundState) CreateTTL(key string, value any, ttl time.Duration) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + _, err = b.conn.StateCreateTTL(b.module, BoundState, key, raw, ttl) + return err +} + +func (b boundState) Delete(key string) error { return b.conn.StateDelete(b.module, BoundState, key) } + +func (b boundState) Watch(pattern string, expired func(key string)) (func(), error) { + return b.conn.StateWatch(b.module, BoundState, pattern, func(c bus.StateChange) error { + if c.Op == "expire" { + expired(c.Key) + } + return nil + }) +} + +// warranted asks mesh-hand-acts.warranted for one acted ask, each try a kept call open while open says so (design 46 +// 7-approval-mark §16; lead answer A-157). +func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error)) error { + return func(asker, ask string, open func() (bool, error)) error { + _, err := busretry.DoKept("mesh-hand-acts.warranted of "+asker+"."+ask, + busretry.Open{Key: asker + "." + ask, Open: func(string) (bool, error) { return open() }, Logf: log.Printf}, + func() (bus.Answered, error) { + return conn.Ask("seat:"+handacts.Seat+"."+handacts.VerbWarranted, handacts.WarrantedInput{Asker: asker, Ask: ask}, + "mesh.seat."+handacts.Seat+".tool."+handacts.VerbWarranted) + }, nil) + return err + } +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 78e15cd..168250b 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -25,7 +25,9 @@ import ( "syscall" "time" + handacts "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts" "github.com/novox/mesh-tools/node-tools/internal/alive" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/console" @@ -77,8 +79,16 @@ func main() { defer stopViews() gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, - Publish: publishAsk(conn), Record: routerRecord(conn)}) + Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}}) opts = append(opts, runtime.WithGate(gate)) + // A run still unrecorded is asked again at each start and when the hand-act seat's holder is heard again (lead answer A-157). + if stopBounds, err := gate.WatchBounds(); err != nil { + log.Printf("[mesh-tools] the gate's bound keys are not watched (%v): an unanswered hand-act is said in the log only", err) + } else { + defer stopBounds() + } + source.OnSeatHolder(handacts.Seat, gate.Recover) + go gate.Recover() stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { log.Fatalf("node-tools: %v", err) diff --git a/node-tools/go.mod b/node-tools/go.mod index e9db0f0..0eda7d2 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -7,6 +7,7 @@ require ( git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhandacts v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000 diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 95ed780..2baddfb 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -2,6 +2,7 @@ package approval import ( "encoding/json" + "errors" "sort" "strings" "sync" @@ -12,6 +13,7 @@ import ( seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -103,17 +105,49 @@ type world struct { now time.Time runs int findings *findings + bounds *bounds + // warranted is what mesh-hand-acts.warranted answers, and recorded each ask it answered for. + warranted error + recorded []string +} + +// bounds is the runner's key-ttl state bound as a fixture: expire hands a key's expiry to the watch. +type bounds struct { + keys map[string]time.Duration + expired func(string) +} + +func (b *bounds) CreateTTL(key string, _ any, ttl time.Duration) error { b.keys[key] = ttl; return nil } +func (b *bounds) Delete(key string) error { delete(b.keys, key); return nil } +func (b *bounds) Watch(_ string, expired func(string)) (func(), error) { + b.expired = expired + return func() {}, nil +} +func (b *bounds) expire(key string) { + delete(b.keys, key) + b.expired(key) } func newWorld() *world { shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}}) w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{}, - now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + bounds: &bounds{keys: map[string]time.Duration{}}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, newest: map[string]catalogseat.CatalogEntry{"shell": shell}, defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} - w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, + w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds, + Go: func(f func()) { f() }, + Warranted: func(asker, ask string, open func() (bool, error)) error { + if still, _ := open(); !still { + return nil + } + if w.warranted == nil { + w.recorded = append(w.recorded, asker+"."+ask) + } + return w.warranted + }, Now: func() time.Time { return w.now }, After: func(d time.Duration) <-chan time.Time { w.now = w.now.Add(d) @@ -309,7 +343,8 @@ func TestTheRefusalsAndAnswers(t *testing.T) { _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) w.decide(OptionApprove, w.now.Add(time.Minute)) w.now = w.now.Add(2 * time.Minute) - if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got != "done" || w.runs != 1 { + if got, err := w.do(call("wipe", `{"dir":"scratch"}`)); err != nil || got.(Answered).Answer != "done" || w.runs != 1 || + !strings.HasPrefix(got.(Answered).Approval, "run on the operator's approval via telegram at 2026-11-12T10:01:00Z, ask ") { t.Fatalf("%v %v, runs %d", got, err, w.runs) } _, err := w.do(call("wipe", `{"dir":"scratch"}`)) @@ -432,6 +467,34 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("%v, asked %d", err, len(w.asked)) } }}, + {"§16: the run is recorded once; unanswered within five minutes it is a finding, cleared when warranted answers", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{}`)) + a := w.decide(OptionApprove, w.now) + stop, _ := w.g.WatchBounds() + defer stop() + w.warranted = errors.New("no responders") + if _, err := w.do(call("wipe", `{}`)); err != nil || w.runs != 1 || len(w.recorded) != 0 { + t.Fatalf("%v, runs %d, recorded %v", err, w.runs, w.recorded) + } + if w.bounds.keys["n1.unanswered."+a.ID] != UnansweredAfter || w.bounds.keys["n1.unrecorded."+a.ID] != KeptFor-SaidBefore { + t.Fatalf("bounds %v", w.bounds.keys) + } + w.bounds.expire("n1.unanswered." + a.ID) + if w.findings.raised[keys.EscapeName("n1."+a.ID)] != KindCallUnanswered { + t.Fatalf("findings %v", w.findings.raised) + } + w.warranted = nil + w.g.Recover() // a start, or the hand-act seat's holder heard again + w.g.Recover() + if len(w.recorded) != 1 || w.recorded[0] != "shell."+a.ID || len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { + t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys) + } + var k operatorchannelclient.Kept + _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + if !k.Recorded || !k.Acted { + t.Fatalf("kept %+v", k) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index a8c8498..5381ec9 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -49,6 +49,12 @@ type Deps struct { Publish func(module, subject string, body any, id string) error // Record reads the router's record of an ask, `.` in the seat's state asks, on the runner's own account (§14). Record func(module, id string) (*stdio.StateEntry, error) + // Warranted asks `mesh-hand-acts.warranted` {asker, ask}, each try a kept call open while open says so (§16, A-157). + Warranted func(asker, ask string, open func() (bool, error)) error + // Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound. + Bounds Bounds + // Go runs what goes on beside the call (a run's recording); nil is a goroutine. + Go func(func()) } // Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). @@ -61,6 +67,8 @@ type Gate struct { unserved map[string][]string // unmarkedAt is each caller's address it last called unmarked, so a mark added since is named (§12). unmarkedAt map[string]bool + // inFlight is each ask whose recording runs now. + inFlight map[string]bool } // New is a gate over d. @@ -74,6 +82,9 @@ func New(d Deps) *Gate { if d.After == nil { d.After = time.After } + if d.Go == nil { + d.Go = func(f func()) { go f() } + } return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} } diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go new file mode 100644 index 0000000..a01a038 --- /dev/null +++ b/node-tools/internal/approval/record.go @@ -0,0 +1,220 @@ +package approval + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// The bounds of a run's recording (§13, §16; lead answers A-157, A-158). +const ( + UnansweredAfter = 5 * time.Minute + KeptFor = 48 * time.Hour + SaidBefore = time.Hour +) + +// Bounds is the runner's key-ttl state `bound` (lead answers A-157, A-158): a key created with its own time to live, which +// the bus removes when it passes, every watch hearing the removal as an expiry, so the gate's waits need no timer. +type Bounds interface { + CreateTTL(key string, value any, ttl time.Duration) error + Delete(key string) error + // Watch hands expired every key under the pattern whose time to live passed, until the stop is called. + Watch(pattern string, expired func(key string)) (stop func(), err error) +} + +// Answered is a call run on the operator's approval: the tool's own answer and the line naming the approval (§16), in the +// form a process holder's `gated-ended` says it (design 47 G §677; lead answer A-158). +type Answered struct { + Answer any `json:"answer"` + Approval string `json:"approval"` +} + +// The bound keys of a run (lead answer A-158; §13). +func (g *Gate) unansweredKey(id string) string { return g.d.Node + ".unanswered." + id } +func (g *Gate) unrecordedKey(id string) string { return g.d.Node + ".unrecorded." + id } + +// ran is a call run on the operator's approval: answered with the tool's answer and the approval's line, and its warrant +// recorded in the hand-act log (§16). +func (g *Gate) ran(c Call, id string, rec *seat.AsksByAskerIDValue, answer any, err error) (any, error) { + channel := "" + at := "" + if w := rec.Warrant; w != nil { + channel, at = w.Channel, w.At + } + if channel == "" { + channel = strings.Join(rec.OfferedOn, ", ") + } + line := fmt.Sprintf("run on the operator's approval via %s at %s, ask %s", orNone(channel), at, id) + g.d.Logf("[mesh-tools] %s on %s %s", c.Address, g.d.Node, line) + g.bindRecording(c.Module, id) + g.d.Go(func() { g.recordRun(c.Module, id) }) + if err != nil { + return nil, fmt.Errorf("%w (%s)", err, line) + } + return Answered{Answer: answer, Approval: line}, nil +} + +// bindRecording keeps the run's two bound keys: one that raises `call-unanswered` when `warranted` has not answered within +// five minutes, one that says an unrecorded run an hour before its key's 48 hours end (§13, §16). +func (g *Gate) bindRecording(module, id string) { + if g.d.Bounds == nil { + return + } + for key, ttl := range map[string]time.Duration{g.unansweredKey(id): UnansweredAfter, g.unrecordedKey(id): KeptFor - SaidBefore} { + if err := g.d.Bounds.CreateTTL(key, map[string]string{"asker": module, "ask": id}, ttl); err != nil { + g.d.Logf("[mesh-tools] the bound %s of the run of ask %s is not kept: %v", key, id, err) + } + } +} + +// recordRun calls `mesh-hand-acts.warranted` for one acted ask, each try a kept call open while the ask lacks `recorded` +// (§16, A-157), and on its answer writes `recorded` and clears what waited on it. One recording per ask at a time. +func (g *Gate) recordRun(module, id string) { + if !g.recording(id, true) { + return + } + defer g.recording(id, false) + if g.d.Warranted == nil { + g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) + return + } + if err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }); err != nil { + g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+ + "holder is heard again and at the next start", id, err) + return + } + if err := g.markRecorded(id); err != nil { + g.d.Logf("[mesh-tools] ask %s is recorded in the hand-act log and its mark recorded is not kept: %v", id, err) + return + } + if g.d.Bounds != nil { + _ = g.d.Bounds.Delete(g.unansweredKey(id)) + _ = g.d.Bounds.Delete(g.unrecordedKey(id)) + } + g.finding(id, KindCallUnanswered, false, "mesh-hand-acts.warranted answered for ask "+id, "") +} + +// recording claims or releases an ask's recording: false when one runs already. +func (g *Gate) recording(id string, start bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + if g.inFlight == nil { + g.inFlight = map[string]bool{} + } + if start { + if g.inFlight[id] { + return false + } + g.inFlight[id] = true + return true + } + delete(g.inFlight, id) + return true +} + +// kept is the ask the client keeps under its id, with its revision; nil when there is none. +func (g *Gate) kept(id string) (*operatorchannelclient.Kept, uint64, error) { + e, err := g.d.State.Get(g.askKey(id)) + if err != nil || e == nil { + return nil, 0, err + } + var k operatorchannelclient.Kept + if err := json.Unmarshal(e.Value, &k); err != nil { + return nil, 0, err + } + return &k, e.Revision, nil +} + +// unrecorded says the ask is acted on and its run not yet recorded: the key that keeps `warranted` open (§16). +func (g *Gate) unrecorded(id string) (bool, error) { + k, _, err := g.kept(id) + if err != nil || k == nil { + return false, err + } + return k.Acted && !k.Recorded, nil +} + +// markRecorded writes `recorded` on the kept ask by compare-and-set, again when another write came first. +func (g *Gate) markRecorded(id string) error { + for { + k, rev, err := g.kept(id) + if err != nil || k == nil { + return fmt.Errorf("the kept ask cannot be read: %v", err) + } + if k.Recorded { + return nil + } + k.Recorded = true + if _, err := g.d.State.Update(g.askKey(id), k, rev); err == nil { + return nil + } else if !isChanged(err) { + return err + } + } +} + +// Recover calls `warranted` again for every acted, unrecorded ask this machine's keys hold within their 48 hours (§16): at +// each start, and when the hand-act seat's holder is heard again (A-157). +func (g *Gate) Recover() { + if g.d.State == nil { + return + } + keys, err := g.d.State.Keys() + if err != nil { + g.d.Logf("[mesh-tools] the acted, unrecorded asks cannot be read (%v): read again at the next start or holder heard", err) + return + } + prefix := g.d.Node + ".ask." + for _, key := range keys { + if !strings.HasPrefix(key, prefix) { + continue + } + id := strings.TrimPrefix(key, prefix) + k, _, err := g.kept(id) + if err != nil || k == nil || !k.Acted || k.Recorded || g.d.Now().Sub(k.Asked) > KeptFor { + continue + } + module := moduleOf(k) + g.d.Go(func() { g.recordRun(module, id) }) + } +} + +// moduleOf is the module an ask was asked under: the warrant's asker. +func moduleOf(k *operatorchannelclient.Kept) string { + if k.Answer != nil { + return k.Answer.Asker + } + return "" +} + +// WatchBounds hears the run's bound keys expire (A-157): `call-unanswered` raised when `warranted` has not answered within +// five minutes; an unrecorded run said in the log an hour before its key's 48 hours end (§13). +func (g *Gate) WatchBounds() (func(), error) { + if g.d.Bounds == nil { + return func() {}, nil + } + return g.d.Bounds.Watch(g.d.Node+".**", func(key string) { + rest := strings.TrimPrefix(key, g.d.Node+".") + kind, id, _ := strings.Cut(rest, ".") + if open, err := g.unrecorded(id); err != nil || !open { + return + } + switch kind { + case "unanswered": + g.finding(id, KindCallUnanswered, true, fmt.Sprintf("mesh-hand-acts.warranted has not answered for ask %s, run on the "+ + "operator's approval on %s, within %s: the run is not yet in the hand-act log", id, g.d.Node, UnansweredAfter), + "a run's hand-act is unrecorded on "+g.d.Node) + case "unrecorded": + g.d.Logf("[mesh-tools] ERROR the run of ask %s on %s is still unrecorded an hour before its key's 48 hours end", id, g.d.Node) + } + }) +} + +// isChanged says a compare-and-set lost to another write. +func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go index b1d3bef..c828e6f 100644 --- a/node-tools/internal/approval/run.go +++ b/node-tools/internal/approval/run.go @@ -170,11 +170,6 @@ func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since st return g.ran(c, id, rec, answer, ran) } -// ran is a call run on the operator's approval: the tool's own answer. -func (g *Gate) ran(_ Call, _ string, _ *seat.AsksByAskerIDValue, answer any, err error) (any, error) { - return answer, err -} - func errText(err error) string { if err == nil { return "no reason given" diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index 587612e..45e1b6c 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -3,6 +3,7 @@ package firstview import ( "context" "strconv" + "strings" "time" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" @@ -155,3 +156,27 @@ func waitAny(next <-chan struct{}, bound <-chan time.Time, ready []<-chan struct } return true } + +// OnSeatHolder calls f whenever the assigner's seat-holders view changes for the seat (lead answer A-157: the gate asks +// mesh-hand-acts.warranted again when the hand-act seat's holder is heard again). +func (v *Views) OnSeatHolder(seat string, f func()) { + v.mu.Lock() + defer v.mu.Unlock() + if v.onKey == nil { + v.onKey = map[string][]func(){} + } + k := "seat-holders " + seat + v.onKey[k] = append(v.onKey[k], f) +} + +// heardKey calls the hearers of a key of an owner's state just changed: a seat's holders, under the seat's own key and +// its `.` keys. +func (v *Views) heardKey(state, key string) { + seat, _, _ := strings.Cut(key, ".") + v.mu.Lock() + fs := append([]func(){}, v.onKey[state+" "+seat]...) + v.mu.Unlock() + for _, f := range fs { + go f() + } +} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 1251cac..8cc6eaa 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -69,6 +69,8 @@ type Views struct { deps Deps // heard is closed and made again at every event heard, so a wait for the picture wakes on events, never on a timer. heard chan struct{} + // onKey are the hearers of one key of an owner's state, by ` ` (OnSeatHolder). + onKey map[string][]func() } // FindingsState is the runner's own state the findings are written in. @@ -230,6 +232,7 @@ func (v *Views) hear(env bus.Envelope) error { if f == nil || f.Owner != ev.Owner { return nil } + defer v.heardKey(ev.State, ev.Key) if gap, why := f.Apply(ev); gap { v.logf("[mesh-tools] a gap in %s's %s (%s): its first view %s is asked once more", f.Owner, f.State, why, f.Tool) go func() { _ = f.Again(v.ctx, "a gap: "+why) }() From 65db969f72b471ffe916b4821122d11def48a4c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:20:46 +0100 Subject: [PATCH 10/16] A-5 failing closed: a marked call runs nothing when a part of the check is missing, and a stuck gate is said MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A marked call is refused, asking nothing, when no module holds operator-channel (read from the assigner's view of the seats' holders the runner follows, an unread view refusing too); refused when the served entry cannot be read, the ask cannot be published, the router refuses it or its record cannot be read; and while the state approvals cannot be read or written no marked call runs on the machine, the failed read made again at the next marked call and the next membership change, a bound key raising approvals-unreadable after five minutes and the read again clearing it. Before the catalog half's first view a call waits for it within 5 seconds, on the events the runner hears, and is refused naming mesh_first_views when it is still unread. Each refusal is in the log with the ask's fingerprint, never the arguments. Builds: design 46 7-approval-mark §19, §5 (an unread served entry); lead answers A-157, A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 5 +- node-tools/internal/approval/approval_test.go | 74 ++++++++++- node-tools/internal/approval/ask.go | 2 + node-tools/internal/approval/closed.go | 119 ++++++++++++++++++ node-tools/internal/approval/gate.go | 4 + node-tools/internal/approval/record.go | 4 + node-tools/internal/approval/run.go | 6 + 7 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 node-tools/internal/approval/closed.go diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 168250b..5796757 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -79,7 +79,10 @@ func main() { defer stopViews() gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, - Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}}) + Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, + Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) })}) + // A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157). + conn.OnMembership(func(bus.Membership) { go gate.StateAgain() }) opts = append(opts, runtime.WithGate(gate)) // A run still unrecorded is asked again at each start and when the hand-act seat's holder is heard again (lead answer A-157). if stopBounds, err := gate.WatchBounds(); err != nil { diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 2baddfb..9c42034 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -106,6 +106,9 @@ type world struct { runs int findings *findings bounds *bounds + // holders is the assigner's view of the seats' holders, by key; holdersErr says it is not answered yet. + holders map[string][]byte + holdersErr error // warranted is what mesh-hand-acts.warranted answers, and recorded each ask it answered for. warranted error recorded []string @@ -131,14 +134,16 @@ func (b *bounds) expire(key string) { func newWorld() *world { shell := entry("src2:1", []string{"node-login-shell.execute"}, map[string]any{"needs-approval": []any{"wipe"}}) w := &world{state: newMem(), records: map[string]seat.AsksByAskerIDValue{}, takes: true, findings: &findings{}, - bounds: &bounds{keys: map[string]time.Duration{}}, - now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), + bounds: &bounds{keys: map[string]time.Duration{}}, + holders: map[string][]byte{"operator-channel": []byte(`{"seat":"operator-channel","scope":"mesh","holders":[{"module":"messenger"}]}`)}, + now: time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC), cat: &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": shell}, newest: map[string]catalogseat.CatalogEntry{"shell": shell}, defs: map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}}}}}} w.g = New(Deps{Node: "n1", Runner: "node-tools", Catalog: w.cat, State: w.state, Findings: w.findings, Bounds: w.bounds, - Go: func(f func()) { f() }, + Go: func(f func()) { f() }, + Holder: HoldersIn(func() (map[string][]byte, error) { return w.holders, w.holdersErr }), Warranted: func(asker, ask string, open func() (bool, error)) error { if still, _ := open(); !still { return nil @@ -495,6 +500,69 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("kept %+v", k) } }}, + {"§19: with no holder of operator-channel in the view, a marked call is refused asking nothing", func(t *testing.T, w *world) { + w.holders["operator-channel"] = []byte(`{"seat":"operator-channel","scope":"mesh","holders":[]}`) + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "no module holds operator-channel") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + w.holdersErr = errors.New("the first view has not been answered yet") + if _, err := w.do(call("wipe", `{}`)); err == nil || len(w.asked) != 0 { + t.Fatalf("an unread view: %v, asked %d", err, len(w.asked)) + } + }}, + {"§19: a publish that fails and a record that cannot be read refuse, running nothing", func(t *testing.T, w *world) { + pub := w.g.d.Publish + w.g.d.Publish = func(string, string, any, string) error { return errors.New("the bus refused it") } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the ask could not be raised") { + t.Fatalf("%v", err) + } + w.g.d.Publish = pub + w.state.vals = map[string]json.RawMessage{} + _, _ = w.do(call("wipe", `{}`)) + w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") } + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 { + t.Fatalf("%v, runs %d", err, w.runs) + } + }}, + {"§5, §19: a served entry that cannot be read refuses every marked call on that runtime", func(t *testing.T, w *world) { + c := call("wipe", `{}`) + c.Fingerprint = "src2:unknown" + if _, err := w.do(c); err == nil || !strings.Contains(err.Error(), "cannot be read") || len(w.asked) != 0 { + t.Fatalf("%v, asked %d", err, len(w.asked)) + } + }}, + {"§19: the marks unread, a call waits within 5 seconds and is refused naming mesh_first_views", func(t *testing.T, w *world) { + w.cat.ready = errors.New("the catalog half has no first view of main yet") + waited := time.Duration(0) + w.g.d.Wait = func(d time.Duration) error { waited = d; return w.cat.ready } + if _, err := w.do(call("tidy", `{}`)); err == nil || !strings.Contains(err.Error(), "mesh_first_views") || waited != RecordWithin || w.runs != 0 { + t.Fatalf("%v, waited %s", err, waited) + } + w.g.d.Wait = func(time.Duration) error { w.cat.ready = nil; return nil } + if _, err := w.do(call("tidy", `{}`)); err != nil || w.runs != 1 { + t.Fatalf("read within the wait: %v, runs %d", err, w.runs) + } + }}, + {"§19: the state failing refuses, raises approvals-unreadable after five minutes, and clears when read again", func(t *testing.T, w *world) { + stop, _ := w.g.WatchBounds() + defer stop() + w.state.fail = errors.New("the bus is unreachable") + if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") { + t.Fatalf("%v", err) + } + if w.bounds.keys["n1.unreadable.approvals"] != UnreadableAfter || len(w.findings.raised) != 0 { + t.Fatalf("bounds %v, findings %v", w.bounds.keys, w.findings.raised) + } + w.bounds.expire("n1.unreadable.approvals") + if w.findings.raised[keys.EscapeName("n1.approvals")] != KindApprovalsUnreadable { + t.Fatalf("findings %v", w.findings.raised) + } + w.state.fail = nil + w.g.StateAgain() // a membership change heard, no further call + if len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { + t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go index a183f3f..43e1bc9 100644 --- a/node-tools/internal/approval/ask.go +++ b/node-tools/internal/approval/ask.go @@ -149,9 +149,11 @@ func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince return g.refused(c, digest, "the ask could not be raised: "+err.Error()) } if err := g.keepCall(id, act, c.Args); err != nil { + g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) } if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) } return g.firstAnswer(c, id, digest, markedSince) diff --git a/node-tools/internal/approval/closed.go b/node-tools/internal/approval/closed.go new file mode 100644 index 0000000..96219a4 --- /dev/null +++ b/node-tools/internal/approval/closed.go @@ -0,0 +1,119 @@ +package approval + +import ( + "encoding/json" + "fmt" + "time" + + assignerseat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner" +) + +// AskedOn is the seat an ask is raised on, whose holder must be there for a marked call to be asked (§19). +const AskedOn = "operator-channel" + +// UnreadableAfter is how long the state approvals fails before the runner's finding says so (§19). +const UnreadableAfter = 5 * time.Minute + +// unreadableKey is the bound key whose expiry raises `approvals-unreadable` (lead answer A-157). +func (g *Gate) unreadableKey() string { return g.d.Node + ".unreadable." + StateName } + +// held says why no marked call can be asked now, or "" (§19): no module holds operator-channel, as the assigner's view +// of the seat's holders says, or that view is not read. +func (g *Gate) held() string { + if g.d.Holder == nil { + return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + AskedOn + } + ok, err := g.d.Holder(AskedOn) + switch { + case err != nil: + return fmt.Sprintf("whether a module holds %s cannot be read: %v", AskedOn, err) + case !ok: + return "no module holds " + AskedOn + ", so nobody can be asked" + } + return "" +} + +// stateFailed notes that the state approvals could not be read or written: no marked call runs on this machine until it +// can, the failed read or write is made again at the next marked call and at the next membership change (A-157), and +// five minutes on the runner's finding says it (§19). +func (g *Gate) stateFailed(err error) { + g.mu.Lock() + first := g.failingSince.IsZero() + if first { + g.failingSince = g.d.Now() + } + g.mu.Unlock() + if first { + g.d.Logf("[mesh-tools] the state %s on %s cannot be read or written (%v): no marked call runs here until it can", StateName, g.d.Node, err) + if g.d.Bounds != nil { + if e := g.d.Bounds.CreateTTL(g.unreadableKey(), map[string]string{"since": g.d.Now().UTC().Format(time.RFC3339)}, UnreadableAfter); e != nil { + g.d.Logf("[mesh-tools] the bound of the state %s's failure is not kept: %v", StateName, e) + } + } + } +} + +// stateRead notes that the state approvals was read or written: a failure noted before ends, its finding cleared. +func (g *Gate) stateRead() { + g.mu.Lock() + was := !g.failingSince.IsZero() + g.failingSince = time.Time{} + g.mu.Unlock() + if !was { + return + } + if g.d.Bounds != nil { + _ = g.d.Bounds.Delete(g.unreadableKey()) + } + g.finding(StateName, KindApprovalsUnreadable, false, "the state "+StateName+" is read again", "") +} + +// failing says the state approvals is failing now. +func (g *Gate) failing() bool { + g.mu.Lock() + defer g.mu.Unlock() + return !g.failingSince.IsZero() +} + +// StateAgain makes the failed read of the state approvals again: at the next membership change the runner hears (A-157). +func (g *Gate) StateAgain() { + if g.d.State == nil || !g.failing() { + return + } + if _, err := g.d.State.Keys(); err != nil { + return + } + g.stateRead() +} + +// unreadableExpired is the bound key of a failing state expiring: the finding raised while the state still fails. +func (g *Gate) unreadableExpired() { + g.mu.Lock() + since := g.failingSince + g.mu.Unlock() + if since.IsZero() { + return + } + g.finding(StateName, KindApprovalsUnreadable, true, fmt.Sprintf("the state %s on %s cannot be read or written since %s: no marked call "+ + "runs here until it can", StateName, g.d.Node, since.UTC().Format(time.RFC3339)), "approvals unreadable on "+g.d.Node) +} + +// HoldersIn is Holder over the assigner's view of the seats' holders as the runner holds it (§19): view answers the view's +// values by key, an error while its first view is not answered; a seat is held when its key names a holder. +func HoldersIn(view func() (map[string][]byte, error)) func(seat string) (bool, error) { + return func(seat string) (bool, error) { + values, err := view() + if err != nil { + return false, err + } + raw, ok := values[assignerseat.SeatHoldersBySeatKey(seat)] + if !ok { + return false, nil + } + var h assignerseat.SeatHolders + if err := json.Unmarshal(raw, &h); err != nil { + return false, fmt.Errorf("the holders of %s cannot be read: %w", seat, err) + } + return len(h.Holders) > 0, nil + } +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 5381ec9..419197c 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -53,6 +53,8 @@ type Deps struct { Warranted func(asker, ask string, open func() (bool, error)) error // Bounds is the runner's key-ttl state bound (A-157, A-158); nil keeps no bound. Bounds Bounds + // Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19). + Holder func(seat string) (bool, error) // Go runs what goes on beside the call (a run's recording); nil is a goroutine. Go func(func()) } @@ -69,6 +71,8 @@ type Gate struct { unmarkedAt map[string]bool // inFlight is each ask whose recording runs now. inFlight map[string]bool + // failingSince is when the state approvals began to fail; zero while it is read. + failingSince time.Time } // New is a gate over d. diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go index a01a038..20af7ab 100644 --- a/node-tools/internal/approval/record.go +++ b/node-tools/internal/approval/record.go @@ -200,6 +200,10 @@ func (g *Gate) WatchBounds() (func(), error) { return func() {}, nil } return g.d.Bounds.Watch(g.d.Node+".**", func(key string) { + if key == g.unreadableKey() { + g.unreadableExpired() + return + } rest := strings.TrimPrefix(key, g.d.Node+".") kind, id, _ := strings.Cut(rest, ".") if open, err := g.unrecorded(id); err != nil || !open { diff --git a/node-tools/internal/approval/run.go b/node-tools/internal/approval/run.go index c828e6f..b0f525c 100644 --- a/node-tools/internal/approval/run.go +++ b/node-tools/internal/approval/run.go @@ -38,6 +38,9 @@ func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { if why := refusedUnasked(c.Args); why != "" { return nil, g.refused(c, "", why) } + if why := g.held(); why != "" { + return nil, g.refused(c, "", why) + } act, err := ActOf(c, g.d.Node) if err != nil { return nil, g.refused(c, "", err.Error()) @@ -48,8 +51,10 @@ func (g *Gate) Run(c Call, run func() (any, error)) (any, error) { } named, err := g.d.State.Get(g.actKey(digest)) if err != nil { + g.stateFailed(err) return nil, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) } + g.stateRead() if named == nil { return nil, g.raise(c, act, digest, since) } @@ -163,6 +168,7 @@ func (g *Gate) judge(c Call, act operatorchannelclient.Act, digest, id, since st case h == operatorchannelclient.Refused: return nil, g.refused(c, digest, "its warrant does not hold: "+errText(err)) case h == "" && err != nil: + g.stateFailed(err) return nil, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()) case err != nil: // the act about to run is not the one the option bound: nothing runs, the warrant stays used return nil, g.refused(c, digest, err.Error()) From 6851ee165714c917ea564ae8f2380a0e7c49b7c3 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:24:41 +0100 Subject: [PATCH 11/16] A-7 tools: mesh_approvals reads and steers what was asked; mesh_describe says a mark and its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh MCP server gains mesh_approvals: with no argument the calls asked on the machine in the last day, newest first, with state and fingerprint, the marks the gate applies with their sources and the catalog revision, and the state's fill against its cap (every machine's keys together); an ask read whole with the act's digest; record asking the hand-act again, and cancel taking an open ask back, each recorded in the hand-act log first and refused when it cannot be. mesh_describe answers needs-approval with its source, and a marked call that reached no one names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §18, §6 (the mesh MCP server's withheld answer), §22 (eight tools) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 15 ++ node-tools/cmd/node-tools/main.go | 4 +- node-tools/internal/approval/approval_test.go | 29 +++ node-tools/internal/approval/gate.go | 6 + node-tools/internal/approval/tools.go | 168 ++++++++++++++++++ node-tools/internal/console/address.go | 16 +- node-tools/internal/console/approvals.go | 99 +++++++++++ node-tools/internal/console/mcp.go | 2 + node-tools/internal/console/wait_test.go | 39 +++- 9 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 node-tools/internal/approval/tools.go create mode 100644 node-tools/internal/console/approvals.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index d048b7c..48dfe63 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + "fmt" "log" "time" @@ -147,3 +148,17 @@ func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error) return err } } + +// gateTools is the gate as mesh_approvals reads it, with its state's fill read from the bucket (§13, §18). +type gateTools struct { + *approval.Gate + state approvalsState +} + +func (t gateTools) Fill() (uint64, int64, error) { + info, err := t.state.conn.StateInfo(t.state.module, approval.StateName) + if err != nil || info == nil { + return 0, 0, fmt.Errorf("the state %s's fill cannot be read: %v", approval.StateName, err) + } + return info.Bytes, info.MaxBytes, nil +} diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 5796757..8a81f04 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -118,7 +118,9 @@ func main() { node = "?" } who := node + "." + cred.Module - up, err = console.Serve(console.NewSurface(conn, source, who), listen) + surface := console.NewSurface(conn, source, who) + surface.Approvals = gateTools{Gate: gate, state: approvalsState{conn: conn, module: cred.Module}} + up, err = console.Serve(surface, listen) if err != nil { log.Fatalf("node-tools: %v", err) } diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 9c42034..230cd1e 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -563,6 +563,35 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys) } }}, + {"§18: the asks of the last day, one read whole, the marks applied with their sources, a record again and a cancel", func(t *testing.T, w *world) { + _, _ = w.do(call("wipe", `{"dir":"scratch"}`)) + list, err := w.g.List() + if err != nil || len(list) != 1 || list[0].State != "asked" || list[0].Address != "shell.wipe" || list[0].Fingerprint == "" { + t.Fatalf("%+v %v", list, err) + } + whole, err := w.g.Read(list[0].Ask) + if err != nil || string(whole.Arguments) != `{"dir":"scratch"}` || !strings.HasPrefix(whole.Digest, "sha256:") { + t.Fatalf("%+v %v", whole, err) + } + applied := w.g.Applied() + if len(applied) != 1 || applied[0].Tools["wipe"] != "entry shell src2:1" || applied[0].Verbs["node-login-shell.execute"] != "seat node-login-shell" || applied[0].Revision != "7" { + t.Fatalf("applied %+v", applied) + } + if ok, src := w.g.Describe("shell", "", "wipe"); !ok || src != "entry shell src2:1" { + t.Errorf("describe %v %q", ok, src) + } + if ok, src := w.g.Describe("", "node-login-shell", "execute"); !ok || src != "seat node-login-shell" { + t.Errorf("describe a seat verb %v %q", ok, src) + } + if err := w.g.Again(list[0].Ask); err == nil { + t.Error("an ask not run was asked its hand-act again") + } + cancelled := "" + w.g.d.Publish = func(module, subject string, body any, id string) error { cancelled = subject + " " + id; return nil } + if err := w.g.Cancel(list[0].Ask); err != nil || cancelled != "mesh.seat.operator-channel.accept.cancel.shell cancel."+list[0].Ask { + t.Fatalf("cancel %v %q", err, cancelled) + } + }}, } { t.Run(tc.name, func(t *testing.T) { tc.run(t, newWorld()) }) } diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 419197c..787014a 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -73,6 +73,8 @@ type Gate struct { inFlight map[string]bool // failingSince is when the state approvals began to fail; zero while it is read. failingSince time.Time + // served is each module this runner serves, with the build its bundle serves, as its last load said (§18). + served map[string]string } // New is a gate over d. @@ -102,6 +104,10 @@ func (g *Gate) Unserved(module, fingerprint string, registered []string) []strin } g.mu.Lock() g.registered[module] = set + if g.served == nil { + g.served = map[string]string{} + } + g.served[module] = fingerprint g.mu.Unlock() if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { return nil diff --git a/node-tools/internal/approval/tools.go b/node-tools/internal/approval/tools.go new file mode 100644 index 0000000..13b4cbf --- /dev/null +++ b/node-tools/internal/approval/tools.go @@ -0,0 +1,168 @@ +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" +) + +// The mesh MCP server's mesh_approvals reads and steers what the gate asked on this machine (§18). + +// Listed is one ask of the last day as mesh_approvals lists it: its state and its act's fingerprint. +type Listed struct { + Ask string `json:"ask"` + Address string `json:"address"` + Module string `json:"module"` + Asked time.Time `json:"asked"` + State string `json:"state"` + Fingerprint string `json:"fingerprint"` +} + +// Whole is one ask read whole: its arguments and the act's digest. +type Whole struct { + Listed + Digest string `json:"digest"` + Act map[string]string `json:"act"` + Arguments json.RawMessage `json:"arguments"` + Recorded bool `json:"recorded"` +} + +// stateOf is a kept ask's state as the runner knows it. +func stateOf(acted, recorded bool, ended string) string { + switch { + case acted && recorded: + return "run, recorded" + case acted: + return "run, not yet recorded" + case ended != "": + return ended + } + return "asked" +} + +// List is the calls asked on this machine in the last day, newest first (§18). +func (g *Gate) List() ([]Listed, error) { + if g.d.State == nil { + return nil, fmt.Errorf("the tool runner's membership binds no state %s", StateName) + } + keys, err := g.d.State.Keys() + if err != nil { + return nil, err + } + prefix := g.d.Node + ".ask." + out := []Listed{} + for _, key := range keys { + if !strings.HasPrefix(key, prefix) { + continue + } + w, err := g.Read(strings.TrimPrefix(key, prefix)) + if err != nil || g.d.Now().Sub(w.Asked) > 24*time.Hour { + continue + } + out = append(out, w.Listed) + } + sort.Slice(out, func(i, j int) bool { return out[i].Asked.After(out[j].Asked) }) + return out, nil +} + +// Read is one ask whole: its arguments and the act's digest (§18). +func (g *Gate) Read(id string) (Whole, error) { + if g.d.State == nil { + return Whole{}, fmt.Errorf("the tool runner's membership binds no state %s", StateName) + } + k, _, err := g.kept(id) + if err != nil { + return Whole{}, err + } + if k == nil { + return Whole{}, fmt.Errorf("no ask %s is kept on %s", id, g.d.Node) + } + module := k.Act["module"] + if module == "" { + module = moduleOf(k) + } + return Whole{Listed: Listed{Ask: id, Address: k.Act["address"], Module: module, Asked: k.Asked, + State: stateOf(k.Acted, k.Recorded, k.Ended), Fingerprint: Fingerprint(k.Ask.About)}, + Digest: k.Ask.About, Act: k.Act, Arguments: k.Arguments, Recorded: k.Recorded}, nil +} + +// Applied are the marks the gate applies on this machine, each module's marked tools and verbs with their source and the +// catalog revision read (§18). +func (g *Gate) Applied() []Marks { + g.mu.Lock() + modules := make([]string, 0, len(g.served)) + for m := range g.served { + modules = append(modules, m) + } + g.mu.Unlock() + sort.Strings(modules) + var out []Marks + if g.d.Catalog == nil { + return out + } + for _, m := range modules { + if marks := Derive(g.d.Catalog, m, g.servedFingerprint(m)); marks.Any() { + out = append(out, marks) + } + } + return out +} + +func (g *Gate) servedFingerprint(module string) string { + g.mu.Lock() + defer g.mu.Unlock() + return g.served[module] +} + +// Describe says whether a tool or a seat verb of a module is marked, and its source, for mesh_describe (§18): the served +// build's marks where this machine serves the module, else the newest entry's and its seat's. +func (g *Gate) Describe(module, seat, name string) (bool, string) { + if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { + return false, "" + } + if seat != "" && module == "" { + for s, d := range g.d.Catalog.Definitions() { + if s != seat { + continue + } + for _, v := range d.Verbs { + if v.Name == name && v.NeedsApproval { + return true, "seat " + seat + } + } + } + return false, "" + } + return Derive(g.d.Catalog, module, g.servedFingerprint(module)).Marked(seat, name) +} + +// Again asks `warranted` again for one acted, unrecorded ask, by hand, in the kept-call form of §16 (§18); the caller +// records the hand act first. +func (g *Gate) Again(id string) error { + w, err := g.Read(id) + if err != nil { + return err + } + if !strings.HasPrefix(w.State, "run, not yet") { + return fmt.Errorf("the ask %s is %s: only a run not yet recorded is asked again", id, w.State) + } + g.recordRun(w.Module, id) + if again, _ := g.Read(id); !again.Recorded { + return fmt.Errorf("mesh-hand-acts.warranted has not answered for ask %s yet", id) + } + return nil +} + +// Cancel takes an open ask back through operator-channel's client (§18); the caller records the hand act first. +func (g *Gate) Cancel(id string) error { + w, err := g.Read(id) + if err != nil { + return err + } + if w.State != "asked" { + return fmt.Errorf("the ask %s is %s, not open", id, w.State) + } + return g.client(w.Module).Cancel(id) +} diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index b8860a7..84de6fd 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -24,6 +24,7 @@ import ( "context" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -103,12 +104,13 @@ func discovery() []map[string]any { }), "description": "Ask the tool runner's four first views again, now, and say what each answers. A hand act, recorded in " + "the runner's log with its reason; for when a picture is doubted, or after an owner's state was made again."}, waitTool(), + approvalsTool(), } } func isDiscovery(name string) bool { switch name { - case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait: + case verbOverview, verbMachine, verbSearch, verbDescribe, verbCall, verbRuntimes, verbFirstViews, verbFirstAgain, verbWait, verbApprovals: return true } return false @@ -949,6 +951,9 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any if name == verbWait { return s.waitAnswer(ctx, args) } + if name == verbApprovals { + return s.approvalsAnswer(args) + } x, err := s.index() if err != nil { return failure("the mesh's discovery failed: " + err.Error()) @@ -1109,6 +1114,10 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any if t.Stale != nil { described["found"] = t.Stale.say() } + if ok, source := s.markedTool(t); ok { + described["needs-approval"] = map[string]any{"source": source, + "says": "the first call asks the operator and runs nothing; the same call again within 30 minutes of the Approve runs once"} + } return answerText(described) case verbCall: @@ -1129,6 +1138,11 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any } if err != nil { why := whyItFailed(t.Address, err) + if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) { + // A marked subject withheld from a machine whose tool runner does not honour the mark (§6). + why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " + + "approval-mark/1: mesh-grants.grants_user_list names it under withheld" + } if t.Stale != nil { why += " (" + t.Stale.say() + ")" } diff --git a/node-tools/internal/console/approvals.go b/node-tools/internal/console/approvals.go new file mode 100644 index 0000000..f189e8b --- /dev/null +++ b/node-tools/internal/console/approvals.go @@ -0,0 +1,99 @@ +package console + +import ( + "log" + "strings" + + "github.com/novox/mesh-tools/node-tools/internal/approval" +) + +// verbApprovals is the mesh MCP server's tool that reads and steers what the gate asked on this machine (design 46 +// 7-approval-mark §18). +const verbApprovals = "mesh_approvals" + +// Approvals is the gate of this machine's tool runner, as mesh_approvals and mesh_describe read it. +type Approvals interface { + List() ([]approval.Listed, error) + Read(id string) (approval.Whole, error) + Applied() []approval.Marks + Describe(module, seat, name string) (bool, string) + Again(id string) error + Cancel(id string) error + // Fill is the state approvals' bytes against its cap, every machine's keys together. + Fill() (bytes uint64, max int64, err error) +} + +func approvalsTool() map[string]any { + str := func(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } + return map[string]any{"name": verbApprovals, "inputSchema": map[string]any{"type": "object", "properties": map[string]any{ + "ask": str("an ask's id: its whole arguments and the act's digest"), + "record": str("an ask's id: ask mesh-hand-acts.warranted again for its run, recorded in the hand-act log first"), + "cancel": str("an ask's id: take that open ask back, recorded in the hand-act log first"), + "why": str("why, for record and cancel, kept in the hand-act log"), + }}, "description": "The calls that needed the operator's approval on this machine: with no argument, those asked in " + + "the last day, newest first, with their state and fingerprint, the marks the tool runner applies (each module's " + + "marked tools and verbs with their source and the catalog revision read) and its state's fill; `ask` reads one " + + "whole, `record` asks its hand-act again, `cancel` takes an open ask back."} +} + +// approvalsAnswer is mesh_approvals. +func (s *Surface) approvalsAnswer(args map[string]any) map[string]any { + if s.Approvals == nil { + return failure("this tool runner has no approval gate") + } + str := func(k string) string { v, _ := args[k].(string); return strings.TrimSpace(v) } + why := str("why") + if why == "" { + why = "asked by hand with no reason given" + } + act := func(id, what, cause string, do func(string) error) map[string]any { + // A hand act, recorded through the seat mesh-hand-acts before it is done (§18); one that cannot be recorded is refused. + log.Printf("[mesh-tools] hand act: %s %s ask %s: %s", s.who, what, id, why) + if err := s.recordAct(map[string]any{"what": what + " ask " + id, "why": why, "cause": cause, "verb": verbApprovals}); err != nil { + return failure("the hand act was not recorded through mesh-hand-acts, so nothing was done: " + err.Error()) + } + if err := do(id); err != nil { + return failure(err.Error()) + } + return answerText(map[string]any{"done": what + " ask " + id, "recorded": "mesh-hand-acts.hand-act"}) + } + switch { + case str("record") != "": + return act(str("record"), "asked the hand-act of", "approval-record", s.Approvals.Again) + case str("cancel") != "": + return act(str("cancel"), "cancelled", "approval-cancel", s.Approvals.Cancel) + case str("ask") != "": + w, err := s.Approvals.Read(str("ask")) + if err != nil { + return failure(err.Error()) + } + return answerText(w) + } + asks, err := s.Approvals.List() + out := map[string]any{"asks": asks, "marks": s.Approvals.Applied()} + if err != nil { + out["asks"], out["asks_why"] = []approval.Listed{}, "the state approvals cannot be read: "+err.Error() + } + if b, max, err := s.Approvals.Fill(); err != nil { + out["fill_why"] = err.Error() + } else { + out["fill"] = map[string]any{"bytes": b, "max_bytes": max, "of": "every machine's keys together"} + } + return answerText(out) +} + +// marked is what mesh_describe and a call that reached no one say of a marked tool or verb (§6, §18). +func (s *Surface) marked(module, seat, name string) (bool, string) { + if s.Approvals == nil { + return false, "" + } + return s.Approvals.Describe(module, seat, name) +} + +// markedTool is marked for a resolved address. +func (s *Surface) markedTool(t target) (bool, string) { + if t.Seat { + return s.marked("", t.Tool.Module, t.Tool.Name) + } + return s.marked(t.Tool.Module, "", t.Tool.Name) +} diff --git a/node-tools/internal/console/mcp.go b/node-tools/internal/console/mcp.go index 1874356..57e4e8b 100644 --- a/node-tools/internal/console/mcp.go +++ b/node-tools/internal/console/mcp.go @@ -63,6 +63,8 @@ type Surface struct { Flat bool // Record, when set, records a hand act in place of the seat mesh-hand-acts' verb (a test's). Record func(args map[string]any) error + // Approvals is this machine's approval gate, read by mesh_approvals and mesh_describe; nil when the runner has none. + Approvals Approvals } // MethodSeats is the console's JSON-RPC method beside MCP's that answers every seat's verbs and holders, in the shape diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 862517f..3cfc486 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -3,6 +3,7 @@ package console import ( "context" "encoding/json" + "errors" "net/http" "os" "reflect" @@ -11,6 +12,7 @@ import ( "testing" "time" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -194,7 +196,7 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { } } sort.Strings(names) - want := []string{"mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"} + want := []string{"mesh_approvals", "mesh_call", "mesh_describe", "mesh_first_view_again", "mesh_first_views", "mesh_machine", "mesh_overview", "mesh_runtimes", "mesh_search", "mesh_wait"} if !reflect.DeepEqual(names, want) { t.Errorf("announced %v, want %v", names, want) } @@ -207,8 +209,43 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { t.Errorf("mesh_wait's schema has no %s: %s", word, schema) } } + // Design 46 7-approval-mark §18: mesh_approvals lists, and records a hand act before it cancels; refused without a record. + gate := &fakeApprovals{} + var recorded []string + s := &Surface{who: "n1.node-tools", Approvals: gate, Record: func(a map[string]any) error { + if a["why"] == "unrecordable" { + return errors.New("mesh-hand-acts does not answer") + } + recorded = append(recorded, a["what"].(string)) + return nil + }} + if out := s.discover(context.Background(), verbApprovals, map[string]any{}); out["isError"] == true || !strings.Contains(fmtAnswer(out), "every machine") { + t.Errorf("the list %v", out) + } + if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a1", "why": "no longer needed"}); out["isError"] == true || + gate.cancelled != "a1" || len(recorded) != 1 || recorded[0] != "cancelled ask a1" { + t.Errorf("a cancel %v, recorded %v", out, recorded) + } + if out := s.discover(context.Background(), verbApprovals, map[string]any{"cancel": "a2", "why": "unrecordable"}); out["isError"] != true || gate.cancelled != "a1" { + t.Errorf("a cancel without a record %v", out) + } } +func fmtAnswer(out map[string]any) string { raw, _ := json.Marshal(out); return string(raw) } + +// fakeApprovals is a gate as mesh_approvals reads it. +type fakeApprovals struct{ cancelled string } + +func (f *fakeApprovals) List() ([]approval.Listed, error) { + return []approval.Listed{{Ask: "a1", State: "asked"}}, nil +} +func (f *fakeApprovals) Read(id string) (approval.Whole, error) { return approval.Whole{}, nil } +func (f *fakeApprovals) Applied() []approval.Marks { return nil } +func (f *fakeApprovals) Describe(_, _, _ string) (bool, string) { return false, "" } +func (f *fakeApprovals) Again(string) error { return nil } +func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil } +func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil } + // A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left // waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it. func TestMeshWaitEndsWhenItsClientHangsUp(t *testing.T) { From ea8d9c5f5674f948ec4865ffb7cff009fbec8b3f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:26:24 +0100 Subject: [PATCH 12/16] Test budget: the approval mark's two tables replace two tests folded into their neighbours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool runner ends milestone G with no more tests than it started with: the no-responders test takes the 503 case, and the stated-capabilities test takes the sent-object row. Builds: design 46 7-approval-mark §22; design 47 G §626 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/noresponders_test.go | 8 +++----- node-tools/internal/runtime/announce_test.go | 14 -------------- node-tools/internal/runtime/ledgers_test.go | 5 +++++ 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index fd008c8..51f0c0a 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -11,15 +11,13 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529). -func TestAToolsOwn503IsNotNothingServes(t *testing.T) { +// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says +// nothing serves. +func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable")) if strings.Contains(got, "nothing serves") || !strings.HasPrefix(got, "scanner.scan failed: ") || !strings.Contains(got, "503") { t.Fatalf("got %q", got) } -} - -func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { for _, err := range []error{bus.ErrNoResponders, fmt.Errorf("ask: %w", bus.ErrNoResponders), nats.ErrNoResponders} { if got := whyItFailed("a.b", err); !strings.HasPrefix(got, "nothing serves a.b") { t.Fatalf("%v: got %q", err, got) diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index 53de9c5..dd58ba0 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -2,7 +2,6 @@ package runtime import ( "encoding/json" - "git.novox.be/novox/mesh-sdk/go/buscalls" "sort" "strings" "testing" @@ -144,16 +143,3 @@ func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) } } - -// Design 47 G §462: the call that reads a sent object is listed under the bus state capability, and the ledger calls keep theirs. -func TestTheSentObjectCallBelongsToTheBusStateCapability(t *testing.T) { - for c, method := range map[string]string{CapBusStateCalls: buscalls.ObjectsSentGet} { - found := false - for _, m := range callFamilies[c] { - found = found || m == method - } - if !found { - t.Errorf("%s does not list %s", c, method) - } - } -} diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index 3d50b3a..798c85b 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "slices" "sort" "strings" "testing" @@ -207,6 +208,10 @@ func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) { if got := strings.Join(CallCapabilities(), ","); got != "approval-mark/1,bus-state-calls,ledger-calls,put-sessions,seat-settle-delay" { t.Fatalf("the runner states %s", got) } + // Design 47 G §462: the call that reads a sent object is listed under the bus state capability. + if !slices.Contains(callFamilies[CapBusStateCalls], buscalls.ObjectsSentGet) { + t.Errorf("%s does not list %s", CapBusStateCalls, buscalls.ObjectsSentGet) + } for capability, methods := range callFamilies { for _, method := range methods { t.Run(capability+" "+method, func(t *testing.T) { From 809288da6972fbb1f63428635202f570a3bfe4ff Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:41:53 +0100 Subject: [PATCH 13/16] E-5 marked waits: a wait on a marked event runs only on the operator's warrant for that one wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mesh_wait goes through the runner's approval gate (node-tools/internal/approval, Gate.Waited): a wait whose subjects overlap a marked subject of any module the catalog half lists (the newest entry of every followed branch, with the served entry of each module this runner serves) is an act of the runner's own module and build (MESH_FINGERPRINT at its start), asked once on operator-channel as "Wait on on ?", and held, subscribing nothing but its ask's decided, until its timeout, its caller's going or the choice. On a warrant it subscribes, its timeout running from then, and answers {answer, approval}. Before the marks are read nothing is asked; a wait from a seat is never marked. The state fixture moves to meshtest so the console's table can hold a gate. Builds: design 34 every-event §34, §35, §36, §37; design 47 G lead answer A-168 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/approvals.go | 10 + node-tools/cmd/node-tools/main.go | 4 +- node-tools/internal/approval/approval_test.go | 102 +----- node-tools/internal/approval/ask.go | 33 +- node-tools/internal/approval/gate.go | 7 + node-tools/internal/approval/marks.go | 99 +++--- node-tools/internal/approval/marks_test.go | 7 + node-tools/internal/approval/wait.go | 296 ++++++++++++++++++ node-tools/internal/console/wait.go | 41 ++- node-tools/internal/console/wait_test.go | 231 +++++++++++++- node-tools/internal/firstview/approval.go | 14 + node-tools/internal/meshtest/state.go | 87 +++++ 12 files changed, 783 insertions(+), 148 deletions(-) create mode 100644 node-tools/internal/approval/wait.go create mode 100644 node-tools/internal/meshtest/state.go diff --git a/node-tools/cmd/node-tools/approvals.go b/node-tools/cmd/node-tools/approvals.go index 48dfe63..99153d6 100644 --- a/node-tools/cmd/node-tools/approvals.go +++ b/node-tools/cmd/node-tools/approvals.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -149,6 +150,15 @@ func warranted(conn *bus.Conn) func(asker, ask string, open func() (bool, error) } } +// hearOne waits on the runner's connection for the first event on the subjects whose body match takes (design 34 +// every-event §36: a marked wait's ask's decided). +func hearOne(conn *bus.Conn) func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) { + return func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) { + _, ok, _, err := conn.HearFirst(ctx, subjects, within, match) + return ok, err + } +} + // gateTools is the gate as mesh_approvals reads it, with its state's fill read from the bucket (§13, §18). type gateTools struct { *approval.Gate diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 8a81f04..302afde 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -80,7 +80,9 @@ func main() { gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), Wait: source.WaitCatalog, Findings: source.Findings(), Logf: log.Printf, State: approvalsState{conn: conn, module: cred.Module}, Publish: publishAsk(conn), Record: routerRecord(conn), Warranted: warranted(conn), Bounds: boundState{conn: conn, module: cred.Module}, - Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) })}) + Holder: approval.HoldersIn(func() (map[string][]byte, error) { return source.State(firstview.SeatHoldersRead) }), + // The runner's own build, the one a marked wait's act binds (design 34 every-event §35). + Build: os.Getenv(runtime.FingerprintWord), Hear: hearOne(conn)}) // A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157). conn.OnMembership(func(bus.Membership) { go gate.StateAgain() }) opts = append(opts, runtime.WithGate(gate)) diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index 230cd1e..f1cf18f 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -3,7 +3,6 @@ package approval import ( "encoding/json" "errors" - "sort" "strings" "sync" "testing" @@ -15,83 +14,14 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" + + mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) -// mem is the runner's state approvals as a fixture; fail makes every read and write fail, as a bus refusing them. -type mem struct { - mu sync.Mutex - vals map[string]json.RawMessage - revs map[string]uint64 - n uint64 - fail error -} +// mem is the runner's state approvals as a fixture (meshtest.State). +type mem = mt.State -func newMem() *mem { return &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } - -func (m *mem) Get(k string) (*stdio.StateEntry, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return nil, m.fail - } - v, ok := m.vals[k] - if !ok { - return nil, nil - } - return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil -} -func (m *mem) Create(k string, v any) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - if _, ok := m.vals[k]; ok { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) Update(k string, v any, rev uint64) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - if rev == 0 || m.revs[k] != rev { - return 0, stdio.ErrChanged - } - return m.put(k, v) -} -func (m *mem) Put(k string, v any) (uint64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return 0, m.fail - } - return m.put(k, v) -} -func (m *mem) Keys() ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.fail != nil { - return nil, m.fail - } - var out []string - for k := range m.vals { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} -func (m *mem) put(k string, v any) (uint64, error) { - raw, ok := v.(json.RawMessage) - if !ok { - raw, _ = json.Marshal(v) - } - m.n++ - m.vals[k], m.revs[k] = raw, m.n - return m.n, nil -} +func newMem() *mem { return mt.NewState() } // world is a gate on a fixture mesh: the catalog half, the router's records as the router writes them (mesh-catalog's // messenger, asks.go), the asks published, the clock. @@ -262,11 +192,11 @@ func theActAndItsAsk(t *testing.T) { {strings.Repeat("x", 50), "n1", "Run " + strings.Repeat("x", 50) + "?"}, {strings.Repeat("y", 80), "n1", "Run " + strings.Repeat("y", 54) + "…?"}, } { - if got := headlineOf(tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { + if got := headlineOf("Run", tc.name, tc.node, true); got != tc.want || len([]rune(got)) > 60 { t.Errorf("headline %q, want %q", got, tc.want) } } - if got := headlineOf("wipe", "n1", false); got != "Run wipe on n1? Part shown" { + if got := headlineOf("Run", "wipe", "n1", false); got != "Run wipe on n1? Part shown" { t.Errorf("headline %q", got) } } @@ -314,9 +244,9 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Errorf("explanation %q", a.Explanation) } var k operatorchannelclient.Kept - _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) - if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.vals["n1.act."+d]) != `"`+a.ID+`"` { - t.Errorf("kept %+v, act key %s", k, w.state.vals["n1.act."+d]) + _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) + if k.Act["arg.dir"] != "scratch" || string(k.Arguments) != `{"dir":"scratch"}` || string(w.state.Vals["n1.act."+d]) != `"`+a.ID+`"` { + t.Errorf("kept %+v, act key %s", k, w.state.Vals["n1.act."+d]) } }}, {"§12: with no record within 5 seconds, Not run, asked; not yet taken by the router", func(t *testing.T, w *world) { @@ -385,10 +315,10 @@ func TestTheRefusalsAndAnswers(t *testing.T) { rec.Warrant.AskDigest = operatorchannelclient.Digest(a) w.records["shell."+a.ID] = rec var k operatorchannelclient.Kept - _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) k.Ask = a raw, _ := json.Marshal(k) - w.state.vals["n1.ask."+a.ID] = raw + w.state.Vals["n1.ask."+a.ID] = raw if _, err := w.do(call("wipe", `{"dir":"scratch"}`)); err == nil || w.runs != 0 || !strings.Contains(err.Error(), "nothing is done") { t.Fatalf("%v, runs %d", err, w.runs) } @@ -495,7 +425,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys) } var k operatorchannelclient.Kept - _ = json.Unmarshal(w.state.vals["n1.ask."+a.ID], &k) + _ = json.Unmarshal(w.state.Vals["n1.ask."+a.ID], &k) if !k.Recorded || !k.Acted { t.Fatalf("kept %+v", k) } @@ -517,7 +447,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("%v", err) } w.g.d.Publish = pub - w.state.vals = map[string]json.RawMessage{} + w.state.Vals = map[string]json.RawMessage{} _, _ = w.do(call("wipe", `{}`)) w.g.d.Record = func(string, string) (*stdio.StateEntry, error) { return nil, errors.New("timeout") } if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "the router's record of its ask") || w.runs != 0 { @@ -546,7 +476,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { {"§19: the state failing refuses, raises approvals-unreadable after five minutes, and clears when read again", func(t *testing.T, w *world) { stop, _ := w.g.WatchBounds() defer stop() - w.state.fail = errors.New("the bus is unreachable") + w.state.Fail = errors.New("the bus is unreachable") if _, err := w.do(call("wipe", `{}`)); err == nil || !strings.Contains(err.Error(), "cannot be read") { t.Fatalf("%v", err) } @@ -557,7 +487,7 @@ func TestTheRefusalsAndAnswers(t *testing.T) { if w.findings.raised[keys.EscapeName("n1.approvals")] != KindApprovalsUnreadable { t.Fatalf("findings %v", w.findings.raised) } - w.state.fail = nil + w.state.Fail = nil w.g.StateAgain() // a membership change heard, no further call if len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { t.Fatalf("findings %v, bounds %v", w.findings.raised, w.bounds.keys) diff --git a/node-tools/internal/approval/ask.go b/node-tools/internal/approval/ask.go index 43e1bc9..60a27da 100644 --- a/node-tools/internal/approval/ask.go +++ b/node-tools/internal/approval/ask.go @@ -70,7 +70,7 @@ func (g *Gate) askFor(c Call, digest, id string) seat.Ask { if c.Seat != "" { name = c.Seat + "." + c.Name } - headline := headlineOf(name, g.d.Node, whole) + headline := headlineOf("Run", name, g.d.Node, whole) first, _, _ := strings.Cut(strings.TrimSpace(c.Description), "\n") if first == "" { first = name @@ -92,22 +92,23 @@ func (g *Gate) askFor(c Call, digest, id string) seat.Ask { Level: operatorchannelclient.Approve, Binds: digest}}} } -// headlineOf is "Run on ?", "Run ?" when that is longer than the router allows, and the names cut with -// "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). -func headlineOf(name, node string, whole bool) string { +// headlineOf is " on ?", " ?" when that is longer than the router allows, and the names cut +// with "…" when that too is longer; ending "Part shown" whenever an argument is not shown whole (§10, Q-HL). verb is "Run" +// for a call and "Wait on" for a wait (design 34 every-event §35). +func headlineOf(verb, name, node string, whole bool) string { suffix := "" if !whole { suffix = " Part shown" } max := operatorchannelclient.HeadlineLength fits := func(s string) bool { return len([]rune(s)) <= max } - if h := "Run " + name + " on " + node + "?" + suffix; fits(h) { + if h := verb + " " + name + " on " + node + "?" + suffix; fits(h) { return h } - if h := "Run " + name + "?" + suffix; fits(h) { + if h := verb + " " + name + "?" + suffix; fits(h) { return h } - room := max - len([]rune("Run …?"+suffix)) + room := max - len([]rune(verb+" …?"+suffix)) r := []rune(name) if room < 1 { room = 1 @@ -115,7 +116,7 @@ func headlineOf(name, node string, whole bool) string { if len(r) > room { r = r[:room] } - return "Run " + string(r) + "…?" + suffix + return verb + " " + string(r) + "…?" + suffix } // firstCall is a marked call whose act has no ask open: refused at once when its arguments may not be asked (§9), else @@ -140,7 +141,15 @@ func (g *Gate) firstCall(c Call) (any, error) { // the first answer (§12), always an error, never a result. func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince string) error { id := newID() - a := g.askFor(c, digest, id) + if err := g.keepAsk(c, act, digest, g.askFor(c, digest, id)); err != nil { + return err + } + return g.firstAnswer(c, id, digest, markedSince) +} + +// keepAsk raises an ask and keeps it in the runner's state with the act and the whole arguments, its act's key naming it +// (§10, §13); refused, as an error, when it cannot be. +func (g *Gate) keepAsk(c Call, act operatorchannelclient.Act, digest string, a seat.Ask) error { cl := g.client(c.Module) if size := keptSize(a, act, c.Args); size > MostKeptValue { return g.refused(c, digest, fmt.Sprintf("its ask would keep %d bytes, more than the %d a value of the state %s holds", size, MostKeptValue, StateName)) @@ -148,15 +157,15 @@ func (g *Gate) raise(c Call, act operatorchannelclient.Act, digest, markedSince if err := cl.Ask(a); err != nil { return g.refused(c, digest, "the ask could not be raised: "+err.Error()) } - if err := g.keepCall(id, act, c.Args); err != nil { + if err := g.keepCall(a.ID, act, c.Args); err != nil { g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its call could not be kept: "+err.Error()) } - if _, err := g.d.State.Put(g.actKey(digest), id); err != nil { + if _, err := g.d.State.Put(g.actKey(digest), a.ID); err != nil { g.stateFailed(err) return g.refused(c, digest, "the ask was raised and its act could not be kept: "+err.Error()) } - return g.firstAnswer(c, id, digest, markedSince) + return nil } // keptSize is the bytes the kept ask would take. diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go index 787014a..315db22 100644 --- a/node-tools/internal/approval/gate.go +++ b/node-tools/internal/approval/gate.go @@ -2,6 +2,7 @@ package approval import ( "context" + "encoding/json" "fmt" "sort" "strings" @@ -55,6 +56,12 @@ type Deps struct { Bounds Bounds // Holder says whether a module holds a seat, from the assigner's view of the seats' holders the runner follows (§19). Holder func(seat string) (bool, error) + // Build is the tool runner's own fingerprint, read from its MESH_FINGERPRINT at its start: the build a marked wait's + // act binds; empty while unknown, when every marked wait is refused (design 34 every-event §35). + Build string + // Hear waits within the bound for the first event on the subjects whose body match takes, or the caller's going: a + // marked wait's ask's `decided` (every-event §36); false when none came. + Hear func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) // Go runs what goes on beside the call (a run's recording); nil is a goroutine. Go func(func()) } diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go index a61d59a..824ef6e 100644 --- a/node-tools/internal/approval/marks.go +++ b/node-tools/internal/approval/marks.go @@ -29,6 +29,9 @@ type Catalog interface { Newest(module string) (catalogseat.CatalogEntry, bool) // Definitions are the seat definitions the catalog half carries, by seat. Definitions() map[string]seatdef.Definition + // Entries are the newest entry of every module in every followed branch's half, a marked wait's marks (lead answer + // A-168). + Entries() []catalogseat.CatalogEntry } // Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and @@ -89,61 +92,67 @@ func Derive(c Catalog, module, fingerprint string) Marks { entries = append(entries, e) } for _, e := range entries { - source := "entry " + e.Module + " " + orNone(e.Fp) - var said marked - if raw, err := json.Marshal(e.Manifest); err == nil { - _ = json.Unmarshal(raw, &said) - } - serves := map[string]bool{} - for _, sv := range e.Serves { - serves[sv] = true - seat, verb, _ := strings.Cut(sv, ".") - for _, v := range defs[seat].Verbs { - if v.Name == verb && v.NeedsApproval { - m.Verbs[sv] = "seat " + seat - } + m.add(e, defs) + } + return m +} + +// add takes one entry's marks into m: its `needs-approval` (a tool, a verb it serves or an event it declares), the seat +// definitions' marks of every verb it serves, and every stream it declares with a `grant`, each with its source. +func (m *Marks) add(e catalogseat.CatalogEntry, defs map[string]seatdef.Definition) { + source := "entry " + e.Module + " " + orNone(e.Fp) + var said marked + if raw, err := json.Marshal(e.Manifest); err == nil { + _ = json.Unmarshal(raw, &said) + } + serves := map[string]bool{} + for _, sv := range e.Serves { + serves[sv] = true + seat, verb, _ := strings.Cut(sv, ".") + for _, v := range defs[seat].Verbs { + if v.Name == verb && v.NeedsApproval { + m.Verbs[sv] = "seat " + seat } } - events := map[string]bool{} - for _, ev := range said.Emits { + } + events := map[string]bool{} + for _, ev := range said.Emits { + events[ev] = true + } + for _, s := range said.Streams { + for _, ev := range s.Events { events[ev] = true } - for _, s := range said.Streams { - for _, ev := range s.Events { - events[ev] = true + } + for _, name := range said.NeedsApproval { + switch { + case serves[name]: + if _, bySeat := m.Verbs[name]; !bySeat { + m.Verbs[name] = source + } + case events[name]: + for _, s := range eventSubjects(e.Module, name) { + m.Subjects[s] = source } + default: + m.Tools[name] = source + } + } + for _, s := range said.Streams { + if len(s.Grant) == 0 || string(s.Grant) == "null" { + continue } - for _, name := range said.NeedsApproval { - switch { - case serves[name]: - if _, bySeat := m.Verbs[name]; !bySeat { - m.Verbs[name] = source - } - case events[name]: - for _, s := range eventSubjects(e.Module, name) { - m.Subjects[s] = source - } - default: - m.Tools[name] = source - } + wire := make([]string, 0, len(s.Events)) + for _, ev := range s.Events { + wire = append(wire, buscompose.WirePattern(ev)) } - for _, s := range said.Streams { - if len(s.Grant) == 0 || string(s.Grant) == "null" { - continue - } - wire := make([]string, 0, len(s.Events)) - for _, ev := range s.Events { - wire = append(wire, buscompose.WirePattern(ev)) - } - for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { - m.Subjects[sub] = source - if !strings.HasSuffix(sub, ">") { - m.Subjects[sub+".>"] = source - } + for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { + m.Subjects[sub] = source + if !strings.HasSuffix(sub, ">") { + m.Subjects[sub+".>"] = source } } } - return m } // eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index 85053f6..39573cf 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -32,6 +32,13 @@ func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { return e, ok } func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } +func (c *fakeCatalog) Entries() []catalogseat.CatalogEntry { + var out []catalogseat.CatalogEntry + for _, e := range c.newest { + out = append(out, e) + } + return out +} func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry { return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} diff --git a/node-tools/internal/approval/wait.go b/node-tools/internal/approval/wait.go new file mode 100644 index 0000000..7fa963c --- /dev/null +++ b/node-tools/internal/approval/wait.go @@ -0,0 +1,296 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/outward" +) + +// Wait is one mesh_wait call as the gate judges it (design 34 every-event §34 to §36): the event names given, the subjects +// they became (after `from`), the filter and the timeout. +type Wait struct { + Names []string + From string + Subjects []string + Filter map[string]any + Timeout time.Duration +} + +// Waited is a wait through the gate (every-event §34 to §36). An unmarked wait is heard at once by hear, given how long it +// may wait. A marked one is heard only on the operator's warrant for that one wait, its timeout running from then; with +// none it subscribes to nothing, its ask raised once under the runner's own module's name, and is answered as an error at +// its timeout, when its caller goes, or as soon as the router's record says the ask was chosen, never in a hot loop. c is +// the call as the console makes it: its address, its tool's name, its caller and its arguments as given. +func (g *Gate) Waited(ctx context.Context, c Call, w Wait, hear func(within time.Duration) (any, error)) (any, error) { + deadline := g.d.Now().Add(w.Timeout) + // A wait from a seat is never marked, and a seat's event never is (§32, §34). + if w.From == "seat" || !outsideSeats(w.Subjects) { + return hear(w.Timeout) + } + if g.d.Catalog == nil { + return nil, g.holdRefused(ctx, deadline, "this wait is refused: the tool runner reads no catalog half, so the marks are not read") + } + if err := g.d.Catalog.Ready(); err != nil { + // Before the marks are read nothing is asked: the wait waits for the first view within its own timeout and is + // then judged, and is refused at its timeout when it does not come (§34). + if g.d.Wait != nil { + err = g.waitCatalog(ctx, w.Timeout) + } + if err != nil { + return nil, g.holdRefused(ctx, deadline, fmt.Sprintf("this wait is refused at its timeout: the marks are not yet read (%v); "+ + "mesh_first_views says what the tool runner waits for", err)) + } + } + if !overlapsAny(w.Subjects, g.waitMarks()) { + return hear(deadline.Sub(g.d.Now())) + } + c.Module, c.Seat, c.Fingerprint = g.d.Runner, "", g.d.Build + if c.Fingerprint == "" { + return nil, g.holdRefused(ctx, deadline, "this wait needs the operator's approval, and the tool runner's own fingerprint is "+ + "unknown (its MESH_FINGERPRINT was not set at its start), so no warrant can bind its build") + } + if b, ok := g.d.State.(Binding); g.d.State == nil || ok && !b.Bound() { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", "the tool runner's membership binds no state "+StateName+" to keep its asks in").Error()) + } + if why := refusedUnasked(c.Args); why != "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) + } + if why := g.held(); why != "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", why).Error()) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) + } + digest, err := operatorchannelclient.ActDigest(act) + if err != nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, "", err.Error()).Error()) + } + named, err := g.d.State.Get(g.actKey(digest)) + if err != nil { + g.stateFailed(err) + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()).Error()) + } + g.stateRead() + anew := func() (any, error) { + id := newID() + if err := g.keepAsk(c, act, digest, g.askForWait(c, w, digest, id)); err != nil { + return nil, g.holdRefused(ctx, deadline, err.Error()) + } + return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, true) + } + if named == nil { + return anew() + } + var id string + if err := json.Unmarshal(named.Value, &id); err != nil || id == "" { + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)).Error()) + } + rec, err := g.record(c.Module, id) + switch { + case err != nil: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" cannot be read: "+err.Error()).Error()) + case rec == nil, rec.State == "open", rec.State == "held-back": + return nil, g.holdAsk(ctx, deadline, c.Module, id, digest, false) + case rec.State == "refused": + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router refused its ask "+id+": "+orNone(rec.Answer)).Error()) + case rec.State != "chosen": + return anew() // expired, cancelled, replaced: asked again (§15, §17) + } + wt := rec.Warrant + if wt == nil { + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "the router's record of its ask "+id+" is chosen and carries no warrant").Error()) + } + at, _ := operatorchannelclient.Time(wt.At) + if wt.Option == OptionDecline { + if g.d.Now().Before(at.Add(DeclinedFor)) { + return nil, g.holdRefused(ctx, deadline, declinedWords(id, at)) + } + return anew() + } + if g.d.Now().After(at.Add(RunWithin)) { + return anew() + } + var answer any + var ran error + h, err := g.client(c.Module).Take(*wt, func(o seat.Option, _ seat.Warrant) error { + if err := operatorchannelclient.Performs(o, act); err != nil { + ran = err + return err + } + // On the warrant it subscribes, and the wait's timeout runs from then (§35). + answer, ran = hear(w.Timeout) + return nil + }) + switch { + case h == operatorchannelclient.Done: + return anew() // already run: one warrant is one wait (§35) + case h == operatorchannelclient.Refused: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its warrant does not hold: "+errText(err)).Error()) + case h == "" && err != nil: + g.stateFailed(err) + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, "its state "+StateName+" cannot be written: "+err.Error()).Error()) + case err != nil: + return nil, g.holdRefused(ctx, deadline, g.refused(c, digest, err.Error()).Error()) + } + return g.ran(c, id, rec, answer, ran) +} + +// outsideSeats says a subject is not a seat's: only such a subject can be marked (§32, §34). +func outsideSeats(subjects []string) bool { + for _, s := range subjects { + if !strings.HasPrefix(s, "mesh.seat.") { + return true + } + } + return false +} + +// waitMarks are the marked subjects of every module the catalog half lists (§33): the newest entry of every followed +// branch, and the served entry of each module this runner serves (lead answer A-168), so a mark is never lost. +func (g *Gate) waitMarks() map[string]string { + m := Marks{Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}} + defs := g.d.Catalog.Definitions() + for _, e := range g.d.Catalog.Entries() { + m.add(e, defs) + } + g.mu.Lock() + served := make(map[string]string, len(g.served)) + for module, fp := range g.served { + served[module] = fp + } + g.mu.Unlock() + for _, fp := range served { + if e, ok, _ := g.d.Catalog.Served(fp); ok { + m.add(e, defs) + } + } + return m.Subjects +} + +// overlapsAny says a subject a wait subscribes overlaps a marked subject (§34). +func overlapsAny(subjects []string, marked map[string]string) bool { + for _, s := range subjects { + for mk := range marked { + if buscompose.Overlaps(s, mk) { + return true + } + } + } + return false +} + +// waitCatalog waits for the catalog half's first view within the bound, or until the caller goes. +func (g *Gate) waitCatalog(ctx context.Context, within time.Duration) error { + done := make(chan error, 1) + go func() { done <- g.d.Wait(within) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// pause waits until the deadline or the caller's going. +func (g *Gate) pause(ctx context.Context, deadline time.Time) { + if left := deadline.Sub(g.d.Now()); left > 0 { + select { + case <-g.d.After(left): + case <-ctx.Done(): + } + } +} + +// holdRefused is a refused marked wait, said at its timeout or when its caller goes, never at once, so a watcher's loop +// is never a hot one (§36). +func (g *Gate) holdRefused(ctx context.Context, deadline time.Time, words string) error { + g.pause(ctx, deadline) + if !strings.HasPrefix(words, "Not run") { + words = "Not run: " + words + } + return fmt.Errorf("%s", words) +} + +// holdAsk holds a marked wait whose ask is open, subscribing to nothing but its ask's `decided` (§36): it answers, as an +// error, at its timeout, when its caller goes, or once the router's record says the ask was chosen, the event only waking it. +func (g *Gate) holdAsk(ctx context.Context, deadline time.Time, module, id, digest string, first bool) error { + for { + left := deadline.Sub(g.d.Now()) + heard := false + if left > 0 && ctx.Err() == nil { + var err error + if g.d.Hear != nil { + heard, err = g.d.Hear(ctx, []string{seat.SubjectDecided(module)}, left, func(body json.RawMessage) bool { + var w seat.Warrant + return json.Unmarshal(body, &w) == nil && w.Ask == id + }) + } + if g.d.Hear == nil || err != nil { + g.pause(ctx, deadline) + } + } + rec, _ := g.record(module, id) + if heard && (rec == nil || rec.State != "chosen") { + continue // the event only wakes it: the record says nothing chosen yet + } + return g.waitWords(rec, id, digest, first) + } +} + +// waitWords are §12's and §17's words for a marked wait not run, "this wait" for "the call" (§36). +func (g *Gate) waitWords(rec *seat.AsksByAskerIDValue, id, digest string, first bool) error { + switch { + case rec == nil: + return fmt.Errorf("Not run, asked; not yet taken by the router (ask %s, fingerprint %s); make this wait again later", id, Fingerprint(digest)) + case rec.State == "chosen" && rec.Warrant != nil && rec.Warrant.Option == OptionDecline: + at, _ := operatorchannelclient.Time(rec.Warrant.At) + return fmt.Errorf("%s", declinedWords(id, at)) + case rec.State == "chosen", first: + return fmt.Errorf("Not run: this wait needs the operator's approval. Asked on %s at %s (ask %s, fingerprint %s); make this wait "+ + "again within 30 minutes of the approval; the ask expires at %s", orNone(strings.Join(rec.OfferedOn, ", ")), rec.Opened, + id, Fingerprint(digest), rec.Expires) + case rec.State == "open", rec.State == "held-back": + return fmt.Errorf("Not run: still waiting for the operator's answer to ask %s (fingerprint %s); it expires at %s", + id, Fingerprint(digest), rec.Expires) + } + return fmt.Errorf("Not run: the ask %s ended %s; make this wait again to ask anew", id, rec.State) +} + +func declinedWords(id string, at time.Time) string { + return fmt.Sprintf("Not run: the operator declined ask %s; this wait is not asked again until %s", id, + operatorchannelclient.FormatTime(at.Add(DeclinedFor))) +} + +// askForWait is a marked wait's ask (§35): "Wait on on ?", shortened as §10 says, and an explanation naming +// every event name one per line, the filter, and the bound as the timeout in minutes, then the act's fingerprint. +func (g *Gate) askForWait(c Call, w Wait, digest, id string) seat.Ask { + a := g.askFor(c, digest, id) + filter, whole := "none", true + if len(w.Filter) > 0 { + raw, _ := json.Marshal(w.Filter) + filter = string(raw) + if r, ok := outward.Check(filter); !ok { + filter, whole = shapeOf(r), false + } + } + a.Headline = headlineOf("Wait on", strings.Join(w.Names, ", "), g.d.Node, whole) + lines := append([]string{"Wait on " + g.d.Node + ", called through the mesh's tools, which cannot tell who called, for:"}, w.Names...) + lines = append(lines, "Filter: "+filter, fmt.Sprintf("Timeout: %s minutes", minutes(w.Timeout)), "Fingerprint "+Fingerprint(digest)) + a.Explanation = strings.Join(lines, "\n") + a.Options[0].Does = "the same wait, made again within 30 minutes, subscribes once, its timeout running from then" + a.Options[1].Does = "nothing is heard; the same wait is not asked again for 10 minutes" + return a +} + +// minutes is a timeout in minutes, as few digits as say it. +func minutes(d time.Duration) string { + return strings.TrimSuffix(strings.TrimRight(fmt.Sprintf("%.2f", d.Minutes()), "0"), ".") +} diff --git a/node-tools/internal/console/wait.go b/node-tools/internal/console/wait.go index a93a16d..541e620 100644 --- a/node-tools/internal/console/wait.go +++ b/node-tools/internal/console/wait.go @@ -20,6 +20,8 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/catalogue" + + "github.com/novox/mesh-tools/node-tools/internal/approval" ) // WaitDefault is how long a wait lasts when no timeout is given, and WaitLongest the most it may last @@ -107,9 +109,42 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin } } + listen := func(within time.Duration) (any, error) { + return s.hear(ctx, names, subjects, undeclared, known.source, unchecked, filter, within) + } + // A wait whose subjects overlap a marked event's runs only on the operator's warrant for that one wait (design 34 + // every-event §34 to §36), judged by the runner's approval gate. + var answer any + if g, ok := s.Approvals.(waitGate); ok { + given := map[string]any{} + for _, k := range []string{"subjects", "from", "filter", "timeout"} { + if v, has := args[k]; has { + given[k] = v + } + } + raw, _ := json.Marshal(given) + answer, err = g.Waited(ctx, approval.Call{Address: verbWait, Name: verbWait, Caller: s.who, Args: raw}, + approval.Wait{Names: names, From: from, Subjects: subjects, Filter: filter, Timeout: timeout}, listen) + } else { + answer, err = listen(timeout) + } + if err != nil { + return failure(err.Error()) + } + return answerText(answer) +} + +// waitGate is the runner's approval gate as mesh_wait goes through it (design 34 every-event §34 to §36). +type waitGate interface { + Waited(ctx context.Context, c approval.Call, w approval.Wait, hear func(within time.Duration) (any, error)) (any, error) +} + +// hear subscribes the wait's subjects for within and answers the first matching event, or that it timed out. +func (s *Surface) hear(ctx context.Context, names, subjects, undeclared []string, source, unchecked string, filter map[string]any, + timeout time.Duration) (map[string]any, error) { heard, ok, refused, err := s.conn.HearFirst(ctx, subjects, timeout, func(body json.RawMessage) bool { return bodyMatches(body, filter) }) if err != nil { - return failure("mesh_wait could not wait: " + err.Error()) + return nil, fmt.Errorf("mesh_wait could not wait: %w", err) } var answer map[string]any if !ok { @@ -117,7 +152,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin "read": "nothing matching was heard; read the state once from the verb that owns it, then wait again"} if len(undeclared) > 0 { answer["undeclared"] = fmt.Sprintf("no declaration names %s: no seat definition and no module's entry in %s "+ - "declares that event", strings.Join(undeclared, ", "), known.source) + "declares that event", strings.Join(undeclared, ", "), source) } } else { name, from, _ := buscompose.EventName(heard.Subject) @@ -139,7 +174,7 @@ func (s *Surface) waitAnswer(ctx context.Context, args map[string]any) map[strin if len(refused) > 0 { answer["refused"] = refused } - return answerText(answer) + return answer, nil } // halfReader is a Source that also holds the catalog half of each followed branch (firstview.Views.Halves): mesh_wait diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 54e2aaf..1eb7263 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -10,11 +10,16 @@ import ( "reflect" "sort" "strings" + "sync" "testing" "time" + "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + opseat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/seatdef" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -197,7 +202,7 @@ func TestMeshWaitsTimeoutIsBounded(t *testing.T) { // a wildcard, of one token, more than 16 names, none, or a from of another word is refused at once naming the rule; a // wait whose every subscription the bus refuses answers after its timeout, in words naming no wait grant. func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { - s, _ := waitingRuntime(t) + s, mesh := waitingRuntime(t) var seventeen []any for i := 0; i < 17; i++ { seventeen = append(seventeen, fmt.Sprintf("mesh-knowledge.reviewed%d", i)) @@ -274,8 +279,232 @@ func TestMeshWaitRefusesLoudlyAndNeverLoops(t *testing.T) { !strings.Contains(text, "novox/hq ADR 0670 §1") || strings.Contains(text, "wait grant") || strings.Contains(text, "0367 §2") { t.Errorf("a wait the bus refused whole: %s", text) } + // Design 34 every-event §37: a wait whose subjects overlap a marked event's, or a stream's with a `grant`, runs only on the + // operator's warrant for that one wait; with none it subscribes nothing, asks once and answers at its timeout or the choice. + markedWaits(t, s, mesh) } +// markedWaits are §37's rows, against a gate on the test bus, the router's records written there as the router writes them. +func markedWaits(t *testing.T, s *Surface, mesh *mt.Mesh) { + gw := newGatedWaits(t, s, mesh, "src2:runner") + g := gw.surface + took := func(args map[string]any) (string, bool, time.Duration) { + start := time.Now() + text, isErr := callWait(g, args) + return text, isErr, time.Since(start) + } + // A fixture module's marked event, no warrant: nothing of it subscribed (only its ask's decided), asked once, §12's + // words at its timeout; the same wait again asks nothing new. + before := g.conn.Subscriptions() + go func() { + listening(t, g, before, 1) + mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 1}) + }() + text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}) + if !isErr || !strings.Contains(text, "this wait needs the operator's approval. Asked on telegram") || gw.asks() != 1 || + d < time.Second || strings.Contains(text, "heard") { + t.Errorf("a marked event's wait, no warrant: %s after %s, %d asks", text, d, gw.asks()) + } + if text, isErr, _ = took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "still waiting") || gw.asks() != 1 { + t.Errorf("the same marked wait again: %s, %d asks", text, gw.asks()) + } + // A stream with a `grant` is marked whole; a name with the node after a marked event's name is marked. + for i, name := range []string{"mail.inbox", "mail.message.desk"} { + if text, isErr, _ := took(map[string]any{"subjects": []any{name}, "timeout": 1}); !isErr || !strings.Contains(text, "Not run") || gw.asks() != 2+i { + t.Errorf("%s: %s, %d asks", name, text, gw.asks()) + } + } + // The same names from a seat ask nothing. + if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "from": "seat", "timeout": 1}); isErr || + !strings.Contains(text, `"timed-out": true`) || gw.asks() != 3 { + t.Errorf("from a seat: %s, %d asks", text, gw.asks()) + } + // The most timeout a wait takes, its ask chosen while it waits: answered at the choice; the same call made then + // subscribes and hears the event published after it, answered with its approval; a warrant for other names, another + // filter or another timeout is refused, asked anew. + long := map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 600} + done := make(chan string, 1) + go func() { text, _, d := took(long); done <- fmt.Sprintf("%s after %s", text, d) }() + mt.Until(t, func() error { + if gw.asks() < 4 { + return os.ErrNotExist + } + return nil + }) + gw.approve(t) + select { + case got := <-done: + if !strings.Contains(got, "make this wait again within 30 minutes") { + t.Errorf("a long wait chosen while it waits: %s", got) + } + case <-time.After(10 * time.Second): + t.Fatal("a long marked wait was not answered at the choice") + } + before = g.conn.Subscriptions() + go func() { + listening(t, g, before, 6) + mesh.Emit(t, "mesh.mod.mail.event.message", map[string]any{"n": 2}) + }() + if text, isErr, _ := took(long); isErr || !strings.Contains(text, `"approval": "run on the operator's approval`) || + !strings.Contains(text, `"subject": "mail.message"`) { + t.Errorf("the same wait on its warrant: %s", text) + } + for _, other := range []map[string]any{ + {"subjects": []any{"mail.message", "mail.inbox"}, "filter": map[string]any{"n": 2}, "timeout": 1}, + {"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 3}, "timeout": 1}, + {"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 2}, "timeout": 2}, + } { + asked := gw.asks() + if text, isErr, _ := took(other); !isErr || !strings.Contains(text, "Not run") || gw.asks() != asked+1 { + t.Errorf("another wait than the warrant's %v: %s", other, text) + } + } + // Before the catalog half is read nothing is asked: judged when its first view comes within the timeout, refused at + // the timeout when it does not. + gw.cat.mu.Lock() + gw.cat.ready = errors.New("no first view yet") + gw.cat.mu.Unlock() + asked := gw.asks() + if text, isErr, d := took(map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "the marks are not yet read") || gw.asks() != asked || d < time.Second { + t.Errorf("before the catalog half is read: %s after %s", text, d) + } + gw.cat.came = make(chan chan struct{}) + go func() { close(<-gw.cat.came) }() + if text, isErr, _ := took(map[string]any{"subjects": []any{"mail.message"}, "filter": map[string]any{"n": 9}, "timeout": 1}); !isErr || + !strings.Contains(text, "this wait needs the operator's approval") || gw.asks() != asked+1 { + t.Errorf("the catalog half read within the timeout: %s", text) + } + // While the runner's own fingerprint is unknown a marked wait is refused, saying so. + unknown := newGatedWaits(t, s, mesh, "") + if text, isErr := callWait(unknown.surface, map[string]any{"subjects": []any{"mail.message"}, "timeout": 1}); !isErr || + !strings.Contains(text, "own fingerprint is unknown") || unknown.asks() != 0 { + t.Errorf("the runner's fingerprint unknown: %s", text) + } +} + +// gatedWaits is the surface with the runner's approval gate: the fixture module mail marks its event message and declares +// the stream inbox with a grant; the router takes each ask as it is published, its records on the test bus. +type gatedWaits struct { + surface *Surface + cat *waitCatalog + records nats.KeyValue + mu sync.Mutex + asked []opseat.Ask + say *bus.Conn +} + +func newGatedWaits(t *testing.T, s *Surface, mesh *mt.Mesh, build string) *gatedWaits { + gw := &gatedWaits{cat: &waitCatalog{entries: []catalogseat.CatalogEntry{{Module: "mail", Fp: "src2:mail", Manifest: map[string]any{ + "emits": []any{"message"}, "needs-approval": []any{"message"}, + "streams": []any{map[string]any{"name": "inbox", "events": []any{"inbox.*"}, "grant": map[string]any{"subscribe": []any{"x"}}}}}}}}, + records: mesh.Bucket(t, "operator-channel-asks"), say: connect(t, "messenger", "")} + gate := approval.New(approval.Deps{Node: "desk", Runner: "node-tools", Build: build, Catalog: gw.cat, Wait: gw.cat.wait, + State: mt.NewState(), Holder: func(string) (bool, error) { return true, nil }, + Hear: func(ctx context.Context, subjects []string, within time.Duration, match func(json.RawMessage) bool) (bool, error) { + _, ok, _, err := s.conn.HearFirst(ctx, subjects, within, match) + return ok, err + }, + Publish: func(module, subject string, body any, id string) error { + a, ok := body.(opseat.Ask) + if subject != opseat.AcceptSubjectAsk(module) || !ok { + return nil + } + gw.mu.Lock() + gw.asked = append(gw.asked, a) + gw.mu.Unlock() + gw.write(t, opseat.AsksByAskerIDValue{Asker: module, Key: module + "." + a.ID, State: "open", Headline: a.Headline, + Expires: a.Expires, Opened: operatorchannelclient.FormatTime(time.Now()), OfferedOn: []string{"telegram"}}) + return nil + }, + Record: func(module, id string) (*stdio.StateEntry, error) { + e, err := gw.records.Get(module + "." + id) + if errors.Is(err, nats.ErrKeyNotFound) { + return nil, nil + } else if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: e.Key(), Value: e.Value(), Revision: e.Revision()}, nil + }}) + gw.surface = NewSurface(s.conn, busSource{s.conn}, "desk.node-tools") + gw.surface.Approvals = gateApprovals{gate} + return gw +} + +func (gw *gatedWaits) write(t *testing.T, rec opseat.AsksByAskerIDValue) { + raw, _ := json.Marshal(rec) + if _, err := gw.records.Put(rec.Key, raw); err != nil { + t.Error(err) + } +} + +func (gw *gatedWaits) asks() int { + gw.mu.Lock() + defer gw.mu.Unlock() + return len(gw.asked) +} + +// approve writes the router's record of the newest ask as a person's Approve, and says its decided. +func (gw *gatedWaits) approve(t *testing.T) { + gw.mu.Lock() + a := gw.asked[len(gw.asked)-1] + gw.mu.Unlock() + gw.write(t, opseat.AsksByAskerIDValue{Asker: "node-tools", Key: "node-tools." + a.ID, State: "chosen", Expires: a.Expires, + OfferedOn: []string{"telegram"}, Warrant: &opseat.Warrant{Ask: a.ID, Asker: "node-tools", Outcome: operatorchannelclient.OutcomeChosen, + Option: approval.OptionApprove, Level: operatorchannelclient.Approve, At: operatorchannelclient.FormatTime(time.Now()), + AskDigest: operatorchannelclient.Digest(a), Channel: "telegram", + By: &opseat.Person{Who: operatorchannelclient.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}}) + if err := gw.say.Say(opseat.SubjectDecided("node-tools"), []byte(`{"ask":"`+a.ID+`"}`)); err != nil { + t.Fatal(err) + } +} + +// gateApprovals is the gate as the surface holds it: mesh_approvals' reads unused here, mesh_wait's gate. +type gateApprovals struct{ *approval.Gate } + +func (gateApprovals) Fill() (uint64, int64, error) { return 0, 0, nil } + +// waitCatalog is a catalog half as a fixture: its entries, read or not; with came set, its first view comes once a wait +// waits for it (waiting is told on it, then it closes). +type waitCatalog struct { + entries []catalogseat.CatalogEntry + mu sync.Mutex + ready error + came chan chan struct{} +} + +func (c *waitCatalog) Ready() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.ready +} +func (c *waitCatalog) wait(within time.Duration) error { + if c.Ready() == nil { + return nil + } + if c.came != nil { + view := make(chan struct{}) + c.came <- view + <-view + c.mu.Lock() + c.ready = nil + c.mu.Unlock() + return nil + } + <-time.After(within) // the bound of the runner's own wait for the first view + return c.Ready() +} +func (c *waitCatalog) Revision() string { return "7" } +func (c *waitCatalog) Served(string) (catalogseat.CatalogEntry, bool, string) { + return catalogseat.CatalogEntry{}, false, "not-found" +} +func (c *waitCatalog) Newest(string) (catalogseat.CatalogEntry, bool) { + return catalogseat.CatalogEntry{}, false +} +func (c *waitCatalog) Definitions() map[string]seatdef.Definition { return nil } +func (c *waitCatalog) Entries() []catalogseat.CatalogEntry { return c.entries } + // The mesh MCP server announces seven tools (ADR 0367 v13): ADR 0195's five, mesh_runtimes and mesh_wait, its timeout bounded. func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { var names []string diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go index 45e1b6c..f230589 100644 --- a/node-tools/internal/firstview/approval.go +++ b/node-tools/internal/firstview/approval.go @@ -78,6 +78,20 @@ func (c approvalCatalog) Definitions() map[string]seatdef.Definition { return c.defs() } +func (c approvalCatalog) Entries() []catalogseat.CatalogEntry { + halves, err := c.v.Halves() + if err != nil { + return nil + } + var out []catalogseat.CatalogEntry + for _, half := range halves { + for _, e := range half.Entries { + out = append(out, e) + } + } + return out +} + type notFollowed struct{} func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } diff --git a/node-tools/internal/meshtest/state.go b/node-tools/internal/meshtest/state.go new file mode 100644 index 0000000..87c9e4d --- /dev/null +++ b/node-tools/internal/meshtest/state.go @@ -0,0 +1,87 @@ +package meshtest + +import ( + "encoding/json" + "sort" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// State is a module's state as a fixture, the shape a gate's Store takes: Fail makes every read and write fail, as a bus +// refusing them; Vals are its values by key. +type State struct { + mu sync.Mutex + Vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 + Fail error +} + +// NewState is an empty State. +func NewState() *State { return &State{Vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} } + +func (m *State) Get(k string) (*stdio.StateEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return nil, m.Fail + } + v, ok := m.Vals[k] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil +} +func (m *State) Create(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + if _, ok := m.Vals[k]; ok { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *State) Update(k string, v any, rev uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + if rev == 0 || m.revs[k] != rev { + return 0, stdio.ErrChanged + } + return m.put(k, v) +} +func (m *State) Put(k string, v any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return 0, m.Fail + } + return m.put(k, v) +} +func (m *State) Keys() ([]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Fail != nil { + return nil, m.Fail + } + var out []string + for k := range m.Vals { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} +func (m *State) put(k string, v any) (uint64, error) { + raw, ok := v.(json.RawMessage) + if !ok { + raw, _ = json.Marshal(v) + } + m.n++ + m.Vals[k], m.revs[k] = raw, m.n + return m.n, nil +} From afc327392ec8cab7f7022751babf6b4f1b483aa4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:45:53 +0100 Subject: [PATCH 14/16] A-8 pending: an acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At each start (and when the hand-act seat's holder is heard again) the gate pends each acted, unrecorded ask's call-unanswered key in its held writer, bound five minutes, and ends the pending once its warranted is made again, so a restart neither clears nor raises the condition of a run still unrecorded. Builds: design 46 7-approval-mark §16; design 47 G §384; lead answer A-154 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/approval/approval_test.go | 13 +++++++ node-tools/internal/approval/marks_test.go | 17 +++++++++- node-tools/internal/approval/record.go | 34 ++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/node-tools/internal/approval/approval_test.go b/node-tools/internal/approval/approval_test.go index f1cf18f..ef5907f 100644 --- a/node-tools/internal/approval/approval_test.go +++ b/node-tools/internal/approval/approval_test.go @@ -14,6 +14,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/seatdef" + "git.novox.be/novox/mesh-sdk/go/views" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -419,8 +420,20 @@ func TestTheRefusalsAndAnswers(t *testing.T) { t.Fatalf("findings %v", w.findings.raised) } w.warranted = nil + // At a start each acted, unrecorded ask is pending in the runner's findings-stated until warranted is made again + // (design 47 G §384). + pendingKey := views.FindingKey("machine", keys.EscapeName("n1."+a.ID), KindCallUnanswered) + warranted := w.g.d.Warranted + var pendingAtCall bool + w.g.d.Warranted = func(asker, ask string, open func() (bool, error)) error { + _, pendingAtCall = w.findings.pends[pendingKey] + return warranted(asker, ask, open) + } w.g.Recover() // a start, or the hand-act seat's holder heard again w.g.Recover() + if !pendingAtCall || len(w.findings.pended) != 1 || len(w.findings.pends) != 0 || w.findings.pended[0] != pendingKey { + t.Fatalf("pending at the call %v, pended %v, pending after %v", pendingAtCall, w.findings.pended, w.findings.pends) + } if len(w.recorded) != 1 || w.recorded[0] != "shell."+a.ID || len(w.findings.raised) != 0 || len(w.bounds.keys) != 0 { t.Fatalf("recorded %v, findings %v, bounds %v", w.recorded, w.findings.raised, w.bounds.keys) } diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go index 39573cf..000b379 100644 --- a/node-tools/internal/approval/marks_test.go +++ b/node-tools/internal/approval/marks_test.go @@ -7,6 +7,7 @@ import ( condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-sdk/go/seatdef" @@ -128,7 +129,21 @@ func TestTheMarks(t *testing.T) { } // findings is the runner's held writer as a fixture: each finding raised now, by id. -type findings struct{ raised map[string]string } +type findings struct { + raised map[string]string + // pends are the keys pending in the held writer's statement; pended every key ever pended. + pends map[string]views.PendingKey + pended []string +} + +func (f *findings) Pend(p views.PendingKey) { + if f.pends == nil { + f.pends = map[string]views.PendingKey{} + } + f.pends[p.Key] = p + f.pended = append(f.pended, p.Key) +} +func (f *findings) Judged(key string) { delete(f.pends, key) } func (f *findings) Write(_ context.Context, x condseat.Finding) error { if f.raised == nil { diff --git a/node-tools/internal/approval/record.go b/node-tools/internal/approval/record.go index 20af7ab..2826bed 100644 --- a/node-tools/internal/approval/record.go +++ b/node-tools/internal/approval/record.go @@ -10,6 +10,8 @@ import ( "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/views" ) // The bounds of a run's recording (§13, §16; lead answers A-157, A-158). @@ -84,7 +86,9 @@ func (g *Gate) recordRun(module, id string) { g.d.Logf("[mesh-tools] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) return } - if err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }); err != nil { + err := g.d.Warranted(module, id, func() (bool, error) { return g.unrecorded(id) }) + g.judged(id) // made again: its answer, or the bound of its key, judges it from now + if err != nil { g.d.Logf("[mesh-tools] mesh-hand-acts.warranted has not answered for ask %s (%v): asked again when the hand-act seat's "+ "holder is heard again and at the next start", id, err) return @@ -181,6 +185,7 @@ func (g *Gate) Recover() { continue } module := moduleOf(k) + g.pend(id) g.d.Go(func() { g.recordRun(module, id) }) } } @@ -220,5 +225,32 @@ func (g *Gate) WatchBounds() (func(), error) { }) } +// Pender is the runner's held writer's pending (views.HeldWriter's Pend and Judged): the keys it cannot judge yet since its +// start, named in its `findings-stated` (design 47 G §384). +type Pender interface { + Pend(views.PendingKey) + Judged(key string) +} + +// pend names an acted, unrecorded ask's `call-unanswered` in the runner's `findings-stated` until its `warranted` is made +// again (§16; design 47 G §384), its bound the five minutes the finding waits. +func (g *Gate) pend(id string) { + if p, ok := g.d.Findings.(Pender); ok { + p.Pend(views.PendingKey{Key: g.unansweredFinding(id), Since: g.d.Now().UTC(), Bound: UnansweredAfter.String()}) + } +} + +// judged ends that pending. +func (g *Gate) judged(id string) { + if p, ok := g.d.Findings.(Pender); ok { + p.Judged(g.unansweredFinding(id)) + } +} + +// unansweredFinding is the key of an ask's `call-unanswered` in the runner's state findings (lead answer A-154). +func (g *Gate) unansweredFinding(id string) string { + return views.FindingKey(scopeMachine, keys.EscapeName(g.d.Node+"."+id), KindCallUnanswered) +} + // isChanged says a compare-and-set lost to another write. func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } From e7e20e3d13b7d41c763089fd39160865302f33fb Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:49:57 +0100 Subject: [PATCH 15/16] A-165 callers: the tool runner refuses a seat verb's caller outside its effective callers before the bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where serveSeats answers a seat's verb it reads the verb from the runner's seat list (seatdef's EffectiveCallers): an unproven caller is refused, and every caller while the seat's definition is unread, names no such verb or opens it to none; a proven caller is among them, since the grants part composes the call subject only for the effective callers. A module's tool on the subject naming no caller is unchanged. Builds: design 47 G §610; lead answers A-165, A-179 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/cmd/node-tools/main.go | 2 +- node-tools/internal/runtime/caller_test.go | 39 +++++++++++++++++++--- node-tools/internal/runtime/gate.go | 38 +++++++++++++++++++++ node-tools/internal/runtime/runtime.go | 16 ++++++--- 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 302afde..788dd82 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -85,7 +85,7 @@ func main() { Build: os.Getenv(runtime.FingerprintWord), Hear: hearOne(conn)}) // A failed read or write of the state approvals is made again at the next membership change the runner hears (A-157). conn.OnMembership(func(bus.Membership) { go gate.StateAgain() }) - opts = append(opts, runtime.WithGate(gate)) + opts = append(opts, runtime.WithGate(gate), runtime.WithSeatDefinitions(definitionsOf(seatList))) // A run still unrecorded is asked again at each start and when the hand-act seat's holder is heard again (lead answer A-157). if stopBounds, err := gate.WatchBounds(); err != nil { log.Printf("[mesh-tools] the gate's bound keys are not watched (%v): an unanswered hand-act is said in the log only", err) diff --git a/node-tools/internal/runtime/caller_test.go b/node-tools/internal/runtime/caller_test.go index 7958b39..46c4c95 100644 --- a/node-tools/internal/runtime/caller_test.go +++ b/node-tools/internal/runtime/caller_test.go @@ -2,9 +2,12 @@ package runtime import ( "encoding/json" + "strings" "testing" "time" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/nats-io/nats.go" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -24,7 +27,7 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T) mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) nodeTools := connect(t, "node-tools", "anchor") - stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) + stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined) if err != nil { t.Fatal(err) } @@ -62,13 +65,20 @@ func TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller(t *testing.T) } } -// The subject that names no caller is still served, for one release (retired by hq issue 464), and says so: -// the module is named no caller, whatever the body claims. +// deskDefined is the seat list's definition of the fixture seat node-desk, its verb who taking its class's default. +var deskDefined = WithSeatDefinitions(func() map[string]seatdef.Definition { + return map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who"}}}} +}) + +// The subject that names no caller is still served for a module's tool, for one release (retired by hq issue 464), and says +// so: the module is named no caller, whatever the body claims. A seat's verb refuses it before the bundle runs (design 47 G +// §610; lead answers A-165, A-179), and refuses every caller while the seat's definition is unread, names no such verb or +// opens it to none. func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { mesh := mt.New(t) mesh.Issue(t, mt.MembershipOf("ledger", "anchor", true, map[string][]string{"node-desk": {"who"}})) nodeTools := connect(t, "node-tools", "anchor") - stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}) + stop, err := Run(nodeTools, []Served{{"ledger", []string{mt.Fixture("caller-echo.py")}}}, nil, func(string, ...any) {}, deskDefined) if err != nil { t.Fatal(err) } @@ -91,8 +101,29 @@ func TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone(t *testing.T) { } return err }) + if strings.HasPrefix(subject, "mesh.seat.") { + if !strings.Contains(reply.Error, "node-desk.who takes no unproven caller") || reply.Result.Call != "" { + t.Errorf("%s: an unproven caller of a seat's verb was answered %+v (error %q), want refused before the bundle", subject, reply.Result, reply.Error) + } + continue + } if reply.Error != "" || reply.Result.Caller != "" { t.Errorf("%s: the module was named caller %q (error %q), want none", subject, reply.Result.Caller, reply.Error) } } + for _, c := range []struct { + defs map[string]seatdef.Definition + says string + }{ + {nil, "is not yet read"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk"}}, "names no verb who"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Callers: &seatdef.Callers{List: []string{}}}}}}, "open to no caller"}, + {map[string]seatdef.Definition{"node-desk": {Seat: "node-desk", Verbs: []seatdef.Verb{{Name: "who", Class: seatdef.ClassAct}}}}, ""}, + } { + defs := c.defs + err := (&consumers{definitions: func() map[string]seatdef.Definition { return defs }}).admitted("node-desk", "who", "shanks.node-tools") + if c.says == "" && err != nil || c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)) { + t.Errorf("a proven caller with the definitions %v: %v, want %q", c.defs, err, c.says) + } + } } diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go index 649bec5..7c8020e 100644 --- a/node-tools/internal/runtime/gate.go +++ b/node-tools/internal/runtime/gate.go @@ -1,6 +1,10 @@ package runtime import ( + "fmt" + + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/launch" ) @@ -33,6 +37,40 @@ func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } } var _ Gate = (*approval.Gate)(nil) +// WithSeatDefinitions refuses, where a seat's verb is answered, a caller outside the verb's effective callers, before the +// bundle runs (design 47 G §610; lead answers A-165, A-179): defs are the seat definitions the runner's seat list holds. +// Without it a verb is answered for every caller, as before. +func WithSeatDefinitions(defs func() map[string]seatdef.Definition) Option { + return func(c *consumers) { c.definitions = defs } +} + +// admitted says why a seat's verb is refused to a caller, nil when it is answered: an unproven caller is refused, and so is +// every caller while the seat's definition is not read, names no such verb, or the verb's effective callers are empty; a +// proven caller is among them, since the grants part composes a verb's call subject only for its effective callers (A-179). +func (c *consumers) admitted(seat, verb, caller string) error { + if c.definitions == nil { + return nil + } + if caller == "" { + return fmt.Errorf("Not run: %s.%s takes no unproven caller: call it on its subject that names its caller, "+ + "mesh.seat.%s.call.%s.", seat, verb, seat, verb) + } + def, ok := c.definitions()[seat] + if !ok { + return fmt.Errorf("Not run: the definition of the seat %s is not yet read by this tool runner's seat list, so no caller "+ + "of %s.%s is admitted", seat, seat, verb) + } + for _, v := range def.Verbs { + if v.Name == verb { + if len(v.EffectiveCallers()) == 0 { + return fmt.Errorf("Not run: %s.%s is open to no caller", seat, verb) + } + return nil + } + } + return fmt.Errorf("Not run: the seat %s's definition names no verb %s, so no caller is admitted", seat, verb) +} + // unservedMarks is, of the module's marked tools, those its bundle's registrations under its own name do not register. func (r *runner) unservedMarks(module string, regs []launch.Registration) []string { if r.events.gate == nil { diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index 5bbea33..15bfdc2 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -16,6 +16,8 @@ import ( "sync" "time" + "git.novox.be/novox/mesh-sdk/go/seatdef" + "github.com/novox/mesh-tools/node-tools/internal/announce" "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" @@ -304,7 +306,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log } logf("%s", line) conn.OnMembership(func(bus.Membership) { go func() { r.refreshRepairs(); conn.Flush() }() }) - stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.fingerprintOf, logf) + stopSeats, again := serveSeats(conn, r.modules, r.registrations, r.events.gated, r.events.admitted, r.fingerprintOf, logf) r.seatsAgain = again r.stops = append(r.stops, stopSeats) @@ -707,8 +709,8 @@ func subjectsOf(conn *bus.Conn, module, tool string) []string { // the tool of the same name registered under the seat's name (ADR 0159, 0160) — and serves again // whenever a membership changes, and when again is called because a bundle was loaded again. Whether // this machine holds the seat is the bus's to decide. It answers its stop and again. -func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, fingerprintOf func(string) string, - logf func(string, ...any)) (func(), func()) { +func serveSeats(conn *bus.Conn, modules []string, current func() []registration, gated gatedRun, + admitted func(seat, verb, caller string) error, fingerprintOf func(string) string, logf func(string, ...any)) (func(), func()) { var mu sync.Mutex var stops []func() stopped := false @@ -747,9 +749,13 @@ func serveSeats(conn *bus.Conn, modules []string, current func() []registration, logf("[mesh-tools] %s claims %s and implements no %s, which that seat promises; not served", module, v.Seat, v.Verb) continue } - // Through the approval gate, before the bundle (design 46 7-approval-mark §20). + // Its callers checked, then through the approval gate, before the bundle (design 47 G §610, A-179; design 46 + // 7-approval-mark §20). module, v := module, v stop, err := conn.HandleSubjectCalled(v.Subject, func(caller string, body json.RawMessage) (any, error) { + if err := admitted(v.Seat, v.Verb, caller); err != nil { + return nil, err + } return gated(approval.Call{Address: v.Seat + "." + v.Verb, Module: module, Seat: v.Seat, Name: v.Verb, Fingerprint: fingerprintOf(module), Caller: caller, Args: argsOf(body), Description: t.Description}, t, caller) }) @@ -794,6 +800,8 @@ type consumers struct { sessions sessionTable // gate is the approval gate every call is served through (gate.go); nil serves every call as before. gate Gate + // definitions are the seat definitions a seat verb's callers are read from (gate.go); nil admits every caller. + definitions func() map[string]seatdef.Definition } type moduleEvents struct { From b5c7bf13eaa625934eec3c04cf0ca7c5e86e4247 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:54:49 +0100 Subject: [PATCH 16/16] A-9 tests: a bundle not serving its marked tool is refused at the load; a withheld marked call is said so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime's load test gains rows (run with the Python fixture, before the bundles' skip): a bundle whose marks name a tool it does not register serves no tool of its module, and one that registers it serves. mesh_call's failure words move into Surface.callFailed, whose rows hold that only a marked tool reaching no responder names withheld and mesh-grants.grants_user_list. Builds: design 46 7-approval-mark §4, §6, §22 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- node-tools/internal/console/address.go | 26 ++++++----- .../internal/console/noresponders_test.go | 19 +++++++- node-tools/internal/console/wait_test.go | 16 ++++--- node-tools/internal/runtime/runtime_test.go | 45 ++++++++++++++++++- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/node-tools/internal/console/address.go b/node-tools/internal/console/address.go index 84de6fd..8739f24 100644 --- a/node-tools/internal/console/address.go +++ b/node-tools/internal/console/address.go @@ -927,6 +927,21 @@ func schemaWithoutNode(raw json.RawMessage) map[string]any { } // answerText is a discovery verb's answer as MCP content: JSON, indented. +// callFailed is why a call failed, as mesh_call says it: a marked tool or verb that reaches no responder names `withheld` +// and the grants part's read (design 46 7-approval-mark §6), and a target known only from an earlier round says so. +func (s *Surface) callFailed(t target, err error) string { + why := whyItFailed(t.Address, err) + if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) { + // A marked subject withheld from a machine whose tool runner does not honour the mark (§6). + why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " + + "approval-mark/1: mesh-grants.grants_user_list names it under withheld" + } + if t.Stale != nil { + why += " (" + t.Stale.say() + ")" + } + return why +} + func answerText(v any) map[string]any { var b strings.Builder enc := json.NewEncoder(&b) @@ -1137,16 +1152,7 @@ func (s *Surface) discover(ctx context.Context, name string, args map[string]any got, err = callTool(s.conn, t.Key, callArgs, nil, x.Listing) } if err != nil { - why := whyItFailed(t.Address, err) - if ok, _ := s.markedTool(t); ok && errors.Is(err, bus.ErrNoResponders) { - // A marked subject withheld from a machine whose tool runner does not honour the mark (§6). - why += "; it needs the operator's approval, so it may be withheld where the machine's tool runner does not state " + - "approval-mark/1: mesh-grants.grants_user_list names it under withheld" - } - if t.Stale != nil { - why += " (" + t.Stale.say() + ")" - } - return failure(why) + return failure(s.callFailed(t, err)) } result, unread := resolveSent(s.conn, got.Result) content := []map[string]any{{"type": "text", "text": pretty(result)}} diff --git a/node-tools/internal/console/noresponders_test.go b/node-tools/internal/console/noresponders_test.go index 51f0c0a..1cbe5c5 100644 --- a/node-tools/internal/console/noresponders_test.go +++ b/node-tools/internal/console/noresponders_test.go @@ -11,7 +11,8 @@ import ( "github.com/novox/mesh-tools/node-tools/internal/bus" ) -// A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says +// A marked tool or verb that reaches no responder names withheld and the grants part's read (design 46 7-approval-mark §6); +// an unmarked one, or a marked one failing otherwise, does not. A tool's own error that mentions 503 is the tool's answer, not a routing fault (issue 1529); a real no-responders still says // nothing serves. func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { got := whyItFailed("scanner.scan", errors.New("the scanner refused the scan job: 503 Service Unavailable")) @@ -23,4 +24,20 @@ func TestARealNoRespondersStillSaysNothingServes(t *testing.T) { t.Fatalf("%v: got %q", err, got) } } + s := &Surface{Approvals: &fakeApprovals{marked: "shell.wipe"}} + for _, c := range []struct { + target target + err error + hinted bool + }{ + {target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, bus.ErrNoResponders, true}, + {target{Address: "node-login-shell.execute", Seat: true, Tool: Tool{Module: "node-login-shell", Name: "execute"}}, bus.ErrNoResponders, false}, + {target{Address: "shell.tidy", Tool: Tool{Module: "shell", Name: "tidy"}}, bus.ErrNoResponders, false}, + {target{Address: "shell.wipe", Tool: Tool{Module: "shell", Name: "wipe"}}, errors.New("its own failure"), false}, + } { + got := s.callFailed(c.target, c.err) + if hinted := strings.Contains(got, "approval-mark/1: mesh-grants.grants_user_list names it under withheld"); hinted != c.hinted { + t.Errorf("%s (%v): %q", c.target.Address, c.err, got) + } + } } diff --git a/node-tools/internal/console/wait_test.go b/node-tools/internal/console/wait_test.go index 1eb7263..e66fe91 100644 --- a/node-tools/internal/console/wait_test.go +++ b/node-tools/internal/console/wait_test.go @@ -554,17 +554,23 @@ func TestMeshWaitIsAnnouncedAmongSeven(t *testing.T) { func fmtAnswer(out map[string]any) string { raw, _ := json.Marshal(out); return string(raw) } // fakeApprovals is a gate as mesh_approvals reads it. -type fakeApprovals struct{ cancelled string } +type fakeApprovals struct { + cancelled string + // marked is the one `.` its Describe says is marked. + marked string +} func (f *fakeApprovals) List() ([]approval.Listed, error) { return []approval.Listed{{Ask: "a1", State: "asked"}}, nil } func (f *fakeApprovals) Read(id string) (approval.Whole, error) { return approval.Whole{}, nil } func (f *fakeApprovals) Applied() []approval.Marks { return nil } -func (f *fakeApprovals) Describe(_, _, _ string) (bool, string) { return false, "" } -func (f *fakeApprovals) Again(string) error { return nil } -func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil } -func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil } +func (f *fakeApprovals) Describe(module, _, name string) (bool, string) { + return f.marked != "" && f.marked == module+"."+name, "entry " + module +} +func (f *fakeApprovals) Again(string) error { return nil } +func (f *fakeApprovals) Cancel(id string) error { f.cancelled = id; return nil } +func (f *fakeApprovals) Fill() (uint64, int64, error) { return 10, 64 << 20, nil } // A client that hangs up or cancels ends its wait at once: the subscription is closed and nothing is left // waiting for the timeout (the review of issue 745). Over the console's own HTTP, as an agent host calls it. diff --git a/node-tools/internal/runtime/runtime_test.go b/node-tools/internal/runtime/runtime_test.go index eb56bfa..775b920 100644 --- a/node-tools/internal/runtime/runtime_test.go +++ b/node-tools/internal/runtime/runtime_test.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "os" + "slices" "strings" "testing" "time" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/launch" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" @@ -192,10 +194,49 @@ func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) { } } -// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). +// unservingGate is an approval gate whose marks of each module name the tools given, so a bundle not registering them is +// refused at the load (design 46 7-approval-mark §4). +type unservingGate map[string][]string + +func (g unservingGate) Unserved(module, _ string, registered []string) []string { + var missing []string + for _, tool := range g[module] { + if !slices.Contains(registered, tool) { + missing = append(missing, tool) + } + } + return missing +} +func (unservingGate) Run(_ approval.Call, run func() (any, error)) (any, error) { return run() } + +// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193). A bundle whose marks name a +// tool it does not register is refused at the load, serving no tool of its module, and one that registers it serves +// (design 46 7-approval-mark §4). func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) { - mt.NeedBundles(t) mesh := mt.New(t) + { + mesh.Issue(t, mt.MembershipOf("marked", "anchor", true, nil)) + mesh.Issue(t, mt.MembershipOf("served", "anchor", true, nil)) + nodeTools := connect(t, "node-tools", "anchor") + asker := connect(t, "console", "workstation") + logs := &mt.Logs{} + stop, err := Run(nodeTools, []Served{{"marked", []string{mt.Fixture("caller-echo.py")}}, {"served", []string{mt.Fixture("caller-echo.py")}}}, + nil, logs.Logf, WithGate(unservingGate{"marked": {"wipe"}, "served": {"who"}})) + if err != nil { + t.Fatal(err) + } + if !logs.Has("marked's bundle", "failed to load: its marks name wipe, which it does not register (design 46 7-approval-mark §4)") { + t.Errorf("a bundle not serving its marked tool was not refused at the load:\n%s", logs.All()) + } + if _, err := call(t, asker, "served.who@anchor", map[string]any{}); err != nil { + t.Errorf("a bundle serving its marked tool: %v", err) + } + if _, err := asker.Ask("marked.who@anchor", map[string]any{}, ""); !errors.Is(err, bus.ErrNoResponders) { + t.Errorf("a refused bundle's tool answered: %v", err) + } + stop() + } + mt.NeedBundles(t) mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil)) nodeTools := connect(t, "node-tools", "anchor")