node-tools: a tool call names its caller, and the module is told it from the subject (hq issue 365)
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery-group group fix/365-a-tool-call-names-its-caller delivering: 0 of 4 delivered
mesh/delivery superseded: a newer head of the same pull request

Each tool and seat verb is also served on its caller-named subject, kind call
with the caller's bus user last, which the controller grants each user with its
own name alone; the runtime hands the module that caller in the call's _meta,
never from the body. Calls through the runtime (the console and modules'
invokes) ask the caller-named subject first and the old one only when the first
reached nobody: nothing serves it yet, or the bus refused it because the grant
is not delivered yet, which ends the call at once rather than after 30 s. The
old subjects stay served and say no caller for one release (hq issue 464).
This commit is contained in:
2026-10-11 05:19:08 +02:00
parent 6a6ed725c0
commit ab0f888d67
9 changed files with 642 additions and 13 deletions
+187
View File
@@ -0,0 +1,187 @@
package bus
import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"sync"
"github.com/nats-io/nats.go"
)
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
//
// mesh.mod.<module>.call.<tool>.<caller> to whichever instance answers
// mesh.mod.<module>.call.<tool>.<node>.<caller> to the instance on one machine
// mesh.seat.<seat>.call.<verb>.<caller> a mesh seat's verb
// mesh.seat.<seat>.call.<verb>.<node>.<caller> a node seat's verb on one machine
//
// The last token is the bus user that published the call, and the controller grants each user the publish of
// these subjects with its own name there and no other — so the caller is a fact the bus server enforces, and
// the runtime hands it to the module from the subject a call arrived on, never from the payload.
//
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
// wildcard over the `tool` kind — `mesh.mod.<m>.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>`
// for every tool — and either matches a `tool` subject with any caller appended, so a caller could name
// another. Under `call` no grant but the caller-named ones reaches the caller token.
//
// The `tool` subjects are served beside these for one release, and say Unproven, so callers move without a
// gap; their retirement is hq issue 464.
const CallKind = "call"
// A bus user's name carries dots (`<node>.<module>`, `person.<name>`); a subject token cannot. In the token
// each dot is written `~`, which no part of a bus user's name may hold (the controller refuses any part that
// is not letters, digits, `_` or `-`), so the token is the user's name and nothing else.
const userDot = "~"
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// CallerToken is a bus user's name as the last token of a call it publishes, or "" for a name that is not a
// bus user's.
func CallerToken(user string) string {
if !userName.MatchString(user) {
return ""
}
return strings.ReplaceAll(user, ".", userDot)
}
// UserOfToken is the bus user a caller token names, or "" when it names none.
func UserOfToken(token string) string {
user := strings.ReplaceAll(token, userDot, ".")
if !userName.MatchString(user) {
return ""
}
return user
}
// toolShape splits a tool subject — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>` — into what
// comes before the kind and what after; ok is false for any other subject.
func toolShape(subject string) (head, rest string, ok bool) {
parts := strings.SplitN(subject, ".", 5)
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
parts[2] == "" || parts[4] == "" {
return "", "", false
}
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
}
// CalledSubject is where a call to a tool subject goes naming its caller: the same address under the `call`
// kind, the caller's token last. "" for a subject that is not a tool's, or a caller that is not a bus user.
func CalledSubject(subject, user string) string {
head, rest, ok := toolShape(subject)
token := CallerToken(user)
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + "." + token
}
// calledPattern is what a runtime answering a tool subject also subscribes, to hear the calls that name their
// caller: the same address under the `call` kind, with any caller. "" for a subject that is not a tool's.
func calledPattern(subject string) string {
head, rest, ok := toolShape(subject)
if !ok || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + ".*"
}
// callerOn is the bus user a call names, from the subject it arrived on: its last token, under a pattern
// calledPattern made. "" — said by the module as Unproven — for a call on the subject that names no caller.
func callerOn(pattern, subject string) string {
if !strings.HasSuffix(pattern, ".*") {
return ""
}
dot := strings.LastIndex(subject, ".")
if dot < 0 || subject[:dot] != strings.TrimSuffix(pattern, ".*") {
return ""
}
return UserOfToken(subject[dot+1:])
}
// CalledHandler answers one call, told who called: the bus user the subject named, or "" when it named none.
type CalledHandler func(caller string, body json.RawMessage) (any, error)
// callers is what a connection keeps of its caller-named calls: those in flight, by subject, so a refused
// publish ends its call at once instead of after RequestTimeout.
type callers struct {
mu sync.Mutex
next uint64
pending map[string]map[uint64]context.CancelCauseFunc
}
// errCallRefused is the bus refusing a caller-named publish: this user is not granted it yet (the controller's
// grant not delivered to the bus), so the call is asked again on the subject that names no caller.
var errCallRefused = errors.New("the bus refused the caller-named subject")
var refusedPublish = regexp.MustCompile(`(?i)Publish to "(\S+)"`)
// publishRefused ends every call in flight on a subject the bus refused this connection the publish of.
func (c *Conn) publishRefused(err error) bool {
if !errors.Is(err, nats.ErrPermissionViolation) {
return false
}
m := refusedPublish.FindStringSubmatch(err.Error())
if len(m) < 2 {
return false
}
c.calls.mu.Lock()
cancels := c.calls.pending[m[1]]
delete(c.calls.pending, m[1])
c.calls.mu.Unlock()
if len(cancels) > 0 {
c.Logf("[mesh-tools] the bus refused this account the caller-named %s; asked again on the subject that "+
"names no caller, until the mesh grants it (novox/hq issue 365)", m[1])
}
for _, cancel := range cancels {
cancel(errCallRefused)
}
return len(cancels) > 0
}
// askCalled asks once on a caller-named subject. fallBack says the call reached nobody — nothing serves that
// subject yet, or the bus refused this user its publish — so it may be asked on the subject that names no
// caller without being done twice.
func (c *Conn) askCalled(subject string, data []byte) (answered Answered, handingOver, fallBack bool, err error) {
ctx, cancel := context.WithCancelCause(context.Background())
ctx, stop := context.WithTimeout(ctx, RequestTimeout)
defer stop()
c.calls.mu.Lock()
if c.calls.pending == nil {
c.calls.pending = map[string]map[uint64]context.CancelCauseFunc{}
}
if c.calls.pending[subject] == nil {
c.calls.pending[subject] = map[uint64]context.CancelCauseFunc{}
}
c.calls.next++
id := c.calls.next
c.calls.pending[subject][id] = cancel
c.calls.mu.Unlock()
defer func() {
c.calls.mu.Lock()
if list := c.calls.pending[subject]; list != nil {
delete(list, id)
if len(list) == 0 {
delete(c.calls.pending, subject)
}
}
c.calls.mu.Unlock()
cancel(nil)
}()
msg, err := c.nc.RequestWithContext(ctx, subject, data)
if err != nil {
switch {
case errors.Is(context.Cause(ctx), errCallRefused):
return Answered{}, false, true, nil
case errors.Is(err, nats.ErrNoResponders):
return Answered{}, false, true, nil
case errors.Is(err, context.DeadlineExceeded) || errors.Is(err, nats.ErrTimeout):
return Answered{}, false, false, errors.New("timeout")
}
return Answered{}, false, false, err
}
answered, handingOver, err = c.answerOf(subject, msg.Data)
return answered, handingOver, false, err
}