Files
mesh-tools/node-tools/internal/approval/act.go
T
jschoubben e0cfcabe60 A-3 judge and claim: the same call again runs once on the router's record of its Approve
The gate sits on both handlers the runner serves, a module's tools (its ledger repairs
among them) and the seat verbs its modules hold, before the bundle. The same call again
is judged on the router's own record of its act's newest ask: held to the warrant
(operatorchannelclient.For), the option and the act it bound (Performs), within 30 minutes
of the choice, and claimed by compare-and-set (Client.Take) before it runs, so one
approval runs one call. A later call is told it still waits, was declined (not asked again
for 10 minutes), or asks anew after expired, approved too long ago or already run. A
runner's own tool marked is refused unasked; with no state approvals bound a marked call is
refused and an unmarked one runs. The gate's reach (state, publish, the router's record)
is the runner's own connection, in one place; it waits for the catalog half's first view
on the events the runner hears.

Builds: design 46 7-approval-mark §13, §14, §15, §17, §20

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 05:13:18 +01:00

294 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package approval
import (
"bytes"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"unicode/utf8"
"git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-sdk/go/outward"
)
// Call is one call the runner serves, as the gate judges it: a module's tool, or a seat verb its module serves.
type Call struct {
// Address is the address as called: `<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`, `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) (operatorchannelclient.Act, error) {
act := operatorchannelclient.Act{"address": c.Address, "node": node, "build": c.Fingerprint}
if c.Seat == "" {
act["module"], act["tool"] = c.Module, c.Name
} else {
act["seat"], act["verb"] = c.Seat, c.Name
}
args, err := arguments(c.Args)
if err != nil {
return nil, err
}
for name, raw := range args {
var s string
if json.Unmarshal(raw, &s) == nil {
act["arg."+name] = s
continue
}
canon, err := Canonical(raw)
if err != nil {
return nil, fmt.Errorf("the argument %s cannot be put in canonical form: %v", name, err)
}
act["arg."+name] = canon
}
return act, nil
}
// Canonical is a JSON value in RFC 8785's canonical form: no white space, an object's members sorted by their names' UTF-16
// code units, a number as ECMAScript writes a double, a string with only the escapes the RFC names.
func Canonical(raw json.RawMessage) (string, error) {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return "", err
}
var b strings.Builder
if err := canonicalValue(&b, v); err != nil {
return "", err
}
return b.String(), nil
}
func canonicalValue(b *strings.Builder, v any) error {
switch x := v.(type) {
case nil:
b.WriteString("null")
case bool:
b.WriteString(strconv.FormatBool(x))
case json.Number:
f, err := strconv.ParseFloat(string(x), 64)
if err != nil || math.IsInf(f, 0) || math.IsNaN(f) {
return fmt.Errorf("the number %s is not a double", x)
}
b.WriteString(es6Number(f))
case string:
canonicalString(b, x)
case []any:
b.WriteByte('[')
for i, e := range x {
if i > 0 {
b.WriteByte(',')
}
if err := canonicalValue(b, e); err != nil {
return err
}
}
b.WriteByte(']')
case map[string]any:
names := make([]string, 0, len(x))
for k := range x {
names = append(names, k)
}
sort.Slice(names, func(i, j int) bool { return utf16Less(names[i], names[j]) })
b.WriteByte('{')
for i, k := range names {
if i > 0 {
b.WriteByte(',')
}
canonicalString(b, k)
b.WriteByte(':')
if err := canonicalValue(b, x[k]); err != nil {
return err
}
}
b.WriteByte('}')
default:
return fmt.Errorf("a value of type %T", v)
}
return nil
}
// es6Number is a double as ECMAScript's Number.prototype.toString writes it (RFC 8785 §3.2.2.3).
func es6Number(f float64) string {
if f == 0 {
return "0"
}
abs := math.Abs(f)
if abs >= 1e-6 && abs < 1e21 {
return strconv.FormatFloat(f, 'f', -1, 64)
}
s := strconv.FormatFloat(f, 'e', -1, 64) // 1e+21, 1.5e-07
mant, exp, _ := strings.Cut(s, "e")
sign, digits := exp[:1], strings.TrimLeft(exp[1:], "0")
return mant + "e" + sign + digits
}
func canonicalString(b *strings.Builder, s string) {
b.WriteByte('"')
for _, r := range s {
switch {
case r == '"':
b.WriteString(`\"`)
case r == '\\':
b.WriteString(`\\`)
case r == '\b':
b.WriteString(`\b`)
case r == '\f':
b.WriteString(`\f`)
case r == '\n':
b.WriteString(`\n`)
case r == '\r':
b.WriteString(`\r`)
case r == '\t':
b.WriteString(`\t`)
case r < 0x20:
fmt.Fprintf(b, `\u%04x`, r)
default:
b.WriteRune(r)
}
}
b.WriteByte('"')
}
func utf16Less(a, b string) bool {
ua, ub := utf16Of(a), utf16Of(b)
for i := 0; i < len(ua) && i < len(ub); i++ {
if ua[i] != ub[i] {
return ua[i] < ub[i]
}
}
return len(ua) < len(ub)
}
func utf16Of(s string) []uint16 {
out := make([]uint16, 0, utf8.RuneCountInString(s))
for _, r := range s {
if r >= 0x10000 {
r -= 0x10000
out = append(out, uint16(0xD800+(r>>10)), uint16(0xDC00+(r&0x3FF)))
} else {
out = append(out, uint16(r))
}
}
return out
}
// Fingerprint is how an act is shown: its digest's first 24 hexadecimal digits in groups of four (§10).
func Fingerprint(digest string) string {
hex := strings.TrimPrefix(digest, "sha256:")
if len(hex) > 24 {
hex = hex[:24]
}
var groups []string
for len(hex) > 4 {
groups, hex = append(groups, hex[:4]), hex[4:]
}
return strings.Join(append(groups, hex), " ")
}
// The bounds of the arguments shown in an ask (§11).
const (
ShownBytes, ShownLines = 2000, 40
PartBytes, PartLines = 1200, 20
)
// shown is a call's arguments as an ask shows them (§11), one per line as "name: value", and whether all were shown whole:
// an argument whole where outward.Check passes it, else its shape, ‹path›, ‹address› or ‹withheld›; the whole when it is at
// most ShownBytes and ShownLines, else its first PartLines lines and PartBytes bytes, marked NOT SHOWN IN FULL.
func shown(raw json.RawMessage) (string, bool) {
args, _ := arguments(raw)
names := make([]string, 0, len(args))
for n := range args {
names = append(names, n)
}
sort.Strings(names)
whole := true
var lines []string
for _, n := range names {
var value string
if json.Unmarshal(args[n], &value) != nil {
value = string(args[n])
}
if r, ok := outward.Check(value); !ok {
value, whole = shapeOf(r), false
}
lines = append(lines, n+": "+value)
}
text := strings.Join(lines, "\n")
if all := strings.Split(text, "\n"); len(text) > ShownBytes || len(all) > ShownLines {
if len(all) > PartLines {
all = all[:PartLines]
}
text = strings.Join(all, "\n")
if len(text) > PartBytes {
text = cutBytes(text, PartBytes)
}
text += "\nNOT SHOWN IN FULL"
whole = false
}
return text, whole
}
// shapeOf is what an argument outward.Check refuses is shown as.
func shapeOf(r outward.Refusal) string {
switch r.Class {
case "path":
return "‹path›"
case "address":
return "‹address›"
}
return "‹withheld›"
}
// cutBytes is s at most n bytes, cut at a character's end.
func cutBytes(s string, n int) string {
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n]
}