From d87facbb95ba4486a46c00c45a36039072ed1790 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:55:13 +0100 Subject: [PATCH 01/12] EE-S1: name the mesh's event subjects in one place (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buscompose gains EveryEvent, EventSubjects, EventName and Overlaps, so the grants part and the tool runner never spell the event namespace themselves. Builds: design 34 every-event §1, §25, §26, §34, §37 (Overlaps rows) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/buscompose/events.go | 106 +++++++++++++++++++++++++++++++++++ go/buscompose/events_test.go | 98 ++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 go/buscompose/events.go create mode 100644 go/buscompose/events_test.go diff --git a/go/buscompose/events.go b/go/buscompose/events.go new file mode 100644 index 00000000..49302145 --- /dev/null +++ b/go/buscompose/events.go @@ -0,0 +1,106 @@ +package buscompose + +import ( + "fmt" + "regexp" + "strings" +) + +// The one place the mesh's event subjects are named (design 34 every-event §1, §25, §26), so neither the grants part +// nor the tool runner spells the event namespace itself. + +// The namespaces an event lands in, as `from` names them (design 34 every-event §13, §26). +const ( + fromSeat = "seat" + fromModule = "module" + fromStream = "stream" +) + +// eventNamespaces are the three, each with the prefix its subjects start with, in EveryEvent's order. +var eventNamespaces = []struct{ from, prefix string }{ + {fromSeat, "mesh.seat."}, + {fromModule, "mesh.mod."}, + {fromStream, moduleStreamPrefix}, +} + +// EveryEvent is the subjects the tool runner's account hears: every seat's events, every module's, and every module +// stream's, a restricted one included, in that order (novox/hq ADR 0670 §1, ADR 0671 §1). +func EveryEvent() []string { + out := make([]string, 0, len(eventNamespaces)) + for _, ns := range eventNamespaces { + out = append(out, ns.prefix+"*.event.>") + } + return out +} + +// eventToken is the form of each token of an event name. +var eventToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// EventSubjects is the subjects one event name lands on. A name is `.`, optionally followed by further +// tokens (`operator-channel.decided.claude-code`), each token of the form `[A-Za-z0-9_-]+`. With from empty it answers +// six subjects, per namespace (seat, module, stream) `mesh...event.` and the same followed by `.>`; +// with from `seat`, `module` or `stream` only that namespace's two. It refuses, naming the rule, a name of fewer than two +// tokens, an empty token or a token outside that form (so `*` and `>` are refused), and a from of any other word. +func EventSubjects(name, from string) ([]string, error) { + tokens := strings.Split(name, ".") + if len(tokens) < 2 { + return nil, fmt.Errorf("event name %q has fewer than two tokens: a name is .", name) + } + for _, t := range tokens { + if !eventToken.MatchString(t) { + return nil, fmt.Errorf("event name %q has the token %q: each token of a name matches [A-Za-z0-9_-]+, "+ + "so no token is empty and none is * or >", name, t) + } + } + emitter, rest := tokens[0], strings.Join(tokens[1:], ".") + var out []string + for _, ns := range eventNamespaces { + if from != "" && from != ns.from { + continue + } + subject := ns.prefix + emitter + ".event." + rest + out = append(out, subject, subject+".>") + } + if out == nil { + return nil, fmt.Errorf("from %q is none of %s, %s or %s", from, fromSeat, fromModule, fromStream) + } + return out, nil +} + +// EventName is the inverse of EventSubjects for a heard subject: for `mesh.seat..event.`, +// `mesh.mod..event.` or `mesh.stream..event.` it answers the name `.` and +// its namespace (`seat`, `module` or `stream`); any other subject is answered as not an event (ok false). +func EventName(subject string) (name, from string, ok bool) { + for _, ns := range eventNamespaces { + after, found := strings.CutPrefix(subject, ns.prefix) + if !found { + continue + } + emitter, rest, found := strings.Cut(after, ".event.") + if !found || emitter == "" || rest == "" || strings.Contains(emitter, ".") { + return "", "", false + } + return emitter + "." + rest, ns.from, true + } + return "", "", false +} + +// Overlaps says whether some subject matches both a and b, a wildcard on either side: `*` stands for one token and +// `>`, last, for one token or more (design 34 every-event §34). +func Overlaps(a, b string) bool { + at, bt := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; ; i++ { + switch { + case i == len(at) && i == len(bt): + return true + case i == len(at) || i == len(bt): + return false + case at[i] == ">" || bt[i] == ">": + return true + case at[i] == "*" || bt[i] == "*" || at[i] == bt[i]: + continue + default: + return false + } + } +} diff --git a/go/buscompose/events_test.go b/go/buscompose/events_test.go new file mode 100644 index 00000000..7c6c962e --- /dev/null +++ b/go/buscompose/events_test.go @@ -0,0 +1,98 @@ +package buscompose + +import ( + "reflect" + "strings" + "testing" +) + +// The mesh's event subjects are named in one place: EveryEvent, EventSubjects, EventName and Overlaps (design 34 +// every-event §1, §25, §26, §34, §37). +func TestTheEventSubjectsAreNamedInOnePlace(t *testing.T) { + // EE-S1/AC1 (E §1, §25): the three subjects, in order. + if got, want := EveryEvent(), []string{"mesh.seat.*.event.>", "mesh.mod.*.event.>", "mesh.stream.*.event.>"}; !reflect.DeepEqual(got, want) { + t.Fatalf("EveryEvent is %v, want %v", got, want) + } + + // EE-S1/AC2, AC3 (E §1, §26): a name's subjects, all six or one namespace's two, and the refusals naming the rule. + subjects := []struct { + name, from string + want []string + refused string + }{ + {name: "operator-channel.decided.claude-code", want: []string{ + "mesh.seat.operator-channel.event.decided.claude-code", "mesh.seat.operator-channel.event.decided.claude-code.>", + "mesh.mod.operator-channel.event.decided.claude-code", "mesh.mod.operator-channel.event.decided.claude-code.>", + "mesh.stream.operator-channel.event.decided.claude-code", "mesh.stream.operator-channel.event.decided.claude-code.>", + }}, + {name: "mesh-delivery-view.delivered", from: "seat", want: []string{ + "mesh.seat.mesh-delivery-view.event.delivered", "mesh.seat.mesh-delivery-view.event.delivered.>"}}, + {name: "mesh-delivery-view.delivered", from: "module", want: []string{ + "mesh.mod.mesh-delivery-view.event.delivered", "mesh.mod.mesh-delivery-view.event.delivered.>"}}, + {name: "mesh-mail.mail", from: "stream", want: []string{ + "mesh.stream.mesh-mail.event.mail", "mesh.stream.mesh-mail.event.mail.>"}}, + {name: "mesh-delivery-view", refused: "fewer than two tokens"}, + {name: "mesh-delivery-view..delivered", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.*", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.>", refused: "[A-Za-z0-9_-]+"}, + {name: "mesh-delivery-view.delivered", from: "bench", refused: "none of seat, module or stream"}, + } + for _, c := range subjects { + got, err := EventSubjects(c.name, c.from) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("EventSubjects(%q, %q) answered %v, %v; want refused naming %q", c.name, c.from, got, err, c.refused) + } + continue + } + if err != nil || !reflect.DeepEqual(got, c.want) { + t.Errorf("EventSubjects(%q, %q) answered %v, %v; want %v", c.name, c.from, got, err, c.want) + } + // EE-S1/AC5 (E §1): every subject it answers is covered by one of EveryEvent's. + for _, s := range got { + covered := false + for _, e := range EveryEvent() { + covered = covered || Overlaps(e, s) + } + if !covered { + t.Errorf("%s is covered by none of EveryEvent", s) + } + } + } + + // EE-S1/AC4 (E §1, §26): a heard subject's name and namespace, and any other subject not an event. + names := []struct{ subject, name, from string }{ + {"mesh.seat.operator-channel.event.decided.claude-code", "operator-channel.decided.claude-code", "seat"}, + {"mesh.mod.mesh-delivery-view.event.delivered", "mesh-delivery-view.delivered", "module"}, + {"mesh.stream.mesh-delivery-checker.event.verdict", "mesh-delivery-checker.verdict", "stream"}, + {"mesh.seat.intake.call.accept.ace", "", ""}, + {"mesh.mod.netcheck.call.netcheck_dns", "", ""}, + {"$KV.views.x", "", ""}, + {"mesh.seat..event.x", "", ""}, + } + for _, c := range names { + name, from, ok := EventName(c.subject) + if name != c.name || from != c.from || ok != (c.name != "") { + t.Errorf("EventName(%q) answered %q, %q, %v; want %q, %q", c.subject, name, from, ok, c.name, c.from) + } + } + + // EE-S1/AC6 (E §34, §37): overlapping subjects, a wildcard on either side. + overlaps := []struct { + a, b string + want bool + }{ + {"mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.*.message", true}, + {"mesh.mod.mail.event.x", "mesh.stream.mail.event.x", false}, + {"mesh.mod.mail.event.x.>", "mesh.mod.mail.event.x", false}, + {"mesh.mod.mail.event.*", "mesh.mod.*.event.x", true}, + {"mesh.mod.mail.event.x", "mesh.mod.mail.event.x", true}, + {"mesh.mod.*.event.>", "mesh.mod.mail.event.x.ace", true}, + {"mesh.mod.mail.event.x.ace", "mesh.mod.mail.event.x", false}, + } + for _, c := range overlaps { + if Overlaps(c.a, c.b) != c.want || Overlaps(c.b, c.a) != c.want { + t.Errorf("Overlaps(%q, %q) is not %v both ways", c.a, c.b, c.want) + } + } +} From 762de8bf51de31502615fbdde58061855e217d7e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:56:31 +0100 Subject: [PATCH 02/12] EE-S2: the waits list goes from the manifest and the membership (issue 1966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool runner hears every event, so no manifest names the families a session may wait on and no membership issues them: Manifest.Waits, Users.Waits, the type Wait, Membership.Waits and WaitIssued go. A stored manifest still carrying waits is read without it; the baseline keeps word:waits as retired. Builds: design 34 every-event §2, §3, §4 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/buscompose/users.go | 3 --- go/buscompose/waits.go | 18 ------------------ go/catalogue/invokes_test.go | 5 +++++ go/catalogue/manifest.go | 9 +-------- go/catalogue/own_manifest_test.go | 5 +++-- go/catalogue/waits_test.go | 17 ----------------- go/membership/membership.go | 8 -------- 7 files changed, 9 insertions(+), 56 deletions(-) delete mode 100644 go/buscompose/waits.go delete mode 100644 go/catalogue/waits_test.go diff --git a/go/buscompose/users.go b/go/buscompose/users.go index 473b041d..46356436 100644 --- a/go/buscompose/users.go +++ b/go/buscompose/users.go @@ -22,9 +22,6 @@ type Declared struct { Watches []Seat // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). Invokes []string - // Waits are the event families a session may wait on through the tool runner, the tool runner's module's - // alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer. - Waits []Wait // State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201). State []Bucket // Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297). diff --git a/go/buscompose/waits.go b/go/buscompose/waits.go deleted file mode 100644 index b10e6c08..00000000 --- a/go/buscompose/waits.go +++ /dev/null @@ -1,18 +0,0 @@ -package buscompose - -// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the -// emitter — a seat or a module — and the event, as the inventory resolved them. -type Wait struct { - // Name is the family as the manifest names it, `.`: what a session asks for. - Name string - Emitter string - // Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121). - Seat bool - // Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a - // seat that names it last (ADR 0259 §3). - Event string - // Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming - // its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited - // on with it given, and refused without. - Last string -} diff --git a/go/catalogue/invokes_test.go b/go/catalogue/invokes_test.go index e7725b7d..dd0a1aa1 100644 --- a/go/catalogue/invokes_test.go +++ b/go/catalogue/invokes_test.go @@ -39,6 +39,11 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) { if back, _ := json.Marshal(runner); !strings.Contains(string(back), `"tool-runner":true`) { t.Errorf("a tool runner written back as %s", back) } + // EE-S2/AC1: the tool runner names no events to wait on any more (novox/hq ADR 0670 §2): a stored manifest still + // carrying `waits` is read without it, the word named unknown (design 34 every-event §2). + if old, err := decodeForTest([]byte(`{"module":"node-tools","version":"1","tool-runner":true,"waits":["a.b"]}`)); err != nil || !strings.Contains(old.UnknownField(), `"waits"`) { + t.Errorf("a manifest carrying waits: %v, unknown %q", err, old.UnknownField()) + } other, _ := decodeForTest([]byte(`{"module":"node-tools","version":"1"}`)) if got, ok := ToolRunnerOf([]Manifest{other, runner}); !ok || got.Module != "any-name" { t.Errorf("the tool runner found is %q, %v: by what it says, never by its name", got.Module, ok) diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 38c08f1f..5a476c97 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -587,13 +587,6 @@ type Manifest struct { // person's account (design 25 §7) already had the same shape. Invokes []string `json:"invokes,omitempty"` - // Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each - // `.` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool - // runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the - // controller grants the tool runner's account a plain subscription on those subjects alone, with no - // durable consumer, and issues the same list in its membership; no call or setting adds one (§6). - Waits []string `json:"waits,omitempty"` - // State is the current state this module keeps on the bus, by local name: each a key-value // bucket the controller creates, which every instance of the module writes and reads // (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not. @@ -812,7 +805,7 @@ type Manifest struct { OwnSecrets OwnSecrets `json:"own-secrets,omitempty"` // ToolRunner says this module is the node's tool runner (issue 774): the one program on each node that loads every - // assigned module's tools bundle, served by its runtime process and the only module that may declare `waits`. Said by + // assigned module's tools bundle, served by its runtime process. Said by // the module, never by a name in mesh-sdk. ToolRunner bool `json:"tool-runner,omitempty"` diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index 6b3a47ea..5d52469c 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -49,8 +49,9 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { } } // The two seats the milestone renamed (ADR 0634 §9) are read by their new names; a node-engine built with the baseline - // reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines. - renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement"} + // reads the old ones, and a manifest of the new names is the milestone's to deliver after the engines. The word + // `waits` is no longer read: novox/hq ADR 0670 §2 retired it. + renamed := []string{"seat:mesh-enrolment", "seat:mesh-placement", "word:waits"} for _, b := range baseline { if !slices.Contains(reads, b) && !slices.Contains(renamed, b) { t.Errorf("the baseline's %s is no reader capability of this contract", b) diff --git a/go/catalogue/waits_test.go b/go/catalogue/waits_test.go deleted file mode 100644 index 8cf7f8d7..00000000 --- a/go/catalogue/waits_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package catalogue - -import ( - "testing" -) - -// novox/hq ADR 0367 §2: the tool runner's module names, one by one, the event families a session may wait on. -func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { - m, err := decodeForTest([]byte(`{"module":"node-tools","version":"1",` + - `"waits":["mesh-knowledge.reviewed","operator-channel.decided.claude-code"]}`)) - if err != nil { - t.Fatal(err) - } - if len(m.Waits) != 2 || m.Waits[1] != "operator-channel.decided.claude-code" { - t.Fatalf("waits not read: %v", m.Waits) - } -} diff --git a/go/membership/membership.go b/go/membership/membership.go index 8edb700d..ceb508cd 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -61,8 +61,6 @@ type Membership struct { // Streams are this module's own streams, Hears the module streams it hears (novox/hq ADR 0363). Streams []StreamIssued `json:"streams,omitempty"` 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). - Waits []WaitIssued `json:"waits,omitempty"` // Bindings are the module's bus state as the bus module created it, one per entry its manifest asks for in // `requires` (novox/hq ADR 0595 §1, §7): written by the grants part when it hears the bus module's `created`. Bindings []BusBinding `json:"bindings,omitempty"` @@ -174,12 +172,6 @@ type HeardIssued struct { Consumer string `json:"consumer"` } -// WaitIssued is one family as a membership issues it: the name a session asks for, and its subject. -type WaitIssued struct { - Event string `json:"event"` - Subject string `json:"subject"` -} - // StateIssued is the state this membership issues its module's code by a local name, and whether it does: what every // runtime that serves a module's state calls reads, so no reader keeps a second copy of the rule (novox/hq issue 1966). // An entry of the old `state` list wins (ADR 0201); otherwise a binding of a key-value entry the module asks for in From bc5d6c03ead25bd374352a5cad1481fd5eef7a35 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 04:59:15 +0100 Subject: [PATCH 03/12] AM-S3, WirePattern: a per-machine state reaches its machine's keys below it; buscompose exports WirePattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-machine form gains the keys below the machine's own, .>, for every per-machine state and a membership's too (PerMachineKeys), every module's key keeping its form; buscompose exports WirePattern, which a reader uses to turn a stream's events into the bus's subjects (the tool runner's marks). Builds: design 46 7-approval-mark §13, §5 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/buscompose/modstreams.go | 14 ++++++++++++++ go/buscompose/names_test.go | 10 ++++++++++ go/buscompose/nats.go | 4 ++-- go/buscompose/state.go | 9 +++++++-- go/membership/membership.go | 3 ++- 5 files changed, 35 insertions(+), 5 deletions(-) diff --git a/go/buscompose/modstreams.go b/go/buscompose/modstreams.go index 8505473e..55fab00a 100644 --- a/go/buscompose/modstreams.go +++ b/go/buscompose/modstreams.go @@ -3,6 +3,7 @@ package buscompose import ( "fmt" "sort" + "strings" ) // moduleStreamPrefix starts every subject a module's own stream takes. @@ -14,6 +15,19 @@ func ModuleStreamSubject(module, event string) string { return moduleStreamPrefix + module + ".event." + event } +// WirePattern is a stream's local event pattern as the bus spells it: `**` is `>`, `*` stays `*`. A reader turns a +// stream's events into the bus's subjects with it (design 46 7-approval-mark §5): the grants part composing a stream, and +// the tool runner deriving a restricted stream's marked subjects. +func WirePattern(local string) string { + parts := strings.Split(local, ".") + for i, p := range parts { + if p == "**" { + parts[i] = ">" + } + } + return strings.Join(parts, ".") +} + // A ModuleStream is one module's declared stream as the bus holds it. type ModuleStream struct { Module string diff --git a/go/buscompose/names_test.go b/go/buscompose/names_test.go index c934da93..ba22fde5 100644 --- a/go/buscompose/names_test.go +++ b/go/buscompose/names_test.go @@ -33,6 +33,16 @@ func TestTheBusNamesAgree(t *testing.T) { if (Bucket{}).MaxBytes() != -1 || (Bucket{MaxMiB: 2}).MaxBytes() != 2<<20 { t.Fatal("a bucket's cap is not its max-mib in bytes") } + // Design 46 7-approval-mark §5: a stream's local event pattern as the bus spells it. + for local, wire := range map[string]string{"mail.**": "mail.>", "mail.*.message": "mail.*.message", "sent": "sent"} { + if got := WirePattern(local); got != wire { + t.Errorf("WirePattern(%q) = %q, want %q", local, got, wire) + } + } + // Design 46 7-approval-mark §13: a per-machine state reaches its machine's key and the keys below it, no other machine's. + if got := PerMachineKeys("ace"); len(got) != 2 || got[0] != "ace" || got[1] != "ace.>" { + t.Errorf("a per-machine state reaches %v on ace", got) + } // A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age. for _, st := range MeshStreams() { if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 { diff --git a/go/buscompose/nats.go b/go/buscompose/nats.go index f7f8ba8f..68757b4f 100644 --- a/go/buscompose/nats.go +++ b/go/buscompose/nats.go @@ -163,8 +163,8 @@ type Principal struct { // instances and read by whoever declares it. State []string Reads []string - // PerMachine is the local names of its state keyed by machine, reaching the key named for Node and - // no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260). + // PerMachine is the local names of its state keyed by machine, reaching the keys of Node (PerMachineKeys) and + // no other machine's; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260). PerMachine []string KeyedReads []KeyedRead // Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297). diff --git a/go/buscompose/state.go b/go/buscompose/state.go index 8e2d7340..c6ae74f2 100644 --- a/go/buscompose/state.go +++ b/go/buscompose/state.go @@ -18,8 +18,8 @@ type Bucket struct { History int // TTLSeconds is how long a value lives; zero is until replaced or deleted. TTLSeconds int - // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR - // 0260), and is granted that key and no other. + // PerMachine says each machine's instance writes only the keys of its machine (novox/hq ADR 0260; design 46 + // 7-approval-mark §13): the key named for its machine and the keys below it, PerMachineKeys, and no other machine's. PerMachine bool // KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its // own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends. @@ -36,6 +36,11 @@ func (b Bucket) MaxBytes() int64 { return int64(b.MaxMiB) * 1024 * 1024 } +// PerMachineKeys are the keys a machine's instance of a per-machine state reaches (design 46 7-approval-mark §13): the key +// named for its machine, as every module's per-machine key keeps its form, and the keys below it, `.>`; no other +// machine's. A membership's per-machine state (membership.StateIssued's Key) reaches the same. +func PerMachineKeys(node string) []string { return []string{node, node + ".>"} } + // BucketName is the bucket a module's state lives in: the module and the local name joined by an // underscore, which neither may contain, so two modules can never derive one bucket. func BucketName(module, name string) string { return module + "_" + name } diff --git a/go/membership/membership.go b/go/membership/membership.go index 8edb700d..0e493de2 100644 --- a/go/membership/membership.go +++ b/go/membership/membership.go @@ -112,7 +112,8 @@ type SeatServed struct { } // StateIssued is one bucket an assignment may reach, by the name its module uses for it (novox/hq ADR 0201). -// Key, when set, is the one key it may reach (novox/hq ADR 0260): this machine's. +// Key, when set, names the machine whose keys it may reach (novox/hq ADR 0260; design 46 7-approval-mark §13): that key and the +// keys below it (buscompose.PerMachineKeys), no other machine's. // // PerNode says the state is kept one bucket per node (novox/hq ADR 0593 §6: `composed-`): Bucket is then the // buckets' prefix, each bucket the prefix and a node's name, and a key's first part names its node and so its bucket. From aabece5ce3d817e653eab727d008ed70a04119d0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:45:15 +0100 Subject: [PATCH 04/12] Held writer: a part names the keys it cannot judge yet in its findings-stated's pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit views.HeldWriter gains Pend and Judged: a key pended is named, with its since and bound, in every statement until the part judges it, a key pended again keeping its first since. The tool runner pends an acted, unrecorded ask's call-unanswered at its start until its warranted is made again. Builds: design 46 7-approval-mark §16; design 47 G §384 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/views/held_writer.go | 29 ++++++++++++++++++++++++++++- go/views/writer_test.go | 9 +++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/go/views/held_writer.go b/go/views/held_writer.go index c785f03e..58cbc5d9 100644 --- a/go/views/held_writer.go +++ b/go/views/held_writer.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "sort" "sync" ) @@ -20,6 +21,9 @@ type HeldWriter[F, C, S, A any] struct { started bool pending map[string]F // written before Start, written through the writer at Start cleared map[string][4]string // cleared before Start, cleared through the writer at Start + // pends are the keys the part cannot judge yet since its start (design 47 G §384): a kept call it has not yet made + // again, named in every statement until the part judges it. + pends map[string]PendingKey } // NewHeldWriter makes the writer for cfg, whose Observe is none (a given Observe is refused): the findings that stand in @@ -32,7 +36,7 @@ func NewHeldWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*HeldWriter[F, if cfg.State == nil { return nil, errors.New("a finding writer needs the state `findings`") } - h := &HeldWriter[F, C, S, A]{held: map[string]F{}} + h := &HeldWriter[F, C, S, A]{held: map[string]F{}, pends: map[string]PendingKey{}} h.state, h.pending, h.cleared = cfg.State, map[string]F{}, map[string][4]string{} cfg.Observe = func(context.Context) (Observed[F], error) { h.mu.Lock() @@ -41,6 +45,10 @@ func NewHeldWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*HeldWriter[F, for _, f := range h.held { o.Findings = append(o.Findings, f) } + for _, p := range h.pends { + o.Pending = append(o.Pending, p) + } + sort.Slice(o.Pending, func(i, j int) bool { return o.Pending[i].Key < o.Pending[j].Key }) return o, nil } w, err := NewWriter(cfg) @@ -100,6 +108,25 @@ func (h *HeldWriter[F, C, S, A]) WriteWire(ctx context.Context, finding map[stri return h.Write(ctx, f) } +// Pend names a key the part cannot judge yet since its start, with its since and its bound, in every statement until Judged +// (design 47 G §384; design 46 7-approval-mark §16: an acted ask whose hand-act is not yet asked again). A key pended again +// keeps its first since. +func (h *HeldWriter[F, C, S, A]) Pend(p PendingKey) { + h.mu.Lock() + defer h.mu.Unlock() + if was, ok := h.pends[p.Key]; ok { + p.Since = was.Since + } + h.pends[p.Key] = p +} + +// Judged ends a key's pending: the part judged it, raising or clearing its finding as it found. +func (h *HeldWriter[F, C, S, A]) Judged(key string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.pends, key) +} + // Held answers the findings the writer holds, a copy taken under its lock (design 47 G A-38), so a part reads its open findings // from the writer. func (h *HeldWriter[F, C, S, A]) Held() []F { diff --git a/go/views/writer_test.go b/go/views/writer_test.go index f75abe55..ed5d4929 100644 --- a/go/views/writer_test.go +++ b/go/views/writer_test.go @@ -505,6 +505,12 @@ func subHeldWriterStatesWhatItHoldsFromItsOwnState(t *testing.T) { if err := h.Clear(ctx, "module", "seeded", "k", "no longer"); err != nil { t.Fatal(err) } + // a key the part cannot judge yet is named in the statement's pending until judged (design 47 G §384), keeping its since + since := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC) + h.Pend(views.PendingKey{Key: "machine.n1.a2.call-unanswered", Since: since, Bound: "5m"}) + h.Pend(views.PendingKey{Key: "machine.n1.a2.call-unanswered", Since: since.Add(time.Hour), Bound: "5m"}) + h.Pend(views.PendingKey{Key: "machine.n1.a3.call-unanswered", Since: since, Bound: "5m"}) + h.Judged("machine.n1.a3.call-unanswered") sent = nil if err := h.State(ctx, "a1"); err != nil { t.Fatal(err) @@ -514,6 +520,9 @@ func subHeldWriterStatesWhatItHoldsFromItsOwnState(t *testing.T) { len(stated.Findings) != 2 || stated.Asked != "a1" { t.Fatalf("the statement says %+v after %d events", stated, len(sent)) } + if p := stated.Pending; len(p) != 1 || p[0].Key != "machine.n1.a2.call-unanswered" || !p[0].Since.Equal(since) || p[0].Bound != "5m" { + t.Fatalf("the statement's pending is %+v", p) + } if held := h.Held(); len(held) != 2 { t.Fatalf("Held answers %v", held) } else if held[0].ID = "mutated"; len(h.Held()) != 2 { From 2612bc470d07e7ee7c982c6265ec857c10a970c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:47:15 +0100 Subject: [PATCH 05/12] Class defaults: an act, a warrant and an accepted kind take the operator, sessions and invokers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seatdef.DefaultCallers follows §610: a read keeps invokers, persons and sessions; act, warrant and accept take operator, sessions and invokers. The class table's rows say so. Builds: design 47 G §610; lead answer A-179 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/seatdef/callers.go | 12 +++++------- go/seatdef/seatdef_test.go | 9 +++++---- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/go/seatdef/callers.go b/go/seatdef/callers.go index ba60222d..0ec3b333 100644 --- a/go/seatdef/callers.go +++ b/go/seatdef/callers.go @@ -101,15 +101,13 @@ func CallerEntryProblem(e string) string { "holder:@same-node", e) } -// DefaultCallers are the callers a verb that names none takes by its class (design 47 G §391): a read takes invokers, -// persons and sessions; an act the operator and sessions; a warrant the operator. A verb that names no class is a -// read. +// DefaultCallers are the callers a verb that names none takes by its class (design 47 G §610, amending §391): a read takes +// invokers, persons and sessions; an act, a warrant and an accepted kind (`accept`) the operator, sessions and invokers. A +// verb that names no class is a read. func DefaultCallers(class string) []string { switch class { - case ClassAct: - return []string{CallerOperator, CallerSessions} - case ClassWarrant: - return []string{CallerOperator} + case ClassAct, ClassWarrant, "accept": + return []string{CallerOperator, CallerSessions, CallerInvokers} } return []string{CallerInvokers, CallerPersons, CallerSessions} } diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 740f958b..63b89451 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -290,7 +290,7 @@ func TestAKeptEventsStreamIsNamedFromTheSeatAndTheEvent(t *testing.T) { } } -// Design 47 G §391: a verb that names no callers takes its class's default, a verb that names them keeps them. +// Design 47 G §391, §610: a verb that names no callers takes its class's default, a verb that names them keeps them. func subAVerbWithoutCallersTakesItsClassDefault(t *testing.T) { for _, c := range []struct { verb Verb @@ -298,10 +298,11 @@ func subAVerbWithoutCallersTakesItsClassDefault(t *testing.T) { }{ {Verb{}, "invokers persons sessions"}, {Verb{Class: ClassRead}, "invokers persons sessions"}, - {Verb{Class: ClassAct}, "operator sessions"}, - {Verb{Class: ClassWarrant}, "operator"}, + {Verb{Class: ClassAct}, "operator sessions invokers"}, + {Verb{Class: ClassWarrant}, "operator sessions invokers"}, {Verb{Class: ClassAct, Callers: &Callers{List: []string{"seat:mesh-broker"}}}, "seat:mesh-broker"}, - {Verb{Class: ClassWarrant, Callers: &Callers{Text: "a person"}}, "operator"}, + {Verb{Class: ClassWarrant, Callers: &Callers{Text: "a person"}}, "operator sessions invokers"}, + {Verb{Class: "accept"}, "operator sessions invokers"}, } { if got := strings.Join(c.verb.EffectiveCallers(), " "); got != c.want { t.Errorf("%+v is open to %q, want %q", c.verb, got, c.want) From 3102fd5c1b55dbdf28fda485d2ec75bde99930e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:51:14 +0100 Subject: [PATCH 06/12] A-165 callers: the seat server refuses a caller outside a verb's effective callers before its handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conn.AnswerSeat takes the seat's definition from its caller (the seat list the process follows) and, before a handler runs, refuses an unproven caller, and every caller while the 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. Every caller of AnswerSeat passes the definition. Builds: design 47 G §610; lead answers A-165, A-179, A-186 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/process/call_integration_test.go | 18 ++++++++++--- go/process/call_test.go | 34 +++++++++++++++++++++++-- go/process/serve.go | 39 +++++++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/go/process/call_integration_test.go b/go/process/call_integration_test.go index 5da784ec..4b0a90f3 100644 --- a/go/process/call_integration_test.go +++ b/go/process/call_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "git.novox.be/novox/mesh-sdk/go/seatdef" "strings" "sync" "sync/atomic" @@ -285,9 +286,14 @@ func TestCallOnReachesTheNamedNodeAlone(t *testing.T) { } } +// brokerSeat is the fixture seat's definition as a seat list holds it: its three verbs, each its class's default. +func brokerSeat() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: "broker-seat", Verbs: []seatdef.Verb{{Name: "ping"}, {Name: "fail"}, {Name: "boom"}}}, true +} + // A-107: a process answers its seat's verbs over the bus. Given two instances of one holder answering seat // `broker-seat`'s verbs, a call on the caller-named subject reaches exactly one, which is handed the caller the -// subject names (dots written `~`) and replies {result, node}; a call on the subject naming none is handed ""; a +// subject names (dots written `~`) and replies {result, node}; a call on the subject naming none is refused; a // handler's error is the reply's error, a panic too; and after stop nothing answers. func TestAnswerSeatServesVerbsWithTheProvedCallerInOneQueueGroup(t *testing.T) { url, account := grantedSendProcess(t) @@ -316,7 +322,7 @@ func TestAnswerSeatServesVerbsWithTheProvedCallerInOneQueueGroup(t *testing.T) { t.Fatal(err) } defer conn.Close() - stop, err := conn.AnswerSeat("broker-seat", verbs(name)) + stop, err := conn.AnswerSeat("broker-seat", brokerSeat, verbs(name)) if err != nil { t.Fatalf("AnswerSeat: %v", err) } @@ -343,14 +349,18 @@ func TestAnswerSeatServesVerbsWithTheProvedCallerInOneQueueGroup(t *testing.T) { if err != nil || r.Result["caller"] != "novox.mesh-delivery-builder" || r.Result["body"] != `{"x":1}` || r.Node != "novox" { t.Fatalf("a caller-named call: %+v, %v", r, err) } + // An unproven caller is refused before the handler runs (design 47 G §610; lead answers A-165, A-179, A-186). r, err = ask("mesh.seat.broker-seat.tool.ping") - if err != nil || r.Result["caller"] != "" { + if err != nil || r.Result != nil || !strings.Contains(r.Error, "broker-seat.ping takes no unproven caller") { t.Fatalf("an unproven call: %+v, %v", r, err) } + if _, err := ask("mesh.seat.broker-seat.call.ping.novox~mesh-delivery-builder"); err != nil { + t.Fatal(err) + } total := int32(0) heard.Range(func(_, v any) bool { total += atomic.LoadInt32(v.(*int32)); return true }) if total != 2 { - t.Errorf("two calls were answered %d times in all, want once each (one queue group)", total) + t.Errorf("two proven calls were answered %d times in all, want once each (one queue group) and the unproven none", total) } if r, err = ask("mesh.seat.broker-seat.call.fail.x"); err != nil || r.Error != "refused: not yours" { t.Errorf("a handler's error: %+v, %v", r, err) diff --git a/go/process/call_test.go b/go/process/call_test.go index 750e981f..3ea2413a 100644 --- a/go/process/call_test.go +++ b/go/process/call_test.go @@ -3,6 +3,7 @@ package process import ( "context" "encoding/json" + "git.novox.be/novox/mesh-sdk/go/seatdef" "strings" "testing" "time" @@ -138,7 +139,9 @@ func TestACallIsAskedAgainOnlyWhenItReachedNoOne(t *testing.T) { } // A-107: AnswerSeat reads the caller from a call's last token (dots written `~`), "" for anything that is not a -// bus user's name; and it refuses what it cannot serve before subscribing. +// bus user's name; and it refuses what it cannot serve before subscribing. A-179, A-186: before a handler runs, 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 of a verb the definition names runs it. func TestUserOfTokenAndAnswerSeatRefusals(t *testing.T) { for token, want := range map[string]string{"novox~mesh-delivery-builder": "novox.mesh-delivery-builder", "person~jo": "person.jo", "plain": "plain", "": "", "a b": "", "a*": "", "~": ""} { @@ -148,9 +151,36 @@ func TestUserOfTokenAndAnswerSeatRefusals(t *testing.T) { } c := &Conn{} ok := func(string, json.RawMessage) (any, error) { return nil, nil } + defined := func() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v"}}}, true + } for name, verbs := range map[string]map[string]SeatHandler{"none": nil, "nil handler": {"v": nil}, "bad verb": {"a b": ok}, "wildcard": {"*": ok}} { - if _, err := c.AnswerSeat("seat", verbs); err == nil { + if _, err := c.AnswerSeat("seat", defined, verbs); err == nil { t.Errorf("AnswerSeat accepted %s", name) } } + if _, err := c.AnswerSeat("seat", nil, map[string]SeatHandler{"v": ok}); err == nil { + t.Error("AnswerSeat accepted no definition") + } + ran := 0 + run := func(string, json.RawMessage) (any, error) { ran++; return "ran", nil } + for _, row := range []struct { + name, caller string + def func() (seatdef.Definition, bool) + says string + }{ + {"an unproven caller", "", defined, "takes no unproven caller"}, + {"the definition unread", "novox.mesh-recovery", func() (seatdef.Definition, bool) { return seatdef.Definition{}, false }, "not yet read"}, + {"a verb the definition does not name", "novox.mesh-recovery", func() (seatdef.Definition, bool) { return seatdef.Definition{Seat: "seat"}, true }, "names no verb v"}, + {"a verb open to none", "novox.mesh-recovery", func() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v", Callers: &seatdef.Callers{List: []string{}}}}}, true + }, "open to no caller"}, + {"a proven caller", "novox.mesh-recovery", defined, ""}, + } { + before := ran + _, err := admitted("seat", "v", row.def, run)(row.caller, nil) + if row.says == "" && (err != nil || ran != before+1) || row.says != "" && (err == nil || !strings.Contains(err.Error(), row.says) || ran != before) { + t.Errorf("%s: %v, ran %d times", row.name, err, ran-before) + } + } } diff --git a/go/process/serve.go b/go/process/serve.go index 03bbc79b..6fb217db 100644 --- a/go/process/serve.go +++ b/go/process/serve.go @@ -10,6 +10,7 @@ import ( "github.com/nats-io/nats.go" "git.novox.be/novox/mesh-sdk/go/calladdress" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // SeatHandler answers one call of a seat's verb: caller is the bus user the subject the call arrived on names @@ -35,7 +36,16 @@ func userOfToken(token string) string { // of the seat's holder subscribes in one queue group per seat, so a call reaches one of them. The reply is // {result, error, node}, as Call reads it. The returned stop ends every subscription. Nothing is subscribed when // any verb is malformed or a subscription is refused. -func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func(), err error) { +// +// Before a handler runs, the call's caller is checked against the verb's effective callers (design 47 G §610; lead +// answers A-165, A-179, A-186): definition answers the seat's definition from the seat list the process follows, false +// while it is not read. An unproven caller is refused, and so is every caller while the definition is unread, names no +// such verb or opens it to none; a proven caller is among them, since the grants part composes a verb's call subject +// only for its effective callers. +func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bool), verbs map[string]SeatHandler) (stop func(), err error) { + if definition == nil { + return nil, fmt.Errorf("AnswerSeat(%q) is given no definition of the seat to read its verbs' callers from", seat) + } if len(verbs) == 0 { return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat) } @@ -58,7 +68,7 @@ func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func( } } for _, verb := range names { - h := verbs[verb] + h := admitted(seat, verb, definition, verbs[verb]) base := "mesh.seat." + seat for _, subject := range []string{base + ".call." + verb + ".*", base + ".tool." + verb} { subject := subject @@ -77,6 +87,31 @@ func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func( return undo, nil } +// admitted is h behind the check of its caller against the verb's effective callers (A-179, A-186). +func admitted(seat, verb string, definition func() (seatdef.Definition, bool), h SeatHandler) SeatHandler { + return func(caller string, body json.RawMessage) (any, error) { + if caller == "" { + return nil, fmt.Errorf("%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, read := definition() + if !read { + return nil, fmt.Errorf("the definition of the seat %s is not yet read by this process's seat list, so no caller of %s.%s "+ + "is admitted", seat, seat, verb) + } + for _, v := range def.Verbs { + if v.Name != verb { + continue + } + if len(v.EffectiveCallers()) == 0 { + return nil, fmt.Errorf("%s.%s is open to no caller", seat, verb) + } + return h(caller, body) + } + return nil, fmt.Errorf("the seat %s's definition names no verb %s, so no caller is admitted", seat, verb) + } +} + // answerCall runs h for one call and replies {result, error, node}; a panic in h is the call's error, never silence. func (c *Conn) answerCall(msg *nats.Msg, pattern string, h SeatHandler) { caller := "" From 0e3cc811746c0722a4e515eda70e023a7aeaa852 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:09:18 +0100 Subject: [PATCH 07/12] A3d item 1: the act and its digest are mesh-sdk's, so the seat server's gate digests as every asker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §677, lead answers A-198, A-219 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/operatorask/act.go | 51 ++++++++++++++++++++++++++++++++++++++ go/operatorask/act_test.go | 34 +++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 go/operatorask/act.go create mode 100644 go/operatorask/act_test.go diff --git a/go/operatorask/act.go b/go/operatorask/act.go new file mode 100644 index 00000000..0c3b949f --- /dev/null +++ b/go/operatorask/act.go @@ -0,0 +1,51 @@ +package operatorask + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strconv" + "strings" +) + +// An Act is what an option of an ask binds (novox/hq ADR 0286 §10, §11): named fields, each a string — the verb, the +// machine, the level, and each argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over +// these names and values alone, never over how a language happens to encode a struct. It is mesh-sdk's (design 47 G +// lead answers A-198, A-219), so the seat server's gate in go/process digests an act as every asker does; +// operatorchannelclient re-exports it. +type Act map[string]string + +// ActDigest is the digest an asker puts in an option's binds: SHA-256 over the act's canonical encoding, written +// "sha256:". The same names and values give the same digest in any language, whatever order they were set in; a +// field renamed, added or emptied gives another. +func ActDigest(act Act) (string, error) { + if len(act) == 0 { + return "", fmt.Errorf("the act cannot be digested: it names nothing") + } + names := make([]string, 0, len(act)) + for k := range act { + if k == "" { + return "", fmt.Errorf("the act cannot be digested: a field has no name") + } + names = append(names, k) + } + sort.Strings(names) + var b strings.Builder + b.WriteString("novox.act.v1\n") + for _, k := range names { + canonical(&b, k) + canonical(&b, act[k]) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read as +// another's end or start, so two different sequences of values never encode the same. +func canonical(b *strings.Builder, v string) { + b.WriteString(strconv.Itoa(len(v))) + b.WriteByte(':') + b.WriteString(v) + b.WriteByte('\n') +} diff --git a/go/operatorask/act_test.go b/go/operatorask/act_test.go new file mode 100644 index 00000000..f5a6debf --- /dev/null +++ b/go/operatorask/act_test.go @@ -0,0 +1,34 @@ +package operatorask + +import ( + "crypto/sha256" + "encoding/hex" + "testing" +) + +// Design 47 G A-198: the act's form and its digest are mesh-sdk's, so a seat server in go/process digests an act as +// every asker does, with no import of the contracts; the digest is canonical over the act's named fields. +func TestTheActsDigestIsCanonicalOverItsNamedFields(t *testing.T) { + a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"}) + b := Act{} + b["arg.delivery"] = "hq" + b["verb"] = "release" + if a2, _ := ActDigest(b); a1 != a2 { + t.Error("the order an act was built in changed its digest") + } + x, _ := ActDigest(Act{"a": "b\n1:c"}) + y, _ := ActDigest(Act{"a": "b", "c": ""}) + if x == y { + t.Error("two different acts encode the same") + } + if _, err := ActDigest(Act{}); err == nil { + t.Error("an empty act was digested") + } + if _, err := ActDigest(Act{"": "x"}); err == nil { + t.Error("an act with a nameless field was digested") + } + sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n")) + if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) { + t.Errorf("the act's encoding is not the one written down: %s", got) + } +} From ee8958b0463e5a9dda4647542818a095d2debc7a Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:25:56 +0100 Subject: [PATCH 08/12] A3e item 1: the approval gate is mesh-sdk's go/approval, reading the channel, catalog, findings and holders through interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §677, §700, lead answers A-219, A-224, A-228 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/approval/act.go | 293 +++++++++++++++++++++++++++++++++++ go/approval/approval_test.go | 80 ++++++++++ go/approval/ask.go | 222 ++++++++++++++++++++++++++ go/approval/channel.go | 115 ++++++++++++++ go/approval/closed.go | 97 ++++++++++++ go/approval/gate.go | 170 ++++++++++++++++++++ go/approval/marks.go | 172 ++++++++++++++++++++ go/approval/record.go | 255 ++++++++++++++++++++++++++++++ go/approval/run.go | 176 +++++++++++++++++++++ go/approval/tools.go | 172 ++++++++++++++++++++ go/approval/wait.go | 290 ++++++++++++++++++++++++++++++++++ go/packages_list_test.go | 2 +- 12 files changed, 2043 insertions(+), 1 deletion(-) create mode 100644 go/approval/act.go create mode 100644 go/approval/approval_test.go create mode 100644 go/approval/ask.go create mode 100644 go/approval/channel.go create mode 100644 go/approval/closed.go create mode 100644 go/approval/gate.go create mode 100644 go/approval/marks.go create mode 100644 go/approval/record.go create mode 100644 go/approval/run.go create mode 100644 go/approval/tools.go create mode 100644 go/approval/wait.go diff --git a/go/approval/act.go b/go/approval/act.go new file mode 100644 index 00000000..2d61d27e --- /dev/null +++ b/go/approval/act.go @@ -0,0 +1,293 @@ +package approval + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "git.novox.be/novox/mesh-sdk/go/operatorask" + "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 + // 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). +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) (operatorask.Act, error) { + act := operatorask.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/go/approval/approval_test.go b/go/approval/approval_test.go new file mode 100644 index 00000000..29ca678e --- /dev/null +++ b/go/approval/approval_test.go @@ -0,0 +1,80 @@ +package approval + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// call is a fixture call of the marked tool wipe. +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."} +} + +// Design 46 7-approval-mark §8, §10, §11: the act's fields give each call its digest, and the ask shows a call's arguments +// in their shape and its headline cut (the gate's other cases run on the tool runner's fixture mesh, node-tools +// internal/approval). +func TestTheActAndItsAsk(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, _ := operatorask.ActDigest(act) + return d + } + act, _ := ActOf(base, "n1") + want := operatorask.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("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("Run", "wipe", "n1", false); got != "Run wipe on n1? Part shown" { + t.Errorf("headline %q", got) + } +} diff --git a/go/approval/ask.go b/go/approval/ask.go new file mode 100644 index 00000000..1cbc3143 --- /dev/null +++ b/go/approval/ask.go @@ -0,0 +1,222 @@ +package approval + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// 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" +) + +// actKey is the key naming the newest ask of an act (§13), its digest escaped as a key's name is, since the bus holds no `:` in a key (lead answer A-209). +func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + keys.EscapeName(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) Ask { + args, whole := shown(c.Args) + name := c.Name + if c.Seat != "" { + name = c.Seat + "." + c.Name + } + headline := headlineOf("Run", 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 Ask{ID: id, About: digest, Headline: headline, + Explanation: strings.Join(lines, "\n"), Expires: formatTime(g.d.Now().Add(AskLasts)), OnExpiry: OnExpiry, + Options: []Option{ + {ID: OptionApprove, Label: "Approve", Does: "the same call, made again within 30 minutes, runs once", Binds: digest}, + {ID: OptionDecline, Label: "Decline", Does: "nothing runs; the same call is not asked again for 10 minutes", Binds: digest}}} +} + +// 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 := HeadlineLength + fits := func(s string) bool { return len([]rune(s)) <= max } + if h := verb + " " + name + " on " + node + "?" + suffix; fits(h) { + return h + } + if h := verb + " " + name + "?" + suffix; fits(h) { + return h + } + room := max - len([]rune(verb+" …?"+suffix)) + r := []rune(name) + if room < 1 { + room = 1 + } + if len(r) > room { + r = r[:room] + } + 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 +// 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 := operatorask.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 operatorask.Act, digest, markedSince string) error { + id := newID() + 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 operatorask.Act, digest string, a Ask) error { + 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 := g.d.Channel.Ask(c.Module, a); err != nil { + return g.refused(c, digest, "the ask could not be raised: "+err.Error()) + } + 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), 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 nil +} + +// keptSize is the bytes the kept ask would take. +func keptSize(a Ask, act operatorask.Act, args json.RawMessage) int { + raw, _ := json.Marshal(Kept[Ask, Warrant]{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 operatorask.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 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) (*Record, error) { + return g.d.Channel.Record(module, id) +} + +// 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) (*Record, 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("[approval] %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/go/approval/channel.go b/go/approval/channel.go new file mode 100644 index 00000000..b86689b7 --- /dev/null +++ b/go/approval/channel.go @@ -0,0 +1,115 @@ +package approval + +import ( + "encoding/json" + "time" + + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// What the gate reads beyond itself crosses in the gate's own terms, its caller translating from the generated contracts +// (design 47 G lead answers A-219, A-224, A-228): the operator channel (Channel), the catalog's entries (Entry, through +// Catalog), the finding writer (FindingWriter) and the seat holders (Deps.Holder). No form of a generated contract is +// copied here: what the router keeps whole (an ask, a warrant) the gate holds as the bytes it was given. + +// Channel is the operator channel as the gate reaches it (A-219, A-228): operatorchannelclient implements it over the +// generated types, keeping each ask in the gate's own state under the marked module's name. +type Channel interface { + // Ask checks the ask, keeps it in the gate's state as a Kept and publishes it under module's name, at the level + // approve, to the operator (design 46 7-approval-mark §10). + Ask(module string, a Ask) error + // Record is the router's record of module's ask id, read under module's own name; nil while the router holds none + // (design 47 G §700). + Record(module, id string) (*Record, error) + // Claim takes the warrant once (design 46 7-approval-mark §14, §15): it holds it to the ask kept, checks that the + // chosen option binds act and marks the kept ask acted, then calls run; act's run happens only when it answers + // Claimed. A warrant already taken answers ClaimDone, one that does not hold ClaimRefused with why. + Claim(module string, w Warrant, act operatorask.Act, run func()) (Claim, error) + // Cancel takes module's ask back. + Cancel(module, id string) error + // Decided is the subject the router's `decided` for module is heard on. + Decided(module string) string + // Seat is the seat an ask is raised on, whose holder must be there for a marked call to be asked (§19). + Seat() string +} + +// Claim is what came of a warrant handed to Channel.Claim. +type Claim string + +const ( + Claimed Claim = "acted" + ClaimRefused Claim = "refused" + ClaimDone Claim = "already acted on" + // ClaimEnded is a warrant of an ask that ended without a choice. + ClaimEnded Claim = "ended without a choice" +) + +// Ask is an ask the gate raises: two options, each binding the act's digest (design 46 7-approval-mark §10, §11). +type Ask struct { + ID, About, Headline, Explanation, Expires, OnExpiry string + Options []Option +} + +// Option is one option of an ask. +type Option struct{ ID, Label, Does, Binds string } + +// Record is the router's record of an ask as the gate reads it. +type Record struct { + State, Answer, Opened, Expires string + OfferedOn []string + Warrant *Warrant +} + +// Warrant is a person's choice as the gate reads it; Whole is the warrant as the router recorded it, which Claim holds to +// the kept ask. +type Warrant struct { + Ask, Asker, Option, Channel, At string + Whole json.RawMessage +} + +// Kept is one ask as its asker keeps it (design 46 7-approval-mark §13): the ask, beside it the act the ask binds, the +// call's whole arguments, and whether the act's run was recorded in the hand-act log. A is the ask's form and W the +// warrant's: operatorchannelclient's Kept is Kept of the generated contract's, the gate's of the bytes kept (A-228). +type Kept[A, W any] struct { + Ask A `json:"ask"` + Asked time.Time `json:"asked"` + Acted bool `json:"acted,omitempty"` + Ended string `json:"ended,omitempty"` + Answer *W `json:"answer,omitempty"` + // Act is the act the ask's options bind, as its asker digested it. + Act operatorask.Act `json:"act,omitempty"` + // Arguments are the asked call's arguments whole. + Arguments json.RawMessage `json:"arguments,omitempty"` + // Recorded says the act's run is in the hand-act log (`mesh-hand-acts.warranted` answered). + Recorded bool `json:"recorded,omitempty"` +} + +// kept is the gate's own reading of a Kept: the ask and the warrant as the bytes kept, so a write back keeps them whole. +type kept = Kept[json.RawMessage, json.RawMessage] + +// HeadlineLength is the longest headline the router takes, in characters; operatorchannelclient re-exports it. +const HeadlineLength = 60 + +// Entry is a module's entry in the catalog as the marks are read from it (design 46 7-approval-mark §5): its module, the +// build's fingerprint, the seat verbs it serves as `.`, and its manifest. +type Entry struct { + Module, Fingerprint string + Serves []string + Manifest json.RawMessage +} + +// formatTime is a time as the router's records carry it: RFC 3339 to the nanosecond; the zero time is empty. +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339Nano) +} + +// parseTime reads such a time; empty is the zero time. +func parseTime(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339Nano, s) +} diff --git a/go/approval/closed.go b/go/approval/closed.go new file mode 100644 index 00000000..6ee0d1c1 --- /dev/null +++ b/go/approval/closed.go @@ -0,0 +1,97 @@ +package approval + +import ( + "fmt" + "time" +) + +// 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.Channel == nil { + return "the tool runner reaches no operator channel to ask on" + } + if g.d.Holder == nil { + return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + g.d.Channel.Seat() + } + on := g.d.Channel.Seat() + ok, err := g.d.Holder(on) + switch { + case err != nil: + return fmt.Sprintf("whether a module holds %s cannot be read: %v", on, err) + case !ok: + return "no module holds " + on + ", 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("[approval] 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("[approval] 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) +} diff --git a/go/approval/gate.go b/go/approval/gate.go new file mode 100644 index 00000000..170bae16 --- /dev/null +++ b/go/approval/gate.go @@ -0,0 +1,170 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// 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 raises and clears the gate's findings by kind and id (A-228): its caller writes each as the contract's +// finding, about its own module on its machine, through its held writer. +type FindingWriter interface { + Raise(ctx context.Context, kind, id, summary, headline string) error + Clear(ctx context.Context, kind, id, 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 + // 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) + 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 + // Channel is the operator channel, its asks kept in State under this machine's keys (A-219, A-228). + Channel Channel + // 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 + // 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()) +} + +// Gate is the approval gate: every call of a module the tool runner serves goes through it before the bundle (§20), and a +// marked verb a module's process serves before its handler (design 47 G §677; lead answer A-224). +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 + // 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 + // 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. +func New(d Deps) *Gate { + if d.Logf == nil { + d.Logf = func(string, ...any) {} + } + if d.Now == nil { + d.Now = time.Now + } + 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{}} +} + +// 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 + 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 + } + 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, 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 := g.findingID(what) + if g.d.Findings == nil { + g.d.Logf("[approval] %s %s: %s", kind, id, summary) + return + } + var err error + if raised { + err = g.d.Findings.Raise(context.Background(), kind, id, summary, headline) + } else { + err = g.d.Findings.Clear(context.Background(), kind, id, summary) + } + if err != nil { + g.d.Logf("[approval] the finding %s of %s is not kept: %v", kind, id, err) + } +} diff --git a/go/approval/marks.go b/go/approval/marks.go new file mode 100644 index 00000000..e162edff --- /dev/null +++ b/go/approval/marks.go @@ -0,0 +1,172 @@ +// Package approval is the one gate on a call that needs the operator's approval (design 46 7-approval-mark; design 47 G +// §677, lead answer A-224): the tool runner's and a module's process's seat server's. It holds 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" + + "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) (Entry, bool, string) + // Newest is the module's entry at the newest revision; false when the catalog half lists none. + Newest(module string) (Entry, 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() []Entry +} + +// 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 []Entry + 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.Fingerprint != entries[0].Fingerprint) { + entries = append(entries, e) + } + for _, e := range entries { + 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 Entry, defs map[string]seatdef.Definition) { + source := "entry " + e.Module + " " + orNone(e.Fingerprint) + var said marked + _ = json.Unmarshal(e.Manifest, &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 + } + } + } +} + +// 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 +} diff --git a/go/approval/record.go b/go/approval/record.go new file mode 100644 index 00000000..22c1af32 --- /dev/null +++ b/go/approval/record.go @@ -0,0 +1,255 @@ +package approval + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// 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 *Record, 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("[approval] %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("[approval] 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("[approval] the run of ask %s is not recorded: the tool runner reaches no hand-act log", id) + return + } + 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("[approval] 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("[approval] 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) (*kept, uint64, error) { + e, err := g.d.State.Get(g.askKey(id)) + if err != nil || e == nil { + return nil, 0, err + } + var k 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("[approval] 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.pend(id) + g.d.Go(func() { g.recordRun(module, id) }) + } +} + +// moduleOf is the module an ask was asked under: the warrant's asker. +func moduleOf(k *kept) string { + var w struct { + Asker string `json:"asker"` + } + if k.Answer != nil { + _ = json.Unmarshal(*k.Answer, &w) + } + return w.Asker +} + +// 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) { + 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 { + 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("[approval] ERROR the run of ask %s on %s is still unrecorded an hour before its key's 48 hours end", id, g.d.Node) + } + }) +} + +// Pender is the held writer's pending (views.HeldWriter's Pend and Judged), by a finding's kind and id: the findings it +// cannot judge yet since its start, named in its `findings-stated` (design 47 G §384). +type Pender interface { + Pend(kind, id string, since time.Time, bound time.Duration) + Judged(kind, id 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(KindCallUnanswered, g.findingID(id), g.d.Now().UTC(), UnansweredAfter) + } +} + +// judged ends that pending. +func (g *Gate) judged(id string) { + if p, ok := g.d.Findings.(Pender); ok { + p.Judged(KindCallUnanswered, g.findingID(id)) + } +} + +// findingID is the id of a finding about what on this machine, `.` escaped as a key's name is (lead answer +// A-154). +func (g *Gate) findingID(what string) string { return keys.EscapeName(g.d.Node + "." + what) } + +// isChanged says a compare-and-set lost to another write. +func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } diff --git a/go/approval/run.go b/go/approval/run.go new file mode 100644 index 00000000..5cefae6f --- /dev/null +++ b/go/approval/run.go @@ -0,0 +1,176 @@ +package approval + +import ( + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// 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) + } + 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()) + } + digest, err := operatorask.ActDigest(act) + if err != nil { + return nil, g.refused(c, "", err.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) + } + 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 operatorask.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, _ := parseTime(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, 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.d.Channel.Claim(c.Module, *w, act, func() { answer, ran = run() }) + switch { + case h == ClaimDone: + return again("was already run") + case h == ClaimRefused: + 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()) + } + return g.ran(c, id, rec, answer, ran) +} + +func errText(err error) string { + if err == nil { + return "no reason given" + } + return err.Error() +} diff --git a/go/approval/tools.go b/go/approval/tools.go new file mode 100644 index 00000000..7b1d78db --- /dev/null +++ b/go/approval/tools.go @@ -0,0 +1,172 @@ +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) + } + var ask struct { + About string `json:"about"` + } + _ = json.Unmarshal(k.Ask, &ask) + 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(ask.About)}, + Digest: 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.d.Channel.Cancel(w.Module, id) +} diff --git a/go/approval/wait.go b/go/approval/wait.go new file mode 100644 index 00000000..aea3a59a --- /dev/null +++ b/go/approval/wait.go @@ -0,0 +1,290 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/operatorask" + "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 := operatorask.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, _ := parseTime(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 + // On the warrant it subscribes, and the wait's timeout runs from then (§35). + h, err := g.d.Channel.Claim(c.Module, *wt, act, func() { answer, ran = hear(w.Timeout) }) + switch { + case h == ClaimDone: + return anew() // already run: one warrant is one wait (§35) + case h == ClaimRefused: + 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{g.d.Channel.Decided(module)}, left, func(body json.RawMessage) bool { + var w struct { + Ask string `json:"ask"` + } + 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 *Record, 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, _ := parseTime(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, + 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) 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/go/packages_list_test.go b/go/packages_list_test.go index f246289f..f0be789b 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -26,7 +26,7 @@ var meshSDKPackages = []string{ ".", "announce", "announcetest", "busarchive", "buscalls", "buscompose", "busstate", "bussnap", "busretry", "calladdress", "catalogue", "declaration", "forgekey", "health", "internal/beside", "keys", "kvlimit", "membership", "motion", "objectlink", "outward", "pernode", "process", - "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "operatorask", "stateobjects", "testbus", "views", "views/viewstest", + "sdkversion", "seal", "seatdef", "seatlist", "seatlist/seatlisttest", "operatorask", "approval", "stateobjects", "testbus", "views", "views/viewstest", } // clientSeats are the seats whose logic is their client's in mesh-contracts (novox/hq ADR 0627 §1, §4): mesh-sdk names From d3980ded07c54b7c61b6f722889699520f581f56 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:28:06 +0100 Subject: [PATCH 09/12] A3f item 1: a process holder's marked seat verb is held through go/approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conn.AnswerSeat holds a verb its seat definition marks needs-approval through the holder's gate (process.Conn.Gate, go/approval held.go): waiting calls call., act. by create or compare-and-set, claimed. by create; a call carrying the x-wait: held header answered {asked, call} in the reply's field held; on the router's record read under the module's name, the oldest waiting call run once within 30 minutes of the Approve and recorded through mesh-hand-acts.warranted, the others asked anew; declined unasked for 10 minutes; expired and refused ends; every end said on gated-ended..; the read at start; gate-unanswered past the ask's 60 minutes; the verb approvals. A call without the header takes ADR 0286 §37's path. process.Conn's seat call sends the header on every seat call and, answered held, waits on its own gated-ended. Builds: design 47 G §677, §700, lead answers A-158, A-186, A-209, A-219, A-224, A-228, A-244 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/approval/ask.go | 25 +- go/approval/closed.go | 3 + go/approval/gate.go | 7 + go/approval/held.go | 653 +++++++++++++++++++++++++++++++++++++++ go/approval/held_test.go | 333 ++++++++++++++++++++ go/approval/record.go | 2 +- go/process/call.go | 13 +- go/process/call_test.go | 5 +- go/process/gate.go | 129 ++++++++ go/process/serve.go | 72 ++++- 10 files changed, 1223 insertions(+), 19 deletions(-) create mode 100644 go/approval/held.go create mode 100644 go/approval/held_test.go create mode 100644 go/process/gate.go diff --git a/go/approval/ask.go b/go/approval/ask.go index 1cbc3143..1033882e 100644 --- a/go/approval/ask.go +++ b/go/approval/ask.go @@ -43,10 +43,19 @@ const ( ) // actKey is the key naming the newest ask of an act (§13), its digest escaped as a key's name is, since the bus holds no `:` in a key (lead answer A-209). -func (g *Gate) actKey(digest string) string { return g.d.Node + ".act." + keys.EscapeName(digest) } +func (g *Gate) actKey(digest string) string { return g.prefix() + "act." + keys.EscapeName(digest) } + +// prefix is what the gate's keys begin with: the tool runner's machine, `.`, since its state is shared by every +// machine's runner (§13); none for a module's process, whose state approvals is its module's own (design 47 G §677). +func (g *Gate) prefix() string { + if g.d.Holding { + return "" + } + return g.d.Node + "." +} // askKey is the key the client keeps an ask under (§13). -func (g *Gate) askKey(id string) string { return g.d.Node + ".ask." + id } +func (g *Gate) askKey(id string) string { return g.prefix() + "ask." + id } // newID is an ask's id: 128 random bits. func newID() string { @@ -67,12 +76,20 @@ func (g *Gate) askFor(c Call, digest, id string) Ask { if first == "" { first = name } - lines := []string{first, "On " + g.d.Node + ", called through the mesh's tools, which cannot tell who called."} + how := "called through the mesh's tools, which cannot tell who called." + if g.d.Holding { + how = "called by " + c.Caller + "." + } + lines := []string{first, "On " + g.d.Node + ", " + how} 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.") + read := "mesh_approvals on " + g.d.Node + if g.d.Holding { + read = c.Seat + ".approvals" + } + lines = append(lines, "The whole is read with "+read+", whose fingerprint must be the one on the phone.") } lines = append(lines, "Fingerprint "+Fingerprint(digest)) return Ask{ID: id, About: digest, Headline: headline, diff --git a/go/approval/closed.go b/go/approval/closed.go index 6ee0d1c1..00310a6d 100644 --- a/go/approval/closed.go +++ b/go/approval/closed.go @@ -17,6 +17,9 @@ func (g *Gate) held() string { if g.d.Channel == nil { return "the tool runner reaches no operator channel to ask on" } + if g.d.Holder == nil && g.d.Holding { + return "" // a process holder reads no view of the seats' holders: an ask nobody takes is said by its gate-unanswered (design 47 G §677) + } if g.d.Holder == nil { return "the tool runner reads no view of the seats' holders, so it cannot tell that a module holds " + g.d.Channel.Seat() } diff --git a/go/approval/gate.go b/go/approval/gate.go index 170bae16..2f6d9cf2 100644 --- a/go/approval/gate.go +++ b/go/approval/gate.go @@ -59,6 +59,11 @@ type Deps struct { 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()) + // Holding says the gate is a module's process's seat server's (design 47 G §677): its keys are its module's own, its + // calls held as waiting calls and their ends said through Say; Runner is then the holder's module. + Holding bool + // Say publishes one gated-ended on its subject with the message id given (§677). + Say func(subject string, body any, id string) error } // Gate is the approval gate: every call of a module the tool runner serves goes through it before the bundle (§20), and a @@ -76,6 +81,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 + // handlers are a process holder's marked verbs' handlers, by `.` (design 47 G §677). + handlers map[string]func(string, json.RawMessage) (any, error) // served is each module this runner serves, with the build its bundle serves, as its last load said (§18). served map[string]string } diff --git a/go/approval/held.go b/go/approval/held.go new file mode 100644 index 00000000..3c0b0ff1 --- /dev/null +++ b/go/approval/held.go @@ -0,0 +1,653 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/keys" + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// A marked verb a module's process serves is one call with a gate before its running (design 47 G §677): the holder keeps +// the call, asks under its own module's name, and on the Approve runs that same call, saying its end on the seat's event +// `gated-ended`. These are the holder's half of the gate; the caller's is process.Conn's call. + +// WaitHeader is the bus header a call carries to say it waits for its end on `gated-ended` (lead answer A-244): a header, +// never an argument, so no verb's input and no act's digest changes. WaitHeld is its one value. +const ( + WaitHeader = "x-wait" + WaitHeld = "held" +) + +// Held is the holder's answer to a call that waits (§677): the ask it waits on and its own call's id, in the reply's field +// `held` beside result, error and node (A-244). +type Held struct { + Asked string `json:"asked"` + Call string `json:"call"` +} + +// GatedEndedEvent is the seat's event a call's end is said on, kept last per subject for GatedEndedKeptFor (§677). +const ( + GatedEndedEvent = "gated-ended" + GatedEndedKeptFor = 24 * time.Hour +) + +// The outcomes of a held call (§677). +const ( + OutcomeRan = "ran" + OutcomeDeclined = "declined" + OutcomeExpired = "expired" + OutcomeRefused = "refused" +) + +// KindGateUnanswered is the holder's finding of a waiting call past its ask's 60 minutes with no decided heard (§677), +// its id the call's. +const KindGateUnanswered = "gate-unanswered" + +// GatedEnded is a held call's end as the holder says it (§677): no argument is in it. +type GatedEnded struct { + Call string `json:"call"` + Ask string `json:"ask"` + Act string `json:"act"` + Verb string `json:"verb"` + Outcome string `json:"outcome"` + Answer any `json:"answer,omitempty"` + Approval string `json:"approval,omitempty"` + Error string `json:"error,omitempty"` +} + +// GatedEndedSubject is the subject one call's end is said on: `mesh.seat..event.gated-ended..`, the +// caller the account the bus proved, escaped as a key's name is (§677, §73). +func GatedEndedSubject(seat, caller, call string) string { + return "mesh.seat." + seat + ".event." + GatedEndedEvent + "." + keys.EscapeName(caller) + "." + call +} + +// WaitingCall is one call the holder keeps under `call.` (§677): the call whole, the caller the bus proved, its ask, +// and once it ended its outcome, and whether that end was said. +type WaitingCall struct { + ID string `json:"id"` + Caller string `json:"caller"` + Ask string `json:"ask"` + Digest string `json:"digest"` + Act operatorask.Act `json:"act"` + Address string `json:"address"` + Seat string `json:"seat"` + Verb string `json:"verb"` + Arguments json.RawMessage `json:"arguments,omitempty"` + // Made is when the call was made; Asked when its ask (the newest, after an ask anew) was raised or taken. + Made time.Time `json:"made"` + Asked time.Time `json:"asked"` + // End is its end once it ended; Said says End was said on gated-ended. + End *GatedEnded `json:"end,omitempty"` + Said bool `json:"said,omitempty"` +} + +func callKey(id string) string { return "call." + id } +func claimedKey(id string) string { return "claimed." + id } + +// Serves names the handler a waiting call of seat's verb runs on its Approve (§677): process.Conn's AnswerSeat gives each +// marked verb's. +func (g *Gate) Serves(seat, verb string, h func(caller string, args json.RawMessage) (any, error)) { + g.mu.Lock() + defer g.mu.Unlock() + if g.handlers == nil { + g.handlers = map[string]func(string, json.RawMessage) (any, error){} + } + g.handlers[seat+"."+verb] = h +} + +func (g *Gate) handler(seat, verb string) func(string, json.RawMessage) (any, error) { + g.mu.Lock() + defer g.mu.Unlock() + return g.handlers[seat+"."+verb] +} + +// Hold is a call of a marked verb the holder serves (§677). A call whose arguments may not be asked, or that comes while +// the state approvals cannot be read or written, is refused at once and nothing is kept. One that waits (the header +// WaitHeader) is kept as a waiting call and answered at once with Held; one that does not takes ADR 0286 §37's path: it is +// refused naming its act's ask, and the same call made again after the Approve runs. A call of an act declined within 10 +// minutes ends declined at once, unasked. +func (g *Gate) Hold(c Call, wait bool, run func() (any, error)) (any, *Held, error) { + if c.Fingerprint == "" { + c.Fingerprint = g.d.Build + } + if c.Fingerprint == "" { + return nil, nil, g.refused(c, "", "the holder's own fingerprint is unknown, so no warrant can bind its build") + } + if g.d.State == nil { + return nil, nil, g.refused(c, "", "its holder keeps no state "+StateName+" to keep its calls in") + } + if why := refusedUnasked(c.Args); why != "" { + return nil, nil, g.refused(c, "", why) + } + if why := g.held(); why != "" { + return nil, nil, g.refused(c, "", why) + } + act, err := ActOf(c, g.d.Node) + if err != nil { + return nil, nil, g.refused(c, "", err.Error()) + } + digest, err := operatorask.ActDigest(act) + if err != nil { + return nil, nil, g.refused(c, "", err.Error()) + } + if !wait { + answer, err := g.unheld(c, act, digest, run) + return answer, nil, err + } + id, err := g.askOf(c, act, digest) + if err != nil { + return nil, nil, err + } + now := g.d.Now().UTC() + w := WaitingCall{ID: newID(), Caller: c.Caller, Ask: id, Digest: digest, Act: act, Address: c.Address, Seat: c.Seat, + Verb: c.Name, Arguments: c.Args, Made: now, Asked: now} + if _, err := g.d.State.Create(callKey(w.ID), w); err != nil { + g.stateFailed(err) + return nil, nil, g.refused(c, digest, "its call cannot be kept in "+StateName+": "+err.Error()) + } + g.stateRead() + g.armUnanswered(w) + g.d.Go(func() { g.Decided(id) }) // a choice made before the call was kept is acted on now + return nil, &Held{Asked: id, Call: w.ID}, nil +} + +// askOf is the ask a call of the act takes (§677): the one `act.` names while it is open, else a new one, written +// with the bus's create (so two instances raise one ask) or, over an ended one, by compare-and-set on its revision. A call +// of an act declined within 10 minutes is refused with the time it may be asked again. +func (g *Gate) askOf(c Call, act operatorask.Act, digest string) (string, error) { + key := g.actKey(digest) + for { + e, err := g.d.State.Get(key) + if err != nil { + g.stateFailed(err) + return "", g.refused(c, digest, "its state "+StateName+" cannot be read: "+err.Error()) + } + g.stateRead() + id := newID() + if e == nil { + if _, err := g.d.State.Create(key, id); err != nil { + if isChanged(err) { + continue + } + g.stateFailed(err) + return "", g.refused(c, digest, "its act cannot be kept in "+StateName+": "+err.Error()) + } + return id, g.raiseHeld(c, act, digest, id) + } + var named string + if err := json.Unmarshal(e.Value, &named); err != nil || named == "" { + return "", g.refused(c, digest, fmt.Sprintf("its act's key in %s names no ask", StateName)) + } + open, until, err := g.openAsk(c.Module, named) + if err != nil { + return "", g.refused(c, digest, err.Error()) + } + if !until.IsZero() { + return "", fmt.Errorf("Not run: the operator declined ask %s; the same call is not asked again until %s", named, formatTime(until)) + } + if open { + return named, nil + } + if _, err := g.d.State.Update(key, id, e.Revision); err != nil { + if isChanged(err) { + continue + } + g.stateFailed(err) + return "", g.refused(c, digest, "its act cannot be kept in "+StateName+": "+err.Error()) + } + return id, g.raiseHeld(c, act, digest, id) + } +} + +// openAsk says whether an ask is open for another call of its act: raised and not yet ended by the router; and, when it was +// declined within 10 minutes, until when no call of its act is asked. +func (g *Gate) openAsk(module, id string) (bool, time.Time, error) { + rec, err := g.record(module, id) + if err != nil { + return false, time.Time{}, fmt.Errorf("the router's record of its ask %s cannot be read: %v", id, err) + } + if rec == nil { + k, _, err := g.kept(id) + return err == nil && k != nil && k.Ended == "", time.Time{}, nil // raised and not yet taken by the router + } + switch rec.State { + case "open", "held-back": + return true, time.Time{}, nil + case "chosen": + if w := rec.Warrant; w != nil && w.Option == OptionDecline { + at, _ := parseTime(w.At) + if until := at.Add(DeclinedFor); g.d.Now().Before(until) { + return false, until, nil + } + } + } + return false, time.Time{}, nil +} + +// raiseHeld raises the ask the act's key names, under the holder's module's name, keeping the act and the arguments beside +// it (§677; design 46 7-approval-mark §10). +func (g *Gate) raiseHeld(c Call, act operatorask.Act, digest, id string) error { + return g.keepAskHeld(c, act, digest, g.askFor(c, digest, id)) +} + +// keepAskHeld is keepAsk without its write of the act's key, which askOf made first. +func (g *Gate) keepAskHeld(c Call, act operatorask.Act, digest string, a Ask) error { + 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 := g.d.Channel.Ask(c.Module, a); err != nil { + return g.refused(c, digest, "the ask could not be raised: "+err.Error()) + } + 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()) + } + return nil +} + +// unheld is a call that does not wait (ADR 0286 §37's path, §677): run when its act's ask is chosen Approve within 30 +// minutes and unclaimed, claimed once; otherwise refused naming the ask, raising one when none is open. +func (g *Gate) unheld(c Call, act operatorask.Act, digest string, 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 { + var id string + if json.Unmarshal(named.Value, &id) == nil && id != "" { + 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 && rec.State == "chosen" && rec.Warrant != nil && rec.Warrant.Option == OptionApprove { + if at, _ := parseTime(rec.Warrant.At); !g.d.Now().After(at.Add(RunWithin)) { + out := g.claimRun(c.Module, id, *rec.Warrant, act, run) + switch out.outcome { + case OutcomeRan: + return g.ran(c, id, rec, out.answer, out.err) + case OutcomeRefused: + return nil, g.refused(c, digest, out.why) + } + } + } + } + } + id, err := g.askOf(c, act, digest) + if err != nil { + return nil, err + } + return nil, g.firstAnswer(c, id, digest, "") +} + +// claimed is what came of a claim of a warrant for one call. +type claimed struct { + outcome string // OutcomeRan, OutcomeRefused, or "" when another took it + answer any + err error + why string +} + +// claimRun holds the warrant to the ask and the call's act, claims it once at `claimed.` with the bus's create, and +// runs the call (§677; ADR 0286 §27, §28, §30, §31): of two instances, or two deliveries, one runs. +func (g *Gate) claimRun(module, id string, w Warrant, act operatorask.Act, run func() (any, error)) claimed { + var out claimed + var claimErr error + h, err := g.d.Channel.Claim(module, w, act, func() { + if _, err := g.d.State.Create(claimedKey(id), map[string]string{"at": formatTime(g.d.Now().UTC())}); err != nil { + claimErr = err + return + } + out.outcome = OutcomeRan + out.answer, out.err = run() + }) + switch { + case h == Claimed && claimErr != nil && !isChanged(claimErr): + g.stateFailed(claimErr) + return claimed{outcome: OutcomeRefused, why: "its claim cannot be written in " + StateName + ": " + claimErr.Error()} + case h == Claimed: + return out + case h == ClaimDone: + return claimed{} + case h == "" && err != nil: + g.stateFailed(err) + return claimed{outcome: OutcomeRefused, why: "its state " + StateName + " cannot be written: " + err.Error()} + default: // refused, or the act about to run is not the one the option bound: nothing runs + return claimed{outcome: OutcomeRefused, why: "its warrant does not hold: " + errText(err)} + } +} + +// Decided acts on the router's own record of one of the holder's asks (§677): heard on the holder's `decided`, never from +// its body, and at the start for each ask a waiting call waits on. On the Approve the oldest waiting call of the ask runs, +// within 30 minutes of the choice, else it ends expired; every other is asked anew. A Decline ends every one declined, an +// ask that ended without a choice every one expired, and one the router refused every one refused with its words. +func (g *Gate) Decided(id string) { + module := g.d.Runner + rec, err := g.record(module, id) + if err != nil { + g.d.Logf("[approval] the router's record of ask %s cannot be read (%v): read again at the next start", id, err) + return + } + if rec == nil || rec.State == "open" || rec.State == "held-back" { + return + } + calls, err := g.waiting(func(w WaitingCall) bool { return w.Ask == id }) + if err != nil { + g.stateFailed(err) + g.d.Logf("[approval] the waiting calls of ask %s cannot be read (%v): read again at the next start", id, err) + return + } + if len(calls) == 0 { + return + } + end := func(w WaitingCall, outcome, why string) { + g.end(w, GatedEnded{Outcome: outcome, Error: why}) + } + switch { + case rec.State == "refused": + for _, w := range calls { + end(w, OutcomeRefused, "the router refused its ask "+id+": "+orNone(rec.Answer)) + } + return + case rec.State != "chosen": + for _, w := range calls { + end(w, OutcomeExpired, "its ask "+id+" ended "+rec.State+" with no choice; nothing ran") + } + return + case rec.Warrant == nil: + for _, w := range calls { + end(w, OutcomeRefused, "the router's record of its ask "+id+" is chosen and carries no warrant") + } + return + case rec.Warrant.Option == OptionDecline: + for _, w := range calls { + end(w, OutcomeDeclined, "the operator declined ask "+id+"; nothing ran") + } + return + } + first, rest := calls[0], calls[1:] + at, _ := parseTime(rec.Warrant.At) + if g.d.Now().After(at.Add(RunWithin)) { + end(first, OutcomeExpired, "the Approve of ask "+id+" was heard more than 30 minutes after it was given; nothing ran") + } else { + g.runWaiting(first, rec) + } + for _, w := range rest { + g.anew(w) + } +} + +// runWaiting claims and runs one waiting call on its ask's warrant, saying its end. +func (g *Gate) runWaiting(w WaitingCall, rec *Record) { + h := g.handler(w.Seat, w.Verb) + c := Call{Address: w.Address, Module: g.d.Runner, Seat: w.Seat, Name: w.Verb, Caller: w.Caller, Args: w.Arguments} + if h == nil { + g.end(w, GatedEnded{Outcome: OutcomeRefused, Error: "the holder serves no handler of " + w.Address + " now; nothing ran"}) + return + } + out := g.claimRun(g.d.Runner, w.Ask, *rec.Warrant, w.Act, func() (any, error) { return h(w.Caller, w.Arguments) }) + switch out.outcome { + case "": // another instance, or another call of the same ask, took the warrant + return + case OutcomeRefused: + g.end(w, GatedEnded{Outcome: OutcomeRefused, Error: out.why}) + return + } + answered, err := g.ran(c, w.Ask, rec, out.answer, out.err) + if err != nil { + g.end(w, GatedEnded{Outcome: OutcomeRan, Error: err.Error()}) + return + } + a := answered.(Answered) + g.end(w, GatedEnded{Outcome: OutcomeRan, Answer: a.Answer, Approval: a.Approval}) +} + +// anew asks a waiting call's act anew, as a new ask (ADR 0286 §33: a second run takes a second approval); its end comes +// when its own ask ends. +func (g *Gate) anew(w WaitingCall) { + c := Call{Address: w.Address, Module: g.d.Runner, Seat: w.Seat, Name: w.Verb, Caller: w.Caller, Args: w.Arguments, + Fingerprint: w.Act["build"]} + id, err := g.askOf(c, w.Act, w.Digest) + if err != nil { + g.end(w, GatedEnded{Outcome: OutcomeRefused, Error: err.Error()}) + return + } + if err := g.updateCall(w.ID, func(k *WaitingCall) { k.Ask, k.Asked = id, g.d.Now().UTC() }); err != nil { + g.d.Logf("[approval] the call %s, asked anew as ask %s, cannot be kept (%v)", w.ID, id, err) + return + } + w.Ask, w.Asked = id, g.d.Now().UTC() + g.armUnanswered(w) + g.d.Go(func() { g.Decided(id) }) +} + +// end keeps a waiting call's end, then says it on gated-ended (§677); an end not said is said again at the next start. +func (g *Gate) end(w WaitingCall, e GatedEnded) { + e.Call, e.Ask, e.Act, e.Verb = w.ID, w.Ask, w.Digest, w.Verb + ended := false + if err := g.updateCall(w.ID, func(k *WaitingCall) { + if k.End == nil { + k.End, ended = &e, true + } + }); err != nil { + g.d.Logf("[approval] the end %s of call %s cannot be kept (%v)", e.Outcome, w.ID, err) + return + } + if ended { + g.say(w.ID) + } +} + +// say says a kept end on gated-ended and marks it said; its finding gate-unanswered is cleared. +func (g *Gate) say(id string) { + e, err := g.d.State.Get(callKey(id)) + if err != nil || e == nil { + return + } + var w WaitingCall + if json.Unmarshal(e.Value, &w) != nil || w.End == nil || w.Said { + return + } + if g.d.Say == nil { + g.d.Logf("[approval] the end %s of call %s is not said: the holder says no gated-ended", w.End.Outcome, id) + return + } + if err := g.d.Say(GatedEndedSubject(w.Seat, w.Caller, w.ID), *w.End, GatedEndedEvent+"."+w.ID); err != nil { + g.d.Logf("[approval] the end %s of call %s is not said (%v): said again at the next start", w.End.Outcome, id, err) + return + } + g.d.Logf("[approval] %s by %s ended %s (call %s, ask %s)", w.Address, w.Caller, w.End.Outcome, id, w.Ask) + _ = g.updateCall(id, func(k *WaitingCall) { k.Said = true }) + if g.d.Now().Sub(w.Asked) >= AskLasts && g.d.Findings != nil { + _ = g.d.Findings.Clear(nilCtx(), KindGateUnanswered, id, "the call's end, "+w.End.Outcome+", is said") + } +} + +// updateCall changes a kept call by compare-and-set, again when another write came first. +func (g *Gate) updateCall(id string, change func(*WaitingCall)) error { + for { + e, err := g.d.State.Get(callKey(id)) + if err != nil || e == nil { + return fmt.Errorf("the call %s cannot be read: %v", id, err) + } + var w WaitingCall + if err := json.Unmarshal(e.Value, &w); err != nil { + return err + } + change(&w) + if _, err := g.d.State.Update(callKey(id), w, e.Revision); err == nil { + return nil + } else if !isChanged(err) { + return err + } + } +} + +// waiting is every kept call that has not ended and that keep takes, oldest first. +func (g *Gate) waiting(keep func(WaitingCall) bool) ([]WaitingCall, error) { + all, err := g.calls() + if err != nil { + return nil, err + } + var out []WaitingCall + for _, w := range all { + if w.End == nil && keep(w) { + out = append(out, w) + } + } + return out, nil +} + +// calls is every kept call, oldest first. +func (g *Gate) calls() ([]WaitingCall, error) { + names, err := g.d.State.Keys() + if err != nil { + return nil, err + } + var out []WaitingCall + for _, key := range names { + if !strings.HasPrefix(key, "call.") { + continue + } + e, err := g.d.State.Get(key) + if err != nil { + return nil, err + } + var w WaitingCall + if e == nil || json.Unmarshal(e.Value, &w) != nil { + continue + } + out = append(out, w) + } + sort.Slice(out, func(i, j int) bool { return out[i].Made.Before(out[j].Made) }) + return out, nil +} + +// armUnanswered raises the waiting call's gate-unanswered when its ask's 60 minutes pass with no end of it (§677). +func (g *Gate) armUnanswered(w WaitingCall) { + left := max(w.Asked.Add(AskLasts).Sub(g.d.Now()), 0) + at := g.d.After(left) + go func() { // its own goroutine whatever Go is: it waits out the ask's 60 minutes on the clock's pause + <-at + e, err := g.d.State.Get(callKey(w.ID)) + if err != nil || e == nil { + return + } + var now WaitingCall + if json.Unmarshal(e.Value, &now) != nil || now.End != nil || now.Ask != w.Ask || g.d.Findings == nil { + return + } + if err := g.d.Findings.Raise(nilCtx(), KindGateUnanswered, w.ID, fmt.Sprintf("the call %s of %s by %s waits on ask %s, "+ + "past its %s with no decided heard: nothing ran; the holder's %s.approvals reads it", w.ID, w.Address, w.Caller, w.Ask, + AskLasts, w.Seat), "a call waits on an unanswered ask"); err != nil { + g.d.Logf("[approval] the finding %s of call %s is not kept: %v", KindGateUnanswered, w.ID, err) + } + }() +} + +// StartHolding is the holder's read at its start (§677): every waiting call is read, each ask already decided acted on as +// when its decided is heard, each end not yet said said, each waiting call's 60 minutes armed, and every acted, unrecorded +// run recorded. A state that cannot be read leaves every marked call refused until it can. +func (g *Gate) StartHolding() error { + all, err := g.calls() + if err != nil { + g.stateFailed(err) + return fmt.Errorf("the waiting calls in %s cannot be read: %w", StateName, err) + } + g.stateRead() + asks := map[string]bool{} + for _, w := range all { + switch { + case w.End != nil && !w.Said: + g.say(w.ID) + case w.End == nil: + g.armUnanswered(w) + asks[w.Ask] = true + } + } + ids := make([]string, 0, len(asks)) + for id := range asks { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + g.Decided(id) + } + g.Recover() + return nil +} + +// Approvals is the seat's verb `approvals` (§677): the waiting and ended calls with their caller, ask and state, the +// `act.` keys and the claims; a call's arguments only to that call's caller. +type Approvals struct { + Calls []ListedCall `json:"calls"` + Acts []ListedAct `json:"acts"` + Claims []string `json:"claims"` +} + +// ListedCall is one kept call as `approvals` lists it. +type ListedCall struct { + Call string `json:"call"` + Caller string `json:"caller"` + Ask string `json:"ask"` + Verb string `json:"verb"` + State string `json:"state"` + Made time.Time `json:"made"` + End *GatedEnded `json:"end,omitempty"` + Arguments json.RawMessage `json:"arguments,omitempty"` +} + +// ListedAct is one `act.` key: its digest, escaped as kept, and the ask it names. +type ListedAct struct { + Act string `json:"act"` + Ask string `json:"ask"` +} + +// Approvals answers the verb for caller, the account the bus proved. +func (g *Gate) Approvals(caller string) (Approvals, error) { + out := Approvals{Calls: []ListedCall{}, Acts: []ListedAct{}, Claims: []string{}} + if g.d.State == nil { + return out, fmt.Errorf("the holder keeps no state %s", StateName) + } + all, err := g.calls() + if err != nil { + return out, fmt.Errorf("the state %s cannot be read: %w", StateName, err) + } + for _, w := range all { + l := ListedCall{Call: w.ID, Caller: w.Caller, Ask: w.Ask, Verb: w.Verb, State: "waiting", Made: w.Made, End: w.End} + if w.End != nil { + l.State = w.End.Outcome + } + if caller != "" && caller == w.Caller { + l.Arguments = w.Arguments + } + out.Calls = append(out.Calls, l) + } + names, err := g.d.State.Keys() + if err != nil { + return out, fmt.Errorf("the state %s cannot be read: %w", StateName, err) + } + sort.Strings(names) + for _, key := range names { + switch { + case strings.HasPrefix(key, "act."): + e, err := g.d.State.Get(key) + var id string + if err == nil && e != nil { + _ = json.Unmarshal(e.Value, &id) + } + out.Acts = append(out.Acts, ListedAct{Act: strings.TrimPrefix(key, "act."), Ask: id}) + case strings.HasPrefix(key, "claimed."): + out.Claims = append(out.Claims, strings.TrimPrefix(key, "claimed.")) + } + } + return out, nil +} + +func nilCtx() context.Context { return context.Background() } diff --git a/go/approval/held_test.go b/go/approval/held_test.go new file mode 100644 index 00000000..81cd9c81 --- /dev/null +++ b/go/approval/held_test.go @@ -0,0 +1,333 @@ +package approval + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/operatorask" +) + +// memStore is the holder's state approvals in memory: create refuses a key that exists, update is compare-and-set. +type memStore struct { + mu sync.Mutex + vals map[string][]byte + revs map[string]uint64 + rev uint64 + fail error +} + +func newMem() *memStore { return &memStore{vals: map[string][]byte{}, revs: map[string]uint64{}} } + +func (m *memStore) Get(key string) (*stdio.StateEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return nil, m.fail + } + v, ok := m.vals[key] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: v, Revision: m.revs[key]}, nil +} + +func (m *memStore) write(key string, value any, check func(exists bool, rev uint64) bool) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.fail != nil { + return 0, m.fail + } + _, exists := m.vals[key] + if !check(exists, m.revs[key]) { + return 0, stdio.ErrChanged + } + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + m.rev++ + m.vals[key], m.revs[key] = raw, m.rev + return m.rev, nil +} + +func (m *memStore) Create(key string, value any) (uint64, error) { + return m.write(key, value, func(exists bool, _ uint64) bool { return !exists }) +} +func (m *memStore) Update(key string, value any, revision uint64) (uint64, error) { + return m.write(key, value, func(exists bool, rev uint64) bool { return exists && rev == revision }) +} +func (m *memStore) Put(key string, value any) (uint64, error) { + return m.write(key, value, func(bool, uint64) bool { return true }) +} +func (m *memStore) 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 +} + +// router is the operator channel as a fake: it keeps each ask the gate raises, and the test decides its record. +type router struct { + mu sync.Mutex + state Store + asks []Ask + records map[string]*Record +} + +func (r *router) Ask(module string, a Ask) error { + raw, _ := json.Marshal(a) + if _, err := r.state.Create("ask."+a.ID, kept{Ask: raw, Asked: time.Now()}); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + r.asks = append(r.asks, a) + return nil +} +func (r *router) Record(module, id string) (*Record, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.records[id], nil +} +func (r *router) Claim(module string, w Warrant, act operatorask.Act, run func()) (Claim, error) { + e, _ := r.state.Get("ask." + w.Ask) + if e == nil { + return ClaimRefused, fmt.Errorf("no ask %s", w.Ask) + } + var k kept + _ = json.Unmarshal(e.Value, &k) + if k.Acted { + return ClaimDone, nil + } + var a Ask + _ = json.Unmarshal(k.Ask, &a) + digest, _ := operatorask.ActDigest(act) + if a.About != digest { + return ClaimRefused, fmt.Errorf("the option binds another act") + } + k.Acted = true + if _, err := r.state.Update("ask."+w.Ask, k, e.Revision); err != nil { + return ClaimDone, nil + } + run() + return Claimed, nil +} +func (r *router) Cancel(module, id string) error { return nil } +func (r *router) Decided(module string) string { return "decided." + module } +func (r *router) Seat() string { return "operator-channel" } + +func (r *router) choose(id, option string, at time.Time) { + r.mu.Lock() + defer r.mu.Unlock() + r.records[id] = &Record{State: "chosen", OfferedOn: []string{"telegram"}, + Warrant: &Warrant{Ask: id, Asker: "holder", Option: option, Channel: "telegram", At: formatTime(at)}} +} + +type findings struct { + mu sync.Mutex + raised map[string]bool + heard chan string +} + +func (f *findings) Raise(_ context.Context, kind, id, _, _ string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.raised[kind+" "+id] = true + if f.heard != nil { + f.heard <- kind + " " + id + } + return nil +} +func (f *findings) Clear(_ context.Context, kind, id, _ string) error { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.raised, kind+" "+id) + return nil +} + +// Design 47 G §677 (lead answers A-158, A-209, A-244): a process holder's marked verb is one call with a gate before its +// running, its waiting calls, act keys and claims kept in its own state. +func TestAHolderHoldsAMarkedCall(t *testing.T) { + now := time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC) + type world struct { + g *Gate + state *memStore + router *router + said map[string]GatedEnded + ran *int + recs *int + finds *findings + after chan time.Time + } + newGate := func(state *memStore, r *router, ran, recs *int, said map[string]GatedEnded, after chan time.Time, f *findings) *Gate { + g := New(Deps{Node: "n1", Runner: "holder", Holding: true, Build: "fp1", State: state, Channel: r, Findings: f, + Now: func() time.Time { return now }, Go: func(f func()) { f() }, + After: func(d time.Duration) <-chan time.Time { + if d < time.Minute { // the record's short pauses pass at once + fired := make(chan time.Time) + close(fired) + return fired + } + return after + }, + Warranted: func(asker, ask string, open func() (bool, error)) error { + *recs++ + return nil + }, + Say: func(subject string, body any, id string) error { + said[subject] = body.(GatedEnded) + return nil + }}) + g.Serves("wiper", "wipe", func(caller string, args json.RawMessage) (any, error) { *ran++; return "wiped", nil }) + return g + } + setup := func() world { + state := newMem() + r := &router{state: state, records: map[string]*Record{}} + ran, recs := 0, 0 + w := world{state: state, router: r, said: map[string]GatedEnded{}, ran: &ran, recs: &recs, + finds: &findings{raised: map[string]bool{}}, after: make(chan time.Time)} + w.g = newGate(state, r, w.ran, w.recs, w.said, w.after, w.finds) + return w + } + call := func(caller, args string) Call { + return Call{Address: "wiper.wipe", Module: "holder", Seat: "wiper", Name: "wipe", Caller: caller, Args: json.RawMessage(args)} + } + hold := func(t *testing.T, w world, c Call, wait bool) (any, *Held, error) { + return w.g.Hold(c, wait, func() (any, error) { *w.ran++; return "wiped", nil }) + } + ended := func(w world, caller, id string) GatedEnded { return w.said[GatedEndedSubject("wiper", caller, id)] } + + t.Run("asked, run once on the Approve, its end said to its caller", func(t *testing.T) { + w := setup() + _, h, err := hold(t, w, call("n1.caller", `{"dir":"scratch"}`), true) + if err != nil || h == nil || h.Asked == "" || h.Call == "" { + t.Fatalf("held %+v, %v", h, err) + } + if e, _ := w.state.Get("call." + h.Call); e == nil { + t.Fatal("no call kept") + } + w.router.choose(h.Asked, OptionApprove, now) + w.g.Decided(h.Asked) + w.g.Decided(h.Asked) // heard twice: run once + end := ended(w, "n1.caller", h.Call) + if *w.ran != 1 || end.Outcome != OutcomeRan || end.Answer != "wiped" || !strings.Contains(end.Approval, h.Asked) || *w.recs != 1 { + t.Fatalf("ran %d, recorded %d, end %+v", *w.ran, *w.recs, end) + } + if e, _ := w.state.Get("claimed." + h.Asked); e == nil { + t.Error("no claim kept") + } + a, _ := w.g.Approvals("someone.else") + if len(a.Calls) != 1 || a.Calls[0].Arguments != nil || a.Calls[0].State != OutcomeRan || len(a.Acts) != 1 || len(a.Claims) != 1 { + t.Errorf("approvals %+v", a) + } + if a, _ := w.g.Approvals("n1.caller"); string(a.Calls[0].Arguments) != `{"dir":"scratch"}` { + t.Errorf("its caller reads %s", a.Calls[0].Arguments) + } + }) + t.Run("a second call of the act takes the same ask, asked anew after the first runs", func(t *testing.T) { + w := setup() + _, h1, _ := hold(t, w, call("n1.a", `{}`), true) + now = now.Add(time.Second) + _, h2, _ := hold(t, w, call("n1.b", `{}`), true) + if h1.Asked != h2.Asked || h1.Call == h2.Call { + t.Fatalf("asks %s and %s", h1.Asked, h2.Asked) + } + w.router.choose(h1.Asked, OptionApprove, now) + w.g.Decided(h1.Asked) + if *w.ran != 1 || ended(w, "n1.a", h1.Call).Outcome != OutcomeRan || ended(w, "n1.b", h2.Call).Outcome != "" { + t.Fatalf("ran %d; ends %v", *w.ran, w.said) + } + e, _ := w.state.Get("call." + h2.Call) + var second WaitingCall + _ = json.Unmarshal(e.Value, &second) + if second.Ask == h1.Asked || len(w.router.asks) != 2 { + t.Errorf("the second call waits on %s (asks %d)", second.Ask, len(w.router.asks)) + } + }) + t.Run("a decline runs nothing; a call within 10 minutes ends declined unasked", func(t *testing.T) { + w := setup() + _, h, _ := hold(t, w, call("n1.a", `{}`), true) + w.router.choose(h.Asked, OptionDecline, now) + w.g.Decided(h.Asked) + if *w.ran != 0 || ended(w, "n1.a", h.Call).Outcome != OutcomeDeclined { + t.Fatalf("ran %d, end %+v", *w.ran, ended(w, "n1.a", h.Call)) + } + if _, h2, err := hold(t, w, call("n1.a", `{}`), true); err == nil || h2 != nil || !strings.Contains(err.Error(), "declined") || len(w.router.asks) != 1 { + t.Errorf("a call within 10 minutes: %v, %d asks", err, len(w.router.asks)) + } + }) + t.Run("heard 31 minutes after the choice, it ends expired", func(t *testing.T) { + w := setup() + _, h, _ := hold(t, w, call("n1.a", `{}`), true) + w.router.choose(h.Asked, OptionApprove, now.Add(-31*time.Minute)) + w.g.Decided(h.Asked) + if *w.ran != 0 || ended(w, "n1.a", h.Call).Outcome != OutcomeExpired { + t.Fatalf("ran %d, end %+v", *w.ran, ended(w, "n1.a", h.Call)) + } + }) + t.Run("a secret-shaped argument refused, nothing kept", func(t *testing.T) { + w := setup() + if _, h, err := hold(t, w, call("n1.a", `{"token":"ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"}`), true); err == nil || h != nil { + t.Fatalf("held %+v, %v", h, err) + } + if keys, _ := w.state.Keys(); len(keys) != 0 || len(w.router.asks) != 0 { + t.Errorf("kept %v, asked %d", keys, len(w.router.asks)) + } + }) + t.Run("a call with no wait refused with the ask, and run when made again after the Approve", func(t *testing.T) { + w := setup() + _, h, err := hold(t, w, call("n1.a", `{}`), false) + if err == nil || h != nil || len(w.router.asks) != 1 || !strings.Contains(err.Error(), w.router.asks[0].ID) { + t.Fatalf("first: %v", err) + } + w.router.choose(w.router.asks[0].ID, OptionApprove, now) + answer, _, err := hold(t, w, call("n1.a", `{}`), false) + if a, ok := answer.(Answered); err != nil || !ok || a.Answer != "wiped" || *w.ran != 1 { + t.Fatalf("again: %+v, %v", answer, err) + } + }) + t.Run("a restart between the ask and the Approve runs the waiting call once", func(t *testing.T) { + w := setup() + _, h, _ := hold(t, w, call("n1.a", `{}`), true) + w.router.choose(h.Asked, OptionApprove, now) + again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds) + if err := again.StartHolding(); err != nil || *w.ran != 1 || ended(w, "n1.a", h.Call).Outcome != OutcomeRan { + t.Fatalf("start: %v, ran %d", err, *w.ran) + } + _ = again.StartHolding() + if *w.ran != 1 { + t.Errorf("ran %d after a second start", *w.ran) + } + }) + t.Run("past its ask's 60 minutes with no decided, gate-unanswered", func(t *testing.T) { + w := setup() + w.finds.heard = make(chan string, 1) + _, h, _ := hold(t, w, call("n1.a", `{}`), true) + w.after <- now + if got := <-w.finds.heard; got != KindGateUnanswered+" "+h.Call { + t.Errorf("raised %s", got) + } + }) + t.Run("a state that cannot be read refuses every marked call", func(t *testing.T) { + w := setup() + w.state.fail = fmt.Errorf("the bus is away") + if _, h, err := hold(t, w, call("n1.a", `{}`), true); err == nil || h != nil || *w.ran != 0 { + t.Fatalf("held %+v, %v", h, err) + } + }) +} diff --git a/go/approval/record.go b/go/approval/record.go index 22c1af32..97dc8a44 100644 --- a/go/approval/record.go +++ b/go/approval/record.go @@ -171,7 +171,7 @@ func (g *Gate) Recover() { g.d.Logf("[approval] the acted, unrecorded asks cannot be read (%v): read again at the next start or holder heard", err) return } - prefix := g.d.Node + ".ask." + prefix := g.prefix() + "ask." for _, key := range keys { if !strings.HasPrefix(key, prefix) { continue diff --git a/go/process/call.go b/go/process/call.go index 791af6e1..b1ad9e9e 100644 --- a/go/process/call.go +++ b/go/process/call.go @@ -10,6 +10,7 @@ import ( "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/busretry" "git.novox.be/novox/mesh-sdk/go/calladdress" ) @@ -188,7 +189,13 @@ func callSubjectsOn(key, node, token string) (called, fallback string, err error func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) { ctx, cancel := context.WithTimeout(context.Background(), CallTimeout) defer cancel() - msg, err := c.nc.RequestWithContext(ctx, subject, data) + req := nats.NewMsg(subject) + req.Data = data + if strings.HasPrefix(subject, "mesh.seat.") { + // Every seat call says it waits for a held call's end; a verb that is not marked ignores it (lead answer A-244). + req.Header.Set(approval.WaitHeader, approval.WaitHeld) + } + msg, err := c.nc.RequestMsgWithContext(ctx, req) if err != nil { return nil, err } @@ -196,6 +203,7 @@ func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) { Result json.RawMessage `json:"result"` Error string `json:"error"` Node string `json:"node"` + Held *approval.Held `json:"held"` } if err := json.Unmarshal(msg.Data, &r); err != nil { return nil, fmt.Errorf("%s answered something that is not a seat's answer: %w", subject, err) @@ -203,5 +211,8 @@ func (c *Conn) request(subject string, data []byte) (json.RawMessage, error) { if r.Error != "" { return nil, answered(r.Error) } + if r.Held != nil { + return c.awaitGated(subject, *r.Held) + } return r.Result, nil } diff --git a/go/process/call_test.go b/go/process/call_test.go index 3ea2413a..188df54d 100644 --- a/go/process/call_test.go +++ b/go/process/call_test.go @@ -175,10 +175,13 @@ func TestUserOfTokenAndAnswerSeatRefusals(t *testing.T) { {"a verb open to none", "novox.mesh-recovery", func() (seatdef.Definition, bool) { return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v", Callers: &seatdef.Callers{List: []string{}}}}}, true }, "open to no caller"}, + {"a marked verb with no gate (§677)", "novox.mesh-recovery", func() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: "seat", Verbs: []seatdef.Verb{{Name: "v", NeedsApproval: true}}}, true + }, "through no gate"}, {"a proven caller", "novox.mesh-recovery", defined, ""}, } { before := ran - _, err := admitted("seat", "v", row.def, run)(row.caller, nil) + _, _, err := c.admitted("seat", "v", row.def, run, nil)(row.caller, nil, false) if row.says == "" && (err != nil || ran != before+1) || row.says != "" && (err == nil || !strings.Contains(err.Error(), row.says) || ran != before) { t.Errorf("%s: %v, ran %d times", row.name, err, ran-before) } diff --git a/go/process/gate.go b/go/process/gate.go new file mode 100644 index 00000000..f4c372fb --- /dev/null +++ b/go/process/gate.go @@ -0,0 +1,129 @@ +package process + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/nats-io/nats.go/jetstream" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/busretry" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Gating is what a holder's gate reaches beyond this connection (design 47 G §677; lead answers A-219, A-224, A-228): the +// operator channel (mesh-contracts' operatorchannelclient.GateChannel over this connection, reading the router's record +// under the module's own name, §700) and the holder's held finding writer. +type Gating struct { + Channel approval.Channel + Findings approval.FindingWriter + // Build is the holder's own fingerprint; empty reads it from the process's environment (OwnBuild). + Build string + Logf func(string, ...any) +} + +// Gate is the holder's gate over this connection (§677): its state `approvals`, its calls of the seat `mesh-hand-acts`'s +// `warranted` to record a run, and its seat's `gated-ended`, each on the process's own account. AnswerSeat holds the +// seat's marked verbs through it (Gated); the process hears its decided with HearDecided and reads it at its start with +// the gate's StartHolding. +func (c *Conn) Gate(g Gating) *approval.Gate { + build := g.Build + if build == "" { + build = OwnBuild().Fingerprint + } + return approval.New(approval.Deps{Node: c.account.Node, Runner: c.account.Module, Holding: true, Build: build, + State: c.State(approval.StateName), Channel: g.Channel, Findings: g.Findings, Logf: g.Logf, + Warranted: func(asker, ask string, open func() (bool, error)) error { + _, err := c.CallKeptWhileOpen(busretry.Open{Key: ask, Open: func(string) (bool, error) { return open() }, Logf: g.Logf}, + "seat:mesh-hand-acts.warranted", map[string]string{"asker": asker, "ask": ask}) + return err + }, + Say: func(subject string, body any, id string) error { + return c.emitOn(subject, body, []stdio.EmitOptions{{MessageID: id}}) + }}) +} + +// HearDecided hands the gate each `decided` of the holder's own asks that hub hears (§677): only the ask's id is read +// from it, the gate reading the router's own record. +func HearDecided(g *approval.Gate, ch approval.Channel, module string, hub *EventHub) error { + return hub.Handle(ch.Decided(module), func(m jetstream.Msg) { + var w struct { + Ask string `json:"ask"` + } + if err := json.Unmarshal(m.Data(), &w); err != nil || w.Ask == "" { + _ = m.Term() + return + } + g.Decided(w.Ask) + _ = m.Ack() + }) +} + +// HeldWithin bounds a caller's wait on a held call's end: the ask's 60 minutes and the 30 a run may come after its choice. +var HeldWithin = approval.AskLasts + approval.RunWithin + +// awaitGated waits on the held call's own end on its seat's gated-ended, from the last said on its subject (so an end said +// before the wait began is read), and is its answer: `{answer, approval}` when it ran (lead answer A-158), its words as +// the call's error otherwise. Every failure is an answer, never asked again: the call reached its holder (ADR 0668 §2). +func (c *Conn) awaitGated(subject string, held approval.Held) (json.RawMessage, error) { + seat, ok := seatOfSubject(subject) + if !ok { + return nil, answered(fmt.Sprintf("%s answered held, and is no seat's subject", subject)) + } + js, err := jetstream.New(c.nc) + if err != nil { + return nil, answered(err.Error()) + } + on := approval.GatedEndedSubject(seat, c.account.User, held.Call) + stream := seatdef.KeptStream(seat, seatdef.Message{Name: approval.GatedEndedEvent}) + ctx, cancel := context.WithTimeout(context.Background(), HeldWithin) + defer cancel() + cons, err := js.OrderedConsumer(ctx, stream, jetstream.OrderedConsumerConfig{FilterSubjects: []string{on}, + DeliverPolicy: jetstream.DeliverLastPerSubjectPolicy}) + if err != nil { + return nil, answered(fmt.Sprintf("the call %s waits on ask %s, and its end on %s cannot be heard: %v; %s.approvals reads it", + held.Call, held.Asked, stream, err, seat)) + } + for { + m, err := cons.Next(jetstream.FetchMaxWait(time.Minute)) + if err == nil { + _ = m.Ack() + return endOf(m.Data()) + } + if ctx.Err() != nil || !errors.Is(err, jetstream.ErrNoMessages) && !errors.Is(err, context.DeadlineExceeded) { + return nil, answered(fmt.Sprintf("the call %s waits on ask %s, and no end of it was heard (%v); %s.approvals reads it", + held.Call, held.Asked, err, seat)) + } + } +} + +// endOf is a gated-ended's body as the call's answer. +func endOf(data []byte) (json.RawMessage, error) { + var e approval.GatedEnded + if err := json.Unmarshal(data, &e); err != nil { + return nil, answered("the end of the call is not read: " + err.Error()) + } + if e.Outcome != approval.OutcomeRan || e.Error != "" { + words := e.Error + if words == "" { + words = "the call ended " + e.Outcome + } + return nil, answered(fmt.Sprintf("Not run: %s (ask %s, call %s)", words, e.Ask, e.Call)) + } + return json.Marshal(approval.Answered{Answer: e.Answer, Approval: e.Approval}) +} + +// seatOfSubject is the seat a call subject `mesh.seat..…` names. +func seatOfSubject(subject string) (string, bool) { + rest, ok := strings.CutPrefix(subject, "mesh.seat.") + if !ok { + return "", false + } + seat, _, ok := strings.Cut(rest, ".") + return seat, ok && seat != "" +} diff --git a/go/process/serve.go b/go/process/serve.go index 6fb217db..03be2886 100644 --- a/go/process/serve.go +++ b/go/process/serve.go @@ -9,6 +9,7 @@ import ( "github.com/nats-io/nats.go" + "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/calladdress" "git.novox.be/novox/mesh-sdk/go/seatdef" ) @@ -42,13 +43,31 @@ func userOfToken(token string) string { // while it is not read. An unproven caller is refused, and so is every caller while the definition is unread, names no // such verb or opens it to none; a proven caller is among them, since the grants part composes a verb's call subject // only for its effective callers. -func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bool), verbs map[string]SeatHandler) (stop func(), err error) { +// +// A verb the definition marks `needs-approval` is held through the gate Gated gives (design 47 G §677; lead answers A-224, +// A-244): a call that carries the header approval.WaitHeader is kept and answered at once with the reply's field `held`, +// its end said on the seat's `gated-ended`; one that does not takes ADR 0286 §37's path. With no gate a marked verb is +// refused to every caller. With a gate the seat's verb `approvals` is served too, unless verbs answers it. +func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bool), verbs map[string]SeatHandler, opts ...SeatOption) (stop func(), err error) { + var o seatOptions + for _, f := range opts { + f(&o) + } if definition == nil { return nil, fmt.Errorf("AnswerSeat(%q) is given no definition of the seat to read its verbs' callers from", seat) } if len(verbs) == 0 { return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat) } + if o.gate != nil && verbs["approvals"] == nil { + all := make(map[string]SeatHandler, len(verbs)+1) + for v, h := range verbs { + all[v] = h + } + gate := o.gate + all["approvals"] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) } + verbs = all + } names := make([]string, 0, len(verbs)) for verb, h := range verbs { if h == nil { @@ -68,7 +87,11 @@ func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bo } } for _, verb := range names { - h := admitted(seat, verb, definition, verbs[verb]) + if o.gate != nil { + h := verbs[verb] + o.gate.Serves(seat, verb, func(caller string, args json.RawMessage) (any, error) { return h(caller, args) }) + } + h := c.admitted(seat, verb, definition, verbs[verb], o.gate) base := "mesh.seat." + seat for _, subject := range []string{base + ".call." + verb + ".*", base + ".tool." + verb} { subject := subject @@ -87,16 +110,28 @@ func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bo return undo, nil } -// admitted is h behind the check of its caller against the verb's effective callers (A-179, A-186). -func admitted(seat, verb string, definition func() (seatdef.Definition, bool), h SeatHandler) SeatHandler { - return func(caller string, body json.RawMessage) (any, error) { +// SeatOption is an option of AnswerSeat. +type SeatOption func(*seatOptions) + +type seatOptions struct{ gate *approval.Gate } + +// Gated holds the seat's marked verbs through g, the holder's gate (design 47 G §677): Conn.Gate makes it. +func Gated(g *approval.Gate) SeatOption { return func(o *seatOptions) { o.gate = g } } + +// heldHandler is a verb's handler behind its checks: its answer, the held answer of a kept call, or its error. +type heldHandler func(caller string, body json.RawMessage, wait bool) (any, *approval.Held, error) + +// admitted is h behind the check of its caller against the verb's effective callers (A-179, A-186), and, for a verb the +// definition marks, behind the gate (§677). +func (c *Conn) admitted(seat, verb string, definition func() (seatdef.Definition, bool), h SeatHandler, gate *approval.Gate) heldHandler { + return func(caller string, body json.RawMessage, wait bool) (any, *approval.Held, error) { if caller == "" { - return nil, fmt.Errorf("%s.%s takes no unproven caller: call it on its subject that names its caller, "+ + return nil, nil, fmt.Errorf("%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, read := definition() if !read { - return nil, fmt.Errorf("the definition of the seat %s is not yet read by this process's seat list, so no caller of %s.%s "+ + return nil, nil, fmt.Errorf("the definition of the seat %s is not yet read by this process's seat list, so no caller of %s.%s "+ "is admitted", seat, seat, verb) } for _, v := range def.Verbs { @@ -104,16 +139,24 @@ func admitted(seat, verb string, definition func() (seatdef.Definition, bool), h continue } if len(v.EffectiveCallers()) == 0 { - return nil, fmt.Errorf("%s.%s is open to no caller", seat, verb) + return nil, nil, fmt.Errorf("%s.%s is open to no caller", seat, verb) } - return h(caller, body) + if !v.NeedsApproval { + answer, err := h(caller, body) + return answer, nil, err + } + if gate == nil { + return nil, nil, fmt.Errorf("Not run: %s.%s needs the operator's approval, and its holder serves it through no gate", seat, verb) + } + return gate.Hold(approval.Call{Address: seat + "." + verb, Module: c.account.Module, Seat: seat, Name: verb, + Caller: caller, Args: body, Description: v.Description}, wait, func() (any, error) { return h(caller, body) }) } - return nil, fmt.Errorf("the seat %s's definition names no verb %s, so no caller is admitted", seat, verb) + return nil, nil, fmt.Errorf("the seat %s's definition names no verb %s, so no caller is admitted", seat, verb) } } // answerCall runs h for one call and replies {result, error, node}; a panic in h is the call's error, never silence. -func (c *Conn) answerCall(msg *nats.Msg, pattern string, h SeatHandler) { +func (c *Conn) answerCall(msg *nats.Msg, pattern string, h heldHandler) { caller := "" if strings.HasSuffix(pattern, ".*") { caller = userOfToken(msg.Subject[strings.LastIndex(msg.Subject, ".")+1:]) @@ -122,17 +165,22 @@ func (c *Conn) answerCall(msg *nats.Msg, pattern string, h SeatHandler) { Result any `json:"result,omitempty"` Error string `json:"error,omitempty"` Node string `json:"node,omitempty"` + // Held is a kept call's answer (design 47 G §677; lead answer A-244). + Held *approval.Held `json:"held,omitempty"` } + wait := msg.Header != nil && msg.Header.Get(approval.WaitHeader) == approval.WaitHeld func() { defer func() { if r := recover(); r != nil { reply.Error = fmt.Sprintf("%s failed: %v", msg.Subject, r) } }() - result, err := h(caller, json.RawMessage(msg.Data)) + result, held, err := h(caller, json.RawMessage(msg.Data), wait) switch { case err != nil: reply.Error = err.Error() + case held != nil: + reply.Held = held case result == nil: reply.Result = json.RawMessage("null") default: From 89359111a93248dc6b2c1da349ad0009b83ccbb8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:31:08 +0100 Subject: [PATCH 10/12] A3f item 2: one whole test of a process holder's marked verb on the bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Against an embedded bus, a module's process calls a holder's marked verb through process.Conn's call: asked, approved, run once, recorded through mesh-hand-acts.warranted and its answer {answer, approval} heard on its own gated-ended; a declined call runs nothing; a marked verb served through no gate, or a gate whose state approvals is not on the bus, fails closed. Builds: design 47 G §677, §700, lead answers A-158, A-224, A-244 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/process/gated_integration_test.go | 261 +++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 go/process/gated_integration_test.go diff --git a/go/process/gated_integration_test.go b/go/process/gated_integration_test.go new file mode 100644 index 00000000..5ddad2dc --- /dev/null +++ b/go/process/gated_integration_test.go @@ -0,0 +1,261 @@ +//go:build integration + +package process + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/approval" + "git.novox.be/novox/mesh-sdk/go/operatorask" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// busChannel is the operator channel as the test's router: the gate's asks kept in the holder's state approvals, the +// router's record read under the holder's name from messenger_asks (design 47 G §700), the claim as +// operatorchannelclient's Take makes it. asked says each ask raised. +type busChannel struct { + conn *Conn + state State + asked chan approval.Ask +} + +func (b *busChannel) Ask(module string, a approval.Ask) error { + raw, _ := json.Marshal(a) + if _, err := b.state.Create("ask."+a.ID, approval.Kept[json.RawMessage, json.RawMessage]{Ask: raw, Asked: time.Now()}); err != nil { + return err + } + b.asked <- a + return nil +} + +func (b *busChannel) Record(module, id string) (*approval.Record, error) { + e, err := b.conn.Ask("messenger_asks", id) + if err != nil || e == nil { + return nil, err + } + var r approval.Record + return &r, json.Unmarshal(e.Value, &r) +} + +func (b *busChannel) Claim(module string, w approval.Warrant, act operatorask.Act, run func()) (approval.Claim, error) { + e, err := b.state.Get("ask." + w.Ask) + if err != nil || e == nil { + return approval.ClaimRefused, fmt.Errorf("no ask %s: %v", w.Ask, err) + } + var k approval.Kept[json.RawMessage, json.RawMessage] + _ = json.Unmarshal(e.Value, &k) + if k.Acted { + return approval.ClaimDone, nil + } + var a approval.Ask + _ = json.Unmarshal(k.Ask, &a) + if d, _ := operatorask.ActDigest(act); d != a.About { + return approval.ClaimRefused, fmt.Errorf("the option binds another act") + } + k.Acted = true + if _, err := b.state.Update("ask."+w.Ask, k, e.Revision); err != nil { + return approval.ClaimDone, nil + } + run() + return approval.Claimed, nil +} + +func (b *busChannel) Cancel(string, string) error { return nil } +func (b *busChannel) Decided(module string) string { + return "mesh.seat.operator-channel.event.decided." + module +} +func (b *busChannel) Seat() string { return "operator-channel" } + +// Design 47 G §677 (lead answers A-158, A-224, A-244), its one whole test: a process holder's marked verb, called by a +// module's process through process.Conn's call, is asked, approved, run once, recorded in the hand-act log and its +// answer heard on the caller's own gated-ended; a declined call runs nothing; a marked verb served through no gate, or +// a gate whose state approvals is not there, fails closed. +func TestAProcessHoldersMarkedVerbIsAskedApprovedRunAndRecorded(t *testing.T) { + opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true, + Users: []*server.User{{Username: adminUser, Password: adminPass}, {Username: "n1.holder", Password: "pw"}, + {Username: "n1.caller", Password: "pw"}, {Username: "n1.mesh-hand-acts", Password: "pw"}, {Username: "n1.other", Password: "pw"}}} + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the test bus did not come up") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + url := s.ClientURL() + _, js := adminJS(t, url) + ctx := ctxFor(t) + records, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "messenger_asks"}) + if err != nil { + t.Fatal(err) + } + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "holder_" + approval.StateName, TTL: 48 * time.Hour}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: EventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.operator-channel.event.>"}}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateOrUpdateConsumer(ctx, EventsStream, jetstream.ConsumerConfig{Durable: "n1_holder", AckPolicy: jetstream.AckExplicitPolicy, + FilterSubjects: []string{"mesh.seat.operator-channel.event.decided.holder"}}); err != nil { + t.Fatal(err) + } + if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: seatdef.KeptStream("wiper", seatdef.Message{Name: approval.GatedEndedEvent}), + Subjects: []string{"mesh.seat.wiper.event.gated-ended.>"}, MaxMsgsPerSubject: 1, MaxAge: approval.GatedEndedKeptFor}); err != nil { + t.Fatal(err) + } + connect := func(module string) *Conn { + c, err := Connect(Account{URL: url, Node: "n1", Module: module, User: "n1." + module, Password: "pw"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(c.Close) + return c + } + + // The seat mesh-hand-acts' holder, recording each warranted run. + var mu sync.Mutex + var warranted []string + handActs := connect("mesh-hand-acts") + if _, err := handActs.AnswerSeat("mesh-hand-acts", func() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: "mesh-hand-acts", Verbs: []seatdef.Verb{{Name: "warranted"}}}, true + }, map[string]SeatHandler{"warranted": func(caller string, body json.RawMessage) (any, error) { + mu.Lock() + defer mu.Unlock() + warranted = append(warranted, caller+" "+string(body)) + return map[string]bool{"recorded": true}, nil + }}); err != nil { + t.Fatal(err) + } + + // The holder: its marked verb wipe behind its gate, its decided heard through its hub, its start read. + holder := connect("holder") + ch := &busChannel{conn: holder, state: holder.State(approval.StateName), asked: make(chan approval.Ask, 4)} + gate := holder.Gate(Gating{Channel: ch, Build: "fp1", Logf: t.Logf}) + ran := 0 + marked := func(seat string) func() (seatdef.Definition, bool) { + return func() (seatdef.Definition, bool) { + return seatdef.Definition{Seat: seat, Verbs: []seatdef.Verb{{Name: "wipe", NeedsApproval: true}, {Name: "approvals"}}}, true + } + } + wipe := map[string]SeatHandler{"wipe": func(caller string, body json.RawMessage) (any, error) { + mu.Lock() + defer mu.Unlock() + ran++ + return "wiped", nil + }} + if _, err := holder.AnswerSeat("wiper", marked("wiper"), wipe, Gated(gate)); err != nil { + t.Fatal(err) + } + hub := NewEventHub(t.Logf) + if err := HearDecided(gate, ch, "holder", hub); err != nil { + t.Fatal(err) + } + hjs, _ := jetstream.New(holder.nc) + if err := hub.Start(ctx, hjs, holder.account); err != nil { + t.Fatal(err) + } + t.Cleanup(hub.Stop) + if err := gate.StartHolding(); err != nil { + t.Fatal(err) + } + + caller := connect("caller") + type result struct { + answer json.RawMessage + err error + } + callWipe := func(args map[string]string) chan result { + done := make(chan result, 1) + go func() { + a, err := caller.Call("seat:wiper.wipe", args) + done <- result{a, err} + }() + return done + } + decide := func(a approval.Ask, option string) { + raw, _ := json.Marshal(approval.Record{State: "chosen", OfferedOn: []string{"telegram"}, + Warrant: &approval.Warrant{Ask: a.ID, Asker: "holder", Option: option, Channel: "telegram", At: time.Now().UTC().Format(time.RFC3339Nano)}}) + if _, err := records.Put(ctx, "holder."+a.ID, raw); err != nil { + t.Fatal(err) + } + // The decided's body is not read beyond the ask's id: the gate reads the router's record. + if _, err := js.Publish(ctx, ch.Decided("holder"), []byte(`{"ask":"`+a.ID+`","option":"approve"}`)); err != nil { + t.Fatal(err) + } + } + wait := func(done chan result) result { + select { + case r := <-done: + return r + case <-time.After(10 * time.Second): + t.Fatal("the call's end was not heard within 10s") + return result{} + } + } + asked := func() approval.Ask { + select { + case a := <-ch.asked: + return a + case <-time.After(10 * time.Second): + t.Fatal("no ask raised within 10s") + return approval.Ask{} + } + } + + t.Run("asked, approved, run once, recorded, heard on the caller's gated-ended", func(t *testing.T) { + done := callWipe(map[string]string{"dir": "scratch"}) + a := asked() + decide(a, approval.OptionApprove) + r := wait(done) + var got approval.Answered + if r.err != nil || json.Unmarshal(r.answer, &got) != nil || got.Answer != "wiped" || !strings.Contains(got.Approval, a.ID) { + t.Fatalf("answer %s, %v", r.answer, r.err) + } + mu.Lock() + defer mu.Unlock() + if ran != 1 || len(warranted) != 1 || !strings.Contains(warranted[0], a.ID) || !strings.HasPrefix(warranted[0], "n1.holder ") { + t.Errorf("ran %d, warranted %v", ran, warranted) + } + }) + t.Run("declined, nothing runs", func(t *testing.T) { + done := callWipe(map[string]string{"dir": "other"}) + decide(asked(), approval.OptionDecline) + if r := wait(done); r.err == nil || !strings.Contains(r.err.Error(), "declined") { + t.Fatalf("answer %s, %v", r.answer, r.err) + } + mu.Lock() + defer mu.Unlock() + if ran != 1 { + t.Errorf("ran %d", ran) + } + }) + t.Run("failing closed: no gate, or no state approvals", func(t *testing.T) { + if _, err := holder.AnswerSeat("bare", marked("bare"), wipe); err != nil { + t.Fatal(err) + } + other := connect("other") // its state approvals is not on the bus + g := other.Gate(Gating{Channel: &busChannel{conn: other, state: other.State(approval.StateName), asked: make(chan approval.Ask, 1)}, Build: "fp1"}) + if _, err := other.AnswerSeat("keeper", marked("keeper"), wipe, Gated(g)); err != nil { + t.Fatal(err) + } + for _, seat := range []string{"bare", "keeper"} { + if _, err := caller.Call("seat:"+seat+".wipe", map[string]string{"dir": "x"}); err == nil || !strings.Contains(err.Error(), "Not run") { + t.Errorf("%s: %v", seat, err) + } + } + mu.Lock() + defer mu.Unlock() + if ran != 1 { + t.Errorf("ran %d", ran) + } + }) +} From f8062e268039cfe5ece97125933af222bea69614 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:42:24 +0100 Subject: [PATCH 11/12] A3g item 1: a process holder's gate-unanswered rests on a bound key, and its act names no node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A waiting call's ask's 60 minutes are a key with its own time to live in the holder's state approvals, its end heard as the bus's marker, so no clock waits in the process; a call whose key ended while the holder was away is judged at its start. A process holder's act names no node, so two instances raise one ask. Builds: design 47 G §677, lead answer A-255 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/approval/act.go | 7 +- go/approval/closed.go | 4 +- go/approval/gate.go | 3 +- go/approval/held.go | 89 ++++++++++++++----- go/approval/held_test.go | 128 ++++++++++++++++++++++++--- go/approval/record.go | 2 +- go/process/gate.go | 42 ++++++++- go/process/gated_integration_test.go | 52 ++++++++++- 8 files changed, 286 insertions(+), 41 deletions(-) diff --git a/go/approval/act.go b/go/approval/act.go index 2d61d27e..d2e1275e 100644 --- a/go/approval/act.go +++ b/go/approval/act.go @@ -59,10 +59,13 @@ func refusedUnasked(raw json.RawMessage) string { return "" } -// ActOf is the act a marked call binds (§8): `address`, `node`, `module` and `tool` or `seat` and `verb`, `build`, and +// ActOf is the act a marked call binds (§8): `address`, `node` (none given for a process holder, lead answer A-255), `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) (operatorask.Act, error) { - act := operatorask.Act{"address": c.Address, "node": node, "build": c.Fingerprint} + act := operatorask.Act{"address": c.Address, "build": c.Fingerprint} + if node != "" { // a process holder's act names none (lead answer A-255) + act["node"] = node + } if c.Seat == "" { act["module"], act["tool"] = c.Module, c.Name } else { diff --git a/go/approval/closed.go b/go/approval/closed.go index 00310a6d..bfd7460f 100644 --- a/go/approval/closed.go +++ b/go/approval/closed.go @@ -46,7 +46,7 @@ func (g *Gate) stateFailed(err error) { g.mu.Unlock() if first { g.d.Logf("[approval] 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 g.d.Bounds != nil && !g.d.Holding { 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("[approval] the bound of the state %s's failure is not kept: %v", StateName, e) } @@ -63,7 +63,7 @@ func (g *Gate) stateRead() { if !was { return } - if g.d.Bounds != nil { + if g.d.Bounds != nil && !g.d.Holding { _ = g.d.Bounds.Delete(g.unreadableKey()) } g.finding(StateName, KindApprovalsUnreadable, false, "the state "+StateName+" is read again", "") diff --git a/go/approval/gate.go b/go/approval/gate.go index 2f6d9cf2..16ac2efc 100644 --- a/go/approval/gate.go +++ b/go/approval/gate.go @@ -47,7 +47,8 @@ type Deps struct { Channel Channel // 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 is the runner's key-ttl state bound (A-157, A-158), or a process holder's state approvals itself (A-255); 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) diff --git a/go/approval/held.go b/go/approval/held.go index 3c0b0ff1..aef5927a 100644 --- a/go/approval/held.go +++ b/go/approval/held.go @@ -127,7 +127,7 @@ func (g *Gate) Hold(c Call, wait bool, run func() (any, error)) (any, *Held, err if why := g.held(); why != "" { return nil, nil, g.refused(c, "", why) } - act, err := ActOf(c, g.d.Node) + act, err := ActOf(c, "") // a process holder's act names no node, so two instances raise one ask (A-255) if err != nil { return nil, nil, g.refused(c, "", err.Error()) } @@ -423,7 +423,7 @@ func (g *Gate) anew(w WaitingCall) { return } w.Ask, w.Asked = id, g.d.Now().UTC() - g.armUnanswered(w) + g.rearmUnanswered(w) g.d.Go(func() { g.Decided(id) }) } @@ -454,6 +454,13 @@ func (g *Gate) say(id string) { if json.Unmarshal(e.Value, &w) != nil || w.End == nil || w.Said { return } + // Its bound key still kept says gate-unanswered was not raised, and the key goes; one the bus ended is cleared once the + // end is said (A-255). + b, err := g.d.State.Get(unansweredHeldKey(id)) + raised := err == nil && b == nil + if b != nil && g.d.Bounds != nil { + _ = g.d.Bounds.Delete(unansweredHeldKey(id)) + } if g.d.Say == nil { g.d.Logf("[approval] the end %s of call %s is not said: the holder says no gated-ended", w.End.Outcome, id) return @@ -464,7 +471,7 @@ func (g *Gate) say(id string) { } g.d.Logf("[approval] %s by %s ended %s (call %s, ask %s)", w.Address, w.Caller, w.End.Outcome, id, w.Ask) _ = g.updateCall(id, func(k *WaitingCall) { k.Said = true }) - if g.d.Now().Sub(w.Asked) >= AskLasts && g.d.Findings != nil { + if raised && g.d.Findings != nil { _ = g.d.Findings.Clear(nilCtx(), KindGateUnanswered, id, "the call's end, "+w.End.Outcome+", is said") } } @@ -529,26 +536,61 @@ func (g *Gate) calls() ([]WaitingCall, error) { return out, nil } -// armUnanswered raises the waiting call's gate-unanswered when its ask's 60 minutes pass with no end of it (§677). +// unansweredHeldKey is a waiting call's bound key in the holder's state approvals (lead answer A-255): created with the +// ask's 60 minutes as its own time to live, its expiry heard as the bus's event raises gate-unanswered. +func unansweredHeldKey(call string) string { return "unanswered." + call } + +// armUnanswered keeps the waiting call's bound key (§677; A-255): when its ask's 60 minutes pass with no end of it, the bus +// ends the key and the holder, hearing that, raises gate-unanswered; no clock waits in the process. A call already past its +// 60 minutes (its key ended while the holder was away) is judged at once. func (g *Gate) armUnanswered(w WaitingCall) { - left := max(w.Asked.Add(AskLasts).Sub(g.d.Now()), 0) - at := g.d.After(left) - go func() { // its own goroutine whatever Go is: it waits out the ask's 60 minutes on the clock's pause - <-at - e, err := g.d.State.Get(callKey(w.ID)) - if err != nil || e == nil { - return - } - var now WaitingCall - if json.Unmarshal(e.Value, &now) != nil || now.End != nil || now.Ask != w.Ask || g.d.Findings == nil { - return - } - if err := g.d.Findings.Raise(nilCtx(), KindGateUnanswered, w.ID, fmt.Sprintf("the call %s of %s by %s waits on ask %s, "+ - "past its %s with no decided heard: nothing ran; the holder's %s.approvals reads it", w.ID, w.Address, w.Caller, w.Ask, - AskLasts, w.Seat), "a call waits on an unanswered ask"); err != nil { - g.d.Logf("[approval] the finding %s of call %s is not kept: %v", KindGateUnanswered, w.ID, err) - } - }() + left := w.Asked.Add(AskLasts).Sub(g.d.Now()) + if left <= 0 { + g.heldUnanswered(w.ID) + return + } + if g.d.Bounds == nil { + g.d.Logf("[approval] the call %s waits on ask %s with no bound kept: the holder's %s keeps no key with its own time to live", + w.ID, w.Ask, StateName) + return + } + if err := g.d.Bounds.CreateTTL(unansweredHeldKey(w.ID), map[string]string{"call": w.ID, "ask": w.Ask}, left); err != nil && !isChanged(err) { + g.d.Logf("[approval] the bound of call %s on ask %s is not kept: %v", w.ID, w.Ask, err) + } +} + +// rearmUnanswered keeps a call asked anew under a new bound of its new ask's 60 minutes. +func (g *Gate) rearmUnanswered(w WaitingCall) { + if g.d.Bounds != nil { + _ = g.d.Bounds.Delete(unansweredHeldKey(w.ID)) + } + g.armUnanswered(w) +} + +// heldUnanswered is a waiting call's bound ended (A-255): gate-unanswered raised while the call has no end. +func (g *Gate) heldUnanswered(id string) { + e, err := g.d.State.Get(callKey(id)) + if err != nil || e == nil { + return + } + var w WaitingCall + if json.Unmarshal(e.Value, &w) != nil || w.End != nil || g.d.Findings == nil { + return + } + if err := g.d.Findings.Raise(nilCtx(), KindGateUnanswered, w.ID, fmt.Sprintf("the call %s of %s by %s waits on ask %s, "+ + "past its %s with no decided heard: nothing ran; the holder's %s.approvals reads it", w.ID, w.Address, w.Caller, w.Ask, + AskLasts, w.Seat), "a call waits on an unanswered ask"); err != nil { + g.d.Logf("[approval] the finding %s of call %s is not kept: %v", KindGateUnanswered, w.ID, err) + } +} + +// watchHeld hears the waiting calls' bound keys end (A-255) for the life of the holder's connection. +func (g *Gate) watchHeld() error { + if g.d.Bounds == nil { + return nil + } + _, err := g.d.Bounds.Watch("unanswered.*", func(key string) { g.heldUnanswered(strings.TrimPrefix(key, "unanswered.")) }) + return err } // StartHolding is the holder's read at its start (§677): every waiting call is read, each ask already decided acted on as @@ -561,6 +603,9 @@ func (g *Gate) StartHolding() error { return fmt.Errorf("the waiting calls in %s cannot be read: %w", StateName, err) } g.stateRead() + if err := g.watchHeld(); err != nil { + return fmt.Errorf("the bound keys in %s cannot be watched: %w", StateName, err) + } asks := map[string]bool{} for _, w := range all { switch { diff --git a/go/approval/held_test.go b/go/approval/held_test.go index 81cd9c81..e57779c1 100644 --- a/go/approval/held_test.go +++ b/go/approval/held_test.go @@ -80,6 +80,56 @@ func (m *memStore) Keys() ([]string, error) { return out, nil } +// bounds is the holder's bound keys in its state approvals as a fake (A-255): each key kept in the state with its time to +// live, expire ending it as the bus does and handing it to the watch. +type bounds struct { + state *memStore + mu sync.Mutex + ttl map[string]time.Duration + watched func(string) +} + +func (b *bounds) CreateTTL(key string, value any, ttl time.Duration) error { + if _, err := b.state.Create(key, value); err != nil { + return err + } + b.mu.Lock() + defer b.mu.Unlock() + b.ttl[key] = ttl + return nil +} +func (b *bounds) Delete(key string) error { + b.state.delete(key) + b.mu.Lock() + defer b.mu.Unlock() + delete(b.ttl, key) + return nil +} +func (b *bounds) Watch(pattern string, expired func(string)) (func(), error) { + b.mu.Lock() + defer b.mu.Unlock() + b.watched = expired + return func() {}, nil +} +func (b *bounds) expire(key string) { + b.mu.Lock() + _, ok := b.ttl[key] + delete(b.ttl, key) + w := b.watched + b.mu.Unlock() + b.state.delete(key) + if ok && w != nil { + w(key) + } +} + +func (m *memStore) delete(key string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.vals, key) + delete(m.revs, key) +} + // router is the operator channel as a fake: it keeps each ask the gate raises, and the test decides its record. type router struct { mu sync.Mutex @@ -172,9 +222,10 @@ func TestAHolderHoldsAMarkedCall(t *testing.T) { recs *int finds *findings after chan time.Time + bounds *bounds } - newGate := func(state *memStore, r *router, ran, recs *int, said map[string]GatedEnded, after chan time.Time, f *findings) *Gate { - g := New(Deps{Node: "n1", Runner: "holder", Holding: true, Build: "fp1", State: state, Channel: r, Findings: f, + newGate := func(state *memStore, r *router, ran, recs *int, said map[string]GatedEnded, after chan time.Time, f *findings, b *bounds) *Gate { + g := New(Deps{Node: "n1", Runner: "holder", Holding: true, Build: "fp1", State: state, Channel: r, Findings: f, Bounds: b, Now: func() time.Time { return now }, Go: func(f func()) { f() }, After: func(d time.Duration) <-chan time.Time { if d < time.Minute { // the record's short pauses pass at once @@ -200,8 +251,8 @@ func TestAHolderHoldsAMarkedCall(t *testing.T) { r := &router{state: state, records: map[string]*Record{}} ran, recs := 0, 0 w := world{state: state, router: r, said: map[string]GatedEnded{}, ran: &ran, recs: &recs, - finds: &findings{raised: map[string]bool{}}, after: make(chan time.Time)} - w.g = newGate(state, r, w.ran, w.recs, w.said, w.after, w.finds) + finds: &findings{raised: map[string]bool{}}, after: make(chan time.Time), bounds: &bounds{state: state, ttl: map[string]time.Duration{}}} + w.g = newGate(state, r, w.ran, w.recs, w.said, w.after, w.finds, w.bounds) return w } call := func(caller, args string) Call { @@ -285,7 +336,7 @@ func TestAHolderHoldsAMarkedCall(t *testing.T) { if _, h, err := hold(t, w, call("n1.a", `{"token":"ghp_0123456789abcdefghijklmnopqrstuvwxyzAB"}`), true); err == nil || h != nil { t.Fatalf("held %+v, %v", h, err) } - if keys, _ := w.state.Keys(); len(keys) != 0 || len(w.router.asks) != 0 { + if keys, _ := w.state.Keys(); len(keys) != 0 || len(w.router.asks) != 0 || len(w.bounds.ttl) != 0 { t.Errorf("kept %v, asked %d", keys, len(w.router.asks)) } }) @@ -305,7 +356,7 @@ func TestAHolderHoldsAMarkedCall(t *testing.T) { w := setup() _, h, _ := hold(t, w, call("n1.a", `{}`), true) w.router.choose(h.Asked, OptionApprove, now) - again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds) + again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds, w.bounds) if err := again.StartHolding(); err != nil || *w.ran != 1 || ended(w, "n1.a", h.Call).Outcome != OutcomeRan { t.Fatalf("start: %v, ran %d", err, *w.ran) } @@ -314,13 +365,68 @@ func TestAHolderHoldsAMarkedCall(t *testing.T) { t.Errorf("ran %d after a second start", *w.ran) } }) - t.Run("past its ask's 60 minutes with no decided, gate-unanswered", func(t *testing.T) { + t.Run("past its ask's 60 minutes with no decided, gate-unanswered on its bound key's end", func(t *testing.T) { + // A-255: a bound key with the ask's 60 minutes to live in the state approvals, never a clock wait. w := setup() - w.finds.heard = make(chan string, 1) + if err := w.g.StartHolding(); err != nil { + t.Fatal(err) + } _, h, _ := hold(t, w, call("n1.a", `{}`), true) - w.after <- now - if got := <-w.finds.heard; got != KindGateUnanswered+" "+h.Call { - t.Errorf("raised %s", got) + key := "unanswered." + h.Call + if ttl := w.bounds.ttl[key]; ttl != AskLasts { + t.Fatalf("bound %s kept for %s (%v)", key, ttl, w.bounds.ttl) + } + if w.finds.raised[KindGateUnanswered+" "+h.Call] { + t.Fatal("raised before its bound ended") + } + w.bounds.expire(key) + if !w.finds.raised[KindGateUnanswered+" "+h.Call] { + t.Errorf("not raised on its bound's end: %v", w.finds.raised) + } + w.router.choose(h.Asked, OptionDecline, now) + w.g.Decided(h.Asked) + if w.finds.raised[KindGateUnanswered+" "+h.Call] { + t.Error("not cleared once its end is said") + } + }) + t.Run("a call whose bound ended while the holder was away is judged at its start", func(t *testing.T) { + w := setup() + _, h, _ := hold(t, w, call("n1.a", `{}`), true) + w.bounds.watched = nil + w.bounds.expire("unanswered." + h.Call) // the bus ended it with nobody watching + now = now.Add(AskLasts + time.Minute) + defer func() { now = now.Add(-AskLasts - time.Minute) }() + again := newGate(w.state, w.router, w.ran, w.recs, w.said, w.after, w.finds, w.bounds) + if err := again.StartHolding(); err != nil || !w.finds.raised[KindGateUnanswered+" "+h.Call] { + t.Errorf("start: %v, raised %v", err, w.finds.raised) + } + }) + t.Run("two instances of the holder raise one ask and run once", func(t *testing.T) { + // A-255: a process holder's act names no node. + w := setup() + other := New(Deps{Node: "n2", Runner: "holder", Holding: true, Build: "fp1", State: w.state, Channel: w.router, + Findings: w.finds, Bounds: w.bounds, Now: func() time.Time { return now }, Go: func(f func()) { f() }, + Say: func(subject string, body any, id string) error { w.said[subject] = body.(GatedEnded); return nil }}) + other.Serves("wiper", "wipe", func(string, json.RawMessage) (any, error) { *w.ran++; return "wiped", nil }) + _, h1, _ := hold(t, w, call("n1.a", `{}`), true) + _, h2, err := other.Hold(call("n2.b", `{}`), true, nil) + if err != nil || h2 == nil || h2.Asked != h1.Asked || len(w.router.asks) != 1 { + t.Fatalf("second instance: %+v, %v; asks %d", h2, err, len(w.router.asks)) + } + if strings.Contains(w.router.asks[0].About, "n1") { + t.Errorf("the ask binds %s", w.router.asks[0].About) + } + var first WaitingCall + e, _ := w.state.Get("call." + h1.Call) + _ = json.Unmarshal(e.Value, &first) + if _, ok := first.Act["node"]; ok { + t.Errorf("the act names a node: %v", first.Act) + } + w.router.choose(h1.Asked, OptionApprove, now) + w.g.Decided(h1.Asked) + other.Decided(h1.Asked) + if *w.ran != 1 { + t.Errorf("ran %d", *w.ran) } }) t.Run("a state that cannot be read refuses every marked call", func(t *testing.T) { diff --git a/go/approval/record.go b/go/approval/record.go index 97dc8a44..aaf74be0 100644 --- a/go/approval/record.go +++ b/go/approval/record.go @@ -62,7 +62,7 @@ func (g *Gate) ran(c Call, id string, rec *Record, answer any, err error) (any, // 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 { + if g.d.Bounds == nil || g.d.Holding { // a process holder's bound keys are its waiting calls' alone (A-255) return } for key, ttl := range map[string]time.Duration{g.unansweredKey(id): UnansweredAfter, g.unrecordedKey(id): KeptFor - SaidBefore} { diff --git a/go/process/gate.go b/go/process/gate.go index f4c372fb..3d6e5733 100644 --- a/go/process/gate.go +++ b/go/process/gate.go @@ -36,8 +36,9 @@ func (c *Conn) Gate(g Gating) *approval.Gate { if build == "" { build = OwnBuild().Fingerprint } + state := c.State(approval.StateName) return approval.New(approval.Deps{Node: c.account.Node, Runner: c.account.Module, Holding: true, Build: build, - State: c.State(approval.StateName), Channel: g.Channel, Findings: g.Findings, Logf: g.Logf, + State: state, Bounds: heldBounds{state}, Channel: g.Channel, Findings: g.Findings, Logf: g.Logf, Warranted: func(asker, ask string, open func() (bool, error)) error { _, err := c.CallKeptWhileOpen(busretry.Open{Key: ask, Open: func(string) (bool, error) { return open() }, Logf: g.Logf}, "seat:mesh-hand-acts.warranted", map[string]string{"asker": asker, "ask": ask}) @@ -48,6 +49,45 @@ func (c *Conn) Gate(g Gating) *approval.Gate { }}) } +// heldBounds is the holder's bound keys in its own state approvals (lead answer A-255): a key with its own time to live, +// whose end the bus says as a marker that a watch hears, so a waiting call's 60 minutes need no clock in the process. +type heldBounds struct{ s State } + +func (b heldBounds) CreateTTL(key string, value any, ttl time.Duration) error { + _, err := b.s.CreateTTL(key, value, ttl) + return err +} + +func (b heldBounds) Delete(key string) error { return b.s.Delete(key) } + +// Watch hands expired each key under pattern whose own time to live ended, heard from now on: the bus's marker of a time +// to live is a purge, which this state's own writes never make (a delete is not one). +func (b heldBounds) Watch(pattern string, expired func(key string)) (func(), error) { + ctx, cancel := context.WithCancel(context.Background()) + bind, stop := context.WithTimeout(ctx, stateTimeout) + kv, err := b.s.store(bind) + stop() + if err != nil { + cancel() + return nil, err + } + w, err := kv.Watch(ctx, watchPattern(pattern), jetstream.UpdatesOnly()) + if err != nil { + cancel() + return nil, fmt.Errorf("%s's state %s, watching %q: %w", b.s.Module, b.s.Name, pattern, err) + } + go func() { + defer cancel() + defer func() { _ = w.Stop() }() + for e := range w.Updates() { + if e != nil && e.Operation() == jetstream.KeyValuePurge { + expired(e.Key()) + } + } + }() + return cancel, nil +} + // HearDecided hands the gate each `decided` of the holder's own asks that hub hears (§677): only the ask's id is read // from it, the gate reading the router's own record. func HearDecided(g *approval.Gate, ch approval.Channel, module string, hub *EventHub) error { diff --git a/go/process/gated_integration_test.go b/go/process/gated_integration_test.go index 5ddad2dc..9a41f58b 100644 --- a/go/process/gated_integration_test.go +++ b/go/process/gated_integration_test.go @@ -13,6 +13,7 @@ import ( "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go/jetstream" + stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/approval" "git.novox.be/novox/mesh-sdk/go/operatorask" "git.novox.be/novox/mesh-sdk/go/seatdef" @@ -98,7 +99,8 @@ func TestAProcessHoldersMarkedVerbIsAskedApprovedRunAndRecorded(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "holder_" + approval.StateName, TTL: 48 * time.Hour}); err != nil { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "holder_" + approval.StateName, TTL: 48 * time.Hour, + LimitMarkerTTL: time.Minute}); err != nil { t.Fatal(err) } if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: EventsStream, Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.operator-channel.event.>"}}); err != nil { @@ -238,6 +240,54 @@ func TestAProcessHoldersMarkedVerbIsAskedApprovedRunAndRecorded(t *testing.T) { t.Errorf("ran %d", ran) } }) + t.Run("a waiting call's bound key is kept with its time to live, and its end is heard", func(t *testing.T) { + // A-255: gate-unanswered rests on a bound key in the state approvals, never a clock wait. + state := holder.State(approval.StateName) + kept := make(chan string, 1) + if err := state.Watch("unanswered.*", func(c stdio.StateChange) error { + if c.Op == stdio.OpPut && !c.Current { + kept <- c.Key + } + return nil + }); err != nil { + t.Fatal(err) + } + done := callWipe(map[string]string{"dir": "bound"}) + a := asked() + var bound string + select { + case bound = <-kept: + case <-time.After(10 * time.Second): + t.Fatal("no bound key kept within 10s") + } + decide(a, approval.OptionDecline) + wait(done) + if e, _ := state.Get(bound); e != nil { + t.Errorf("the bound %s of an ended call is kept", bound) + } + heard := make(chan string, 2) + stop, err := heldBounds{state}.Watch("unanswered.*", func(k string) { heard <- k }) + if err != nil { + t.Fatal(err) + } + defer stop() + b := heldBounds{state} + if err := b.CreateTTL("unanswered.gone", map[string]string{}, MinTTL); err != nil { + t.Fatal(err) + } + if err := b.CreateTTL("unanswered.deleted", map[string]string{}, time.Hour); err != nil { + t.Fatal(err) + } + _ = b.Delete("unanswered.deleted") + select { + case k := <-heard: + if k != "unanswered.gone" { + t.Errorf("heard %s", k) + } + case <-time.After(10 * time.Second): + t.Fatal("the bound key's end was not heard within 10s") + } + }) t.Run("failing closed: no gate, or no state approvals", func(t *testing.T) { if _, err := holder.AnswerSeat("bare", marked("bare"), wipe); err != nil { t.Fatal(err) From 7f100fc74ad583a196fac744ea5dd2266f317342 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:53:40 +0100 Subject: [PATCH 12/12] A3g item 2a: a definition with a marked verb is read with gated-ended, approvals and the verb by caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seatdef.Resolve, which the seat generator and every reader of a definition go through, makes each marked verb by-caller and adds the event gated-ended (gated-ended.., kept last per subject one day) and the verb approvals (class read); the seat files stay as written. approvals is the gate's verb, so CanHold asks no holder to name it. Builds: design 47 G §677, §697; lead answers A-264, A-265, A-266 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/approval/held.go | 5 ++- go/catalogue/environment_into_test.go | 5 ++- go/catalogue/login_shell_execute_test.go | 6 ++- go/catalogue/verbs.go | 6 ++- go/process/serve.go | 4 +- go/seatdef/seatdef.go | 55 +++++++++++++++++++++++- go/seatdef/seatdef_test.go | 51 ++++++++++++++++++++++ 7 files changed, 121 insertions(+), 11 deletions(-) diff --git a/go/approval/held.go b/go/approval/held.go index aef5927a..f27fa1a2 100644 --- a/go/approval/held.go +++ b/go/approval/held.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/operatorask" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // A marked verb a module's process serves is one call with a gate before its running (design 47 G §677): the holder keeps @@ -32,8 +33,8 @@ type Held struct { // GatedEndedEvent is the seat's event a call's end is said on, kept last per subject for GatedEndedKeptFor (§677). const ( - GatedEndedEvent = "gated-ended" - GatedEndedKeptFor = 24 * time.Hour + GatedEndedEvent = seatdef.GatedEndedEvent + GatedEndedKeptFor = seatdef.GatedEndedKeptDays * 24 * time.Hour ) // The outcomes of a held call (§677). diff --git a/go/catalogue/environment_into_test.go b/go/catalogue/environment_into_test.go index b3bdbb63..08900074 100644 --- a/go/catalogue/environment_into_test.go +++ b/go/catalogue/environment_into_test.go @@ -35,8 +35,9 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" { t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok) } - if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" { - t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves) + // A-264, A-265: its marked execute gains the verb approvals beside it. + if len(shell.Serves) != 2 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" || shell.Serves[1].Name != "approvals" { + t.Fatalf("the login shell serves %+v, not execute and approvals", shell.Serves) } props, _ := shell.Serves[0].Input["properties"].(map[string]any) required, _ := shell.Serves[0].Input["required"].([]string) diff --git a/go/catalogue/login_shell_execute_test.go b/go/catalogue/login_shell_execute_test.go index 822476a0..0b0c7b16 100644 --- a/go/catalogue/login_shell_execute_test.go +++ b/go/catalogue/login_shell_execute_test.go @@ -14,12 +14,14 @@ func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) { if !ok { t.Fatal("node-login-shell is not defined") } - if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" { - t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves) + // A-264, A-265: its marked execute gains the verb approvals beside it. + if len(seat.Serves) != 2 || seat.Serves[0].Name != "execute" || seat.Serves[1].Name != "approvals" { + t.Fatalf("the login shell promises %+v, not execute and approvals", seat.Serves) } if !seat.Serves[0].Optional { t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there") } + // A-266: no holder names the gate's verb approvals, so zsh's claim holds as it is written. withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}} if err := CanHold(withholding, seat); err != nil { t.Fatalf("a holder serving no execute is refused: %v", err) diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 550020e1..841dad46 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of @@ -135,7 +137,9 @@ func unservedVerbs(tools []string, promised []Verb) []string { } var missing []string for _, v := range promised { - if !has[v.Name] && !v.Optional { + // The verb approvals is the gate's, served wherever a marked verb is (the seat server or the tool runner), so no + // holder names it (design 47 G lead answer A-266). + if !has[v.Name] && !v.Optional && v.Name != seatdef.ApprovalsVerb { missing = append(missing, v.Name) } } diff --git a/go/process/serve.go b/go/process/serve.go index 03be2886..79fe1392 100644 --- a/go/process/serve.go +++ b/go/process/serve.go @@ -59,13 +59,13 @@ func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bo if len(verbs) == 0 { return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat) } - if o.gate != nil && verbs["approvals"] == nil { + if o.gate != nil && verbs[seatdef.ApprovalsVerb] == nil { all := make(map[string]SeatHandler, len(verbs)+1) for v, h := range verbs { all[v] = h } gate := o.gate - all["approvals"] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) } + all[seatdef.ApprovalsVerb] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) } verbs = all } names := make([]string, 0, len(verbs)) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 34d75038..da9a86df 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -881,7 +881,7 @@ func checkMessages(field string, ms []Message, whole bool) Problems { // or a name the part and the definition both define, is refused. func Resolve(d Definition, parts map[string]Part) (Definition, error) { if len(d.Includes) == 0 { - return d, nil + return gated(d), nil } out := d out.Includes = nil @@ -916,7 +916,58 @@ func Resolve(d Definition, parts map[string]Part) (Definition, error) { if len(ps) > 0 { return Definition{}, ps } - return out, nil + return gated(out), nil +} + +// What a definition with a marked verb gains (design 47 G §677, §697; lead answer A-264). +const ( + // GatedEndedEvent is the event a process holder says a held call's end on, subject + // `gated-ended..`, kept last per subject for GatedEndedKeptDays. + GatedEndedEvent = "gated-ended" + // GatedEndedKeptDays is how long gated-ended keeps each call's end. + GatedEndedKeptDays = 1 + // ApprovalsVerb is the verb that reads a holder's waiting calls, `act.` keys and claims, served by the seat server. + ApprovalsVerb = "approvals" +) + +// gated is d as every reader reads it (A-264): when a verb of it needs approval, each marked verb is by-caller, and it +// gains the event gated-ended and the verb approvals (class read) where it does not name them itself; the seat's files +// stay as written. A definition with no marked verb is d. +func gated(d Definition) Definition { + marked := false + for _, v := range d.Verbs { + marked = marked || v.NeedsApproval + } + if !marked { + return d + } + verbs := make([]Verb, 0, len(d.Verbs)+1) + for _, v := range d.Verbs { + if v.NeedsApproval { + v.ByCaller = true + } + verbs = append(verbs, v) + } + if !slices.Contains(VerbNames(verbs), ApprovalsVerb) { + verbs = append(verbs, Verb{Name: ApprovalsVerb, Class: ClassRead, ByCaller: true, + Description: "The holder's calls of its marked verbs: each waiting or ended call with its caller, ask and state, " + + "the act keys and the claims; a call's arguments only to that call's caller.", + Input: map[string]any{"type": "object"}, + Answer: map[string]any{"type": "object", "required": []any{"calls", "acts", "claims"}, "properties": map[string]any{ + "calls": map[string]any{"type": "array"}, "acts": map[string]any{"type": "array"}, "claims": map[string]any{"type": "array"}}}}) + } + d.Verbs = verbs + if !slices.Contains(MessageNames(d.Events), GatedEndedEvent) { + days := GatedEndedKeptDays + events := append([]Message{}, d.Events...) + d.Events = append(events, Message{Name: GatedEndedEvent, Subject: GatedEndedEvent + "..", + Keep: &Keep{MaxAgeDays: &days, LastPerSubject: true, Object: true}, + Body: map[string]any{"type": "object", "required": []any{"call", "ask", "act", "verb", "outcome"}, "properties": map[string]any{ + "call": map[string]any{"type": "string"}, "ask": map[string]any{"type": "string"}, "act": map[string]any{"type": "string"}, + "verb": map[string]any{"type": "string"}, "outcome": map[string]any{"enum": []any{"ran", "declined", "expired", "refused"}}, + "answer": map[string]any{}, "approval": map[string]any{"type": "string"}, "error": map[string]any{"type": "string"}}}}) + } + return d } // EventSubject is where the seat's event is published; by a caller, the caller's name follows. diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 63b89451..61890a4e 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -1,8 +1,10 @@ package seatdef import ( + "encoding/json" "errors" "fmt" + "slices" "strings" "testing" ) @@ -205,6 +207,55 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Errorf("a type defined twice: %v", err) } + // A-264 (design 47 G §677, §697): a definition with a marked verb is read with that verb by-caller, the event + // gated-ended and the verb approvals, through a part or not; its files stay as written, and one with none is as written. + marked, err := Parse([]byte(strings.Replace(whole, `"name":"list",`, `"name":"list","needs-approval":true,`, 1))) + if err != nil { + t.Fatal(err) + } + if VerbNames(marked.Verbs)[0] != "list" || len(marked.Verbs) != 1 || marked.Verbs[0].ByCaller { + t.Fatalf("parsed %+v", marked.Verbs) + } + for _, includes := range [][]string{nil, {"registry"}} { + marked.Includes = includes + got, err := Resolve(marked, map[string]Part{"registry": part}) + if err != nil { + t.Fatalf("includes %v: %v", includes, err) + } + var list, approvals *Verb + for i := range got.Verbs { + switch got.Verbs[i].Name { + case "list": + list = &got.Verbs[i] + case ApprovalsVerb: + approvals = &got.Verbs[i] + } + } + var ended *Message + for i := range got.Events { + if got.Events[i].Name == GatedEndedEvent { + ended = &got.Events[i] + } + } + if list == nil || !list.ByCaller || approvals == nil || approvals.Class != ClassRead || !approvals.ByCaller || ended == nil || + ended.SubjectPattern() != "gated-ended.." || !ended.KeptLastPerSubject() || ended.KeptAgeDays() != 1 { + t.Errorf("includes %v: verbs %+v, events %+v", includes, got.Verbs, got.Events) + } + if ps := append(checkVerbs(got.Verbs, true), checkMessages("events", got.Events, true)...); len(ps) > 0 { + t.Errorf("includes %v: what is added is refused: %v", includes, ps) + } + if again, _ := Resolve(got, nil); len(again.Verbs) != len(got.Verbs) || len(again.Events) != len(got.Events) { + t.Errorf("resolved twice: %d verbs, %d events", len(again.Verbs), len(again.Events)) + } + raw, _ := json.Marshal(got) + if back, err := Parse(raw); err != nil || len(back.Events) != len(got.Events) { + t.Errorf("includes %v: the resolved definition does not read back: %v", includes, err) + } + } + if plain, _ := Resolve(d, map[string]Part{"registry": part}); slices.Contains(VerbNames(plain.Verbs), ApprovalsVerb) { + t.Errorf("a definition with no marked verb gains %v", VerbNames(plain.Verbs)) + } + // Fixtures name what they are an instance of. if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created", "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil {