Merge branch 'feat/47-G-fix11-a3g' into feat/47-G-join11

# Conflicts:
#	go/catalogue/manifest.go
This commit is contained in:
2026-11-12 10:24:52 +01:00
43 changed files with 4336 additions and 92 deletions
+296
View File
@@ -0,0 +1,296 @@
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: `<module>.<tool>` or `<seat>.<verb>`.
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` (none given for a process holder, lead answer A-255), `module` and `tool` or `seat` and `verb`, `build`, and
// `arg.<name>` 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, "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 {
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]
}
+80
View File
@@ -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)
}
}
+239
View File
@@ -0,0 +1,239 @@
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.prefix() + "act." + keys.EscapeName(digest) }
// prefix is what the gate's keys begin with: the tool runner's machine, `<node>.`, 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.prefix() + "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
}
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 {
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,
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 "<verb> <names> on <node>?", "<verb> <names>?" 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)
}
+115
View File
@@ -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 `<seat>.<verb>`, 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)
}
+100
View File
@@ -0,0 +1,100 @@
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 && 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()
}
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 && !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)
}
}
}
}
// 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.Holding {
_ = 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)
}
+178
View File
@@ -0,0 +1,178 @@
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), 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)
// 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())
// 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
// 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
// handlers are a process holder's marked verbs' handlers, by `<seat>.<verb>` (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
}
// 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 `<node>.<what>` 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)
}
}
+699
View File
@@ -0,0 +1,699 @@
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"
"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
// 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 = seatdef.GatedEndedEvent
GatedEndedKeptFor = seatdef.GatedEndedKeptDays * 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.<seat>.event.gated-ended.<caller>.<call>`, 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.<id>` (§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, "") // 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())
}
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.<digest>` 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.<ask id>` 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.rearmUnanswered(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
}
// 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
}
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 raised && 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
}
// 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 := 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
// 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()
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 {
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() }
+439
View File
@@ -0,0 +1,439 @@
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
}
// 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
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
bounds *bounds
}
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
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), 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 {
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 || len(w.bounds.ttl) != 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, 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)
}
_ = 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 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()
if err := w.g.StartHolding(); err != nil {
t.Fatal(err)
}
_, h, _ := hold(t, w, call("n1.a", `{}`), true)
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) {
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)
}
})
}
+172
View File
@@ -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 `<seat>.<verb>`, 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
}
+255
View File
@@ -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 || 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} {
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.prefix() + "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, `<node>.<what>` 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) }
+176
View File
@@ -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()
}
+172
View File
@@ -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)
}
+290
View File
@@ -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 <names> on <node>?", 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"), ".")
}
+106
View File
@@ -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 `<emitter>.<event>`, 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.<ns>.<emitter>.event.<rest>` 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 <seat or module>.<event>", 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.<emitter>.event.<rest>`,
// `mesh.mod.<emitter>.event.<rest>` or `mesh.stream.<emitter>.event.<rest>` it answers the name `<emitter>.<rest>` 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
}
}
}
+98
View File
@@ -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)
}
}
}
+14
View File
@@ -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
+10
View File
@@ -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 {
+2 -2
View File
@@ -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).
+7 -2
View File
@@ -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, `<node>.>`; 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 }
-3
View File
@@ -22,9 +22,6 @@ type Declared struct {
Watches []Seat
// Invokes are the tools it calls, `<module>.<tool>` 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).
-18
View File
@@ -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, `<emitter>.<event>`: 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
}
+3 -2
View File
@@ -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)
+5
View File
@@ -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)
+4 -2
View File
@@ -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)
+1 -8
View File
@@ -592,13 +592,6 @@ type Manifest struct {
// changes. A long-form entry naming no field is a verb of Invokes alone.
InvokeFields map[string][]string `json:"-"`
// Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each
// `<emitter>.<event>` 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.
@@ -817,7 +810,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"`
+3 -2
View File
@@ -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)
+5 -1
View File
@@ -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)
}
}
-17
View File
@@ -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)
}
}
+2 -9
View File
@@ -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"`
@@ -112,7 +110,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-<node>`): 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.
@@ -174,12 +173,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
+51
View File
@@ -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:<hex>". 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')
}
+34
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+12 -1
View File
@@ -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
}
+14 -4
View File
@@ -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)
+35 -2
View File
@@ -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,39 @@ 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 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 := 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)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
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
}
state := c.State(approval.StateName)
return approval.New(approval.Deps{Node: c.account.Node, Runner: c.account.Module, Holding: true, Build: build,
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})
return err
},
Say: func(subject string, body any, id string) error {
return c.emitOn(subject, body, []stdio.EmitOptions{{MessageID: id}})
}})
}
// 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 {
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.<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 != ""
}
+311
View File
@@ -0,0 +1,311 @@
//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"
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"
)
// 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,
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 {
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("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)
}
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)
}
})
}
+87 -4
View File
@@ -9,7 +9,9 @@ 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"
)
// SeatHandler answers one call of a seat's verb: caller is the bus user the subject the call arrived on names
@@ -35,10 +37,37 @@ 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.
//
// 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[seatdef.ApprovalsVerb] == nil {
all := make(map[string]SeatHandler, len(verbs)+1)
for v, h := range verbs {
all[v] = h
}
gate := o.gate
all[seatdef.ApprovalsVerb] = 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 {
@@ -58,7 +87,11 @@ func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func(
}
}
for _, verb := range names {
h := 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
@@ -77,8 +110,53 @@ func (c *Conn) AnswerSeat(seat string, verbs map[string]SeatHandler) (stop func(
return undo, nil
}
// 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, nil, fmt.Errorf("%s.%s takes no unproven caller: call it on its subject that names its caller, "+
"mesh.seat.%s.call.%s.<caller>", seat, verb, seat, verb)
}
def, read := definition()
if !read {
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 {
if v.Name != verb {
continue
}
if len(v.EffectiveCallers()) == 0 {
return nil, nil, fmt.Errorf("%s.%s is open to no caller", seat, verb)
}
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, 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:])
@@ -87,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:
+5 -7
View File
@@ -101,15 +101,13 @@ func CallerEntryProblem(e string) string {
"holder:<seat>@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}
}
+53 -2
View File
@@ -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.<caller>.<call>`, 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 + ".<caller>.<call>",
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.
+56 -4
View File
@@ -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.<caller>.<call>" || !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 {
@@ -290,7 +341,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 +349,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)
+28 -1
View File
@@ -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 {
+9
View File
@@ -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 {