From 7f100fc74ad583a196fac744ea5dd2266f317342 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:53:40 +0100 Subject: [PATCH] A3g item 2a: a definition with a marked verb is read with gated-ended, approvals and the verb by caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seatdef.Resolve, which the seat generator and every reader of a definition go through, makes each marked verb by-caller and adds the event gated-ended (gated-ended.., kept last per subject one day) and the verb approvals (class read); the seat files stay as written. approvals is the gate's verb, so CanHold asks no holder to name it. Builds: design 47 G §677, §697; lead answers A-264, A-265, A-266 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- go/approval/held.go | 5 ++- go/catalogue/environment_into_test.go | 5 ++- go/catalogue/login_shell_execute_test.go | 6 ++- go/catalogue/verbs.go | 6 ++- go/process/serve.go | 4 +- go/seatdef/seatdef.go | 55 +++++++++++++++++++++++- go/seatdef/seatdef_test.go | 51 ++++++++++++++++++++++ 7 files changed, 121 insertions(+), 11 deletions(-) diff --git a/go/approval/held.go b/go/approval/held.go index aef5927a..f27fa1a2 100644 --- a/go/approval/held.go +++ b/go/approval/held.go @@ -10,6 +10,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/operatorask" + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // A marked verb a module's process serves is one call with a gate before its running (design 47 G §677): the holder keeps @@ -32,8 +33,8 @@ type Held struct { // GatedEndedEvent is the seat's event a call's end is said on, kept last per subject for GatedEndedKeptFor (§677). const ( - GatedEndedEvent = "gated-ended" - GatedEndedKeptFor = 24 * time.Hour + GatedEndedEvent = seatdef.GatedEndedEvent + GatedEndedKeptFor = seatdef.GatedEndedKeptDays * 24 * time.Hour ) // The outcomes of a held call (§677). diff --git a/go/catalogue/environment_into_test.go b/go/catalogue/environment_into_test.go index b3bdbb63..08900074 100644 --- a/go/catalogue/environment_into_test.go +++ b/go/catalogue/environment_into_test.go @@ -35,8 +35,9 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" { t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok) } - if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" { - t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves) + // A-264, A-265: its marked execute gains the verb approvals beside it. + if len(shell.Serves) != 2 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" || shell.Serves[1].Name != "approvals" { + t.Fatalf("the login shell serves %+v, not execute and approvals", shell.Serves) } props, _ := shell.Serves[0].Input["properties"].(map[string]any) required, _ := shell.Serves[0].Input["required"].([]string) diff --git a/go/catalogue/login_shell_execute_test.go b/go/catalogue/login_shell_execute_test.go index 822476a0..0b0c7b16 100644 --- a/go/catalogue/login_shell_execute_test.go +++ b/go/catalogue/login_shell_execute_test.go @@ -14,12 +14,14 @@ func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) { if !ok { t.Fatal("node-login-shell is not defined") } - if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" { - t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves) + // A-264, A-265: its marked execute gains the verb approvals beside it. + if len(seat.Serves) != 2 || seat.Serves[0].Name != "execute" || seat.Serves[1].Name != "approvals" { + t.Fatalf("the login shell promises %+v, not execute and approvals", seat.Serves) } if !seat.Serves[0].Optional { t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there") } + // A-266: no holder names the gate's verb approvals, so zsh's claim holds as it is written. withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}} if err := CanHold(withholding, seat); err != nil { t.Fatalf("a holder serving no execute is refused: %v", err) diff --git a/go/catalogue/verbs.go b/go/catalogue/verbs.go index 550020e1..841dad46 100644 --- a/go/catalogue/verbs.go +++ b/go/catalogue/verbs.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + + "git.novox.be/novox/mesh-sdk/go/seatdef" ) // A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of @@ -135,7 +137,9 @@ func unservedVerbs(tools []string, promised []Verb) []string { } var missing []string for _, v := range promised { - if !has[v.Name] && !v.Optional { + // The verb approvals is the gate's, served wherever a marked verb is (the seat server or the tool runner), so no + // holder names it (design 47 G lead answer A-266). + if !has[v.Name] && !v.Optional && v.Name != seatdef.ApprovalsVerb { missing = append(missing, v.Name) } } diff --git a/go/process/serve.go b/go/process/serve.go index 03be2886..79fe1392 100644 --- a/go/process/serve.go +++ b/go/process/serve.go @@ -59,13 +59,13 @@ func (c *Conn) AnswerSeat(seat string, definition func() (seatdef.Definition, bo if len(verbs) == 0 { return nil, fmt.Errorf("AnswerSeat(%q) answers no verb", seat) } - if o.gate != nil && verbs["approvals"] == nil { + if o.gate != nil && verbs[seatdef.ApprovalsVerb] == nil { all := make(map[string]SeatHandler, len(verbs)+1) for v, h := range verbs { all[v] = h } gate := o.gate - all["approvals"] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) } + all[seatdef.ApprovalsVerb] = func(caller string, _ json.RawMessage) (any, error) { return gate.Approvals(caller) } verbs = all } names := make([]string, 0, len(verbs)) diff --git a/go/seatdef/seatdef.go b/go/seatdef/seatdef.go index 34d75038..da9a86df 100644 --- a/go/seatdef/seatdef.go +++ b/go/seatdef/seatdef.go @@ -881,7 +881,7 @@ func checkMessages(field string, ms []Message, whole bool) Problems { // or a name the part and the definition both define, is refused. func Resolve(d Definition, parts map[string]Part) (Definition, error) { if len(d.Includes) == 0 { - return d, nil + return gated(d), nil } out := d out.Includes = nil @@ -916,7 +916,58 @@ func Resolve(d Definition, parts map[string]Part) (Definition, error) { if len(ps) > 0 { return Definition{}, ps } - return out, nil + return gated(out), nil +} + +// What a definition with a marked verb gains (design 47 G §677, §697; lead answer A-264). +const ( + // GatedEndedEvent is the event a process holder says a held call's end on, subject + // `gated-ended..`, kept last per subject for GatedEndedKeptDays. + GatedEndedEvent = "gated-ended" + // GatedEndedKeptDays is how long gated-ended keeps each call's end. + GatedEndedKeptDays = 1 + // ApprovalsVerb is the verb that reads a holder's waiting calls, `act.` keys and claims, served by the seat server. + ApprovalsVerb = "approvals" +) + +// gated is d as every reader reads it (A-264): when a verb of it needs approval, each marked verb is by-caller, and it +// gains the event gated-ended and the verb approvals (class read) where it does not name them itself; the seat's files +// stay as written. A definition with no marked verb is d. +func gated(d Definition) Definition { + marked := false + for _, v := range d.Verbs { + marked = marked || v.NeedsApproval + } + if !marked { + return d + } + verbs := make([]Verb, 0, len(d.Verbs)+1) + for _, v := range d.Verbs { + if v.NeedsApproval { + v.ByCaller = true + } + verbs = append(verbs, v) + } + if !slices.Contains(VerbNames(verbs), ApprovalsVerb) { + verbs = append(verbs, Verb{Name: ApprovalsVerb, Class: ClassRead, ByCaller: true, + Description: "The holder's calls of its marked verbs: each waiting or ended call with its caller, ask and state, " + + "the act keys and the claims; a call's arguments only to that call's caller.", + Input: map[string]any{"type": "object"}, + Answer: map[string]any{"type": "object", "required": []any{"calls", "acts", "claims"}, "properties": map[string]any{ + "calls": map[string]any{"type": "array"}, "acts": map[string]any{"type": "array"}, "claims": map[string]any{"type": "array"}}}}) + } + d.Verbs = verbs + if !slices.Contains(MessageNames(d.Events), GatedEndedEvent) { + days := GatedEndedKeptDays + events := append([]Message{}, d.Events...) + d.Events = append(events, Message{Name: GatedEndedEvent, Subject: GatedEndedEvent + "..", + Keep: &Keep{MaxAgeDays: &days, LastPerSubject: true, Object: true}, + Body: map[string]any{"type": "object", "required": []any{"call", "ask", "act", "verb", "outcome"}, "properties": map[string]any{ + "call": map[string]any{"type": "string"}, "ask": map[string]any{"type": "string"}, "act": map[string]any{"type": "string"}, + "verb": map[string]any{"type": "string"}, "outcome": map[string]any{"enum": []any{"ran", "declined", "expired", "refused"}}, + "answer": map[string]any{}, "approval": map[string]any{"type": "string"}, "error": map[string]any{"type": "string"}}}}) + } + return d } // EventSubject is where the seat's event is published; by a caller, the caller's name follows. diff --git a/go/seatdef/seatdef_test.go b/go/seatdef/seatdef_test.go index 63b89451..61890a4e 100644 --- a/go/seatdef/seatdef_test.go +++ b/go/seatdef/seatdef_test.go @@ -1,8 +1,10 @@ package seatdef import ( + "encoding/json" "errors" "fmt" + "slices" "strings" "testing" ) @@ -205,6 +207,55 @@ func TestADefinitionIsReadStrictlyNamingWhatIsWrong(t *testing.T) { t.Errorf("a type defined twice: %v", err) } + // A-264 (design 47 G §677, §697): a definition with a marked verb is read with that verb by-caller, the event + // gated-ended and the verb approvals, through a part or not; its files stay as written, and one with none is as written. + marked, err := Parse([]byte(strings.Replace(whole, `"name":"list",`, `"name":"list","needs-approval":true,`, 1))) + if err != nil { + t.Fatal(err) + } + if VerbNames(marked.Verbs)[0] != "list" || len(marked.Verbs) != 1 || marked.Verbs[0].ByCaller { + t.Fatalf("parsed %+v", marked.Verbs) + } + for _, includes := range [][]string{nil, {"registry"}} { + marked.Includes = includes + got, err := Resolve(marked, map[string]Part{"registry": part}) + if err != nil { + t.Fatalf("includes %v: %v", includes, err) + } + var list, approvals *Verb + for i := range got.Verbs { + switch got.Verbs[i].Name { + case "list": + list = &got.Verbs[i] + case ApprovalsVerb: + approvals = &got.Verbs[i] + } + } + var ended *Message + for i := range got.Events { + if got.Events[i].Name == GatedEndedEvent { + ended = &got.Events[i] + } + } + if list == nil || !list.ByCaller || approvals == nil || approvals.Class != ClassRead || !approvals.ByCaller || ended == nil || + ended.SubjectPattern() != "gated-ended.." || !ended.KeptLastPerSubject() || ended.KeptAgeDays() != 1 { + t.Errorf("includes %v: verbs %+v, events %+v", includes, got.Verbs, got.Events) + } + if ps := append(checkVerbs(got.Verbs, true), checkMessages("events", got.Events, true)...); len(ps) > 0 { + t.Errorf("includes %v: what is added is refused: %v", includes, ps) + } + if again, _ := Resolve(got, nil); len(again.Verbs) != len(got.Verbs) || len(again.Events) != len(got.Events) { + t.Errorf("resolved twice: %d verbs, %d events", len(again.Verbs), len(again.Events)) + } + raw, _ := json.Marshal(got) + if back, err := Parse(raw); err != nil || len(back.Events) != len(got.Events) { + t.Errorf("includes %v: the resolved definition does not read back: %v", includes, err) + } + } + if plain, _ := Resolve(d, map[string]Part{"registry": part}); slices.Contains(VerbNames(plain.Verbs), ApprovalsVerb) { + t.Errorf("a definition with no marked verb gains %v", VerbNames(plain.Verbs)) + } + // Fixtures name what they are an instance of. if _, err := ParseFixtures([]byte(`[{"name":"a","of":"verb:list.input","given":{}},{"name":"b","of":"event:created", "subject":"mesh.seat.mesh-broker.event.created","given":{}}]`)); err != nil {