From 827628164ad67b90b0d244a31bc5fffec345a958 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:05:13 +0100 Subject: [PATCH] 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 {