A3g item 2a: a definition with a marked verb is read with gated-ended, approvals and the verb by caller

seatdef.Resolve, which the seat generator and every reader of a definition go
through, makes each marked verb by-caller and adds the event gated-ended
(gated-ended.<caller>.<call>, kept last per subject one day) and the verb
approvals (class read); the seat files stay as written. approvals is the
gate's verb, so CanHold asks no holder to name it.

Builds: design 47 G §677, §697; lead answers A-264, A-265, A-266

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 09:53:40 +01:00
parent f8062e2680
commit 7f100fc74a
7 changed files with 121 additions and 11 deletions
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/operatorask"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// A marked verb a module's process serves is one call with a gate before its running (design 47 G §677): the holder keeps
@@ -32,8 +33,8 @@ type Held struct {
// GatedEndedEvent is the seat's event a call's end is said on, kept last per subject for GatedEndedKeptFor (§677).
const (
GatedEndedEvent = "gated-ended"
GatedEndedKeptFor = 24 * time.Hour
GatedEndedEvent = seatdef.GatedEndedEvent
GatedEndedKeptFor = seatdef.GatedEndedKeptDays * 24 * time.Hour
)
// The outcomes of a held call (§677).
+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)
+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)
+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)
}
}
+2 -2
View File
@@ -59,13 +59,13 @@ func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bo
if len(verbs) == 0 {
return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat)
}
if o.gate != nil && verbs["approvals"] == nil {
if o.gate != nil && verbs[seatdef.ApprovalsVerb] == nil {
all := make(map[string]SeatHandler, len(verbs)+1)
for v, h := range verbs {
all[v] = h
}
gate := o.gate
all["approvals"] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) }
all[seatdef.ApprovalsVerb] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) }
verbs = all
}
names := make([]string, 0, len(verbs))
+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.
+51
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 {