From e802e67f9ab77bcd15f9142701209b9838c8d14a Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:32:20 +0100 Subject: [PATCH] =?UTF-8?q?ADR=200627=20=C2=A72:=20a=20holder's=20caller?= =?UTF-8?q?=20list=20per=20verb=20and=20its=20question=20to=20the=20builde?= =?UTF-8?q?r=20are=20shared,=20not=20copied=20per=20holder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- go/caller_test.go | 26 ++++++++++++++ go/callers.go | 64 +++++++++++++++++++++++++++++++++ go/delivery/builder_test.go | 17 +++++++++ go/delivery/builder_versions.go | 38 ++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 go/delivery/builder_versions.go diff --git a/go/caller_test.go b/go/caller_test.go index bac2a36..1759eec 100644 --- a/go/caller_test.go +++ b/go/caller_test.go @@ -122,6 +122,32 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) { if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil { t.Error("a list with a field it does not know was read") } + // A holder's list per verb (ADR 0627 §2): each verb's entries as above, plus every proved caller (AnyCaller) or + // every operator's session (AnyPerson); an unproven caller never, a verb the holder does not serve refused. + verbs, err := ParseVerbCallers(`{"publish":["mesh-build-go"],"versions":["*"],"release":["operator","sweep"]}`, + []string{"publish", "versions", "release", "remove"}, nil) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + verb, caller string + want bool + }{{"publish", "ace.mesh-build-go", true}, {"publish", "ace.mesh-build-node", false}, {"versions", "g14.anything", true}, + {"versions", Unproven, false}, {"release", "person.jo", true}, {"release", "ace.operator", false}, + {"release", "novox.sweep", true}, {"remove", "novox.sweep", false}} { + if got := verbs.Allowed(c.verb, c.caller) == nil; got != c.want { + t.Errorf("%s by %q allowed: %v, want %v", c.verb, c.caller, got, c.want) + } + } + if _, err := ParseVerbCallers(`{"mirror":["x"]}`, []string{"publish"}, nil); err == nil { + t.Error("a list naming a verb the holder does not serve was read") + } + if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 { + t.Error("an empty value is not the holder's defaults") + } + if ModuleOf("ace.mesh-build-go") != "mesh-build-go" { + t.Error("ModuleOf does not read the module of a proved caller") + } } // A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is diff --git a/go/callers.go b/go/callers.go index 74a7ce3..67a8a38 100644 --- a/go/callers.go +++ b/go/callers.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "slices" "strings" ) @@ -46,3 +47,66 @@ func (c Callers) Allows(caller string) bool { } return false } + +// VerbCallers is a holder's trusted list per verb (novox/hq ADR 0627 §2, design 51 *Who may call which verb*): for each +// verb of the seats it holds, the callers that may call it, each entry read as Callers reads it. Two entries more stand +// for a whole class: AnyCaller lets every proved caller call the verb (a read the mesh keeps open), AnyPerson every +// operator's session the runtime proved as person.. It names no seat and no verb: the holder gives its verbs and +// its defaults, and keeps any rule of its own seat (who may never be on a list) beside it. +type VerbCallers map[string][]string + +// The entries of a VerbCallers list that stand for a class of callers. +const ( + AnyCaller = "*" + AnyPerson = "operator" +) + +// ParseVerbCallers reads a holder's config value of verb to callers; an empty value is defaults. A verb not among verbs +// is refused, so a list that names a verb the holder does not serve is said at once. +func ParseVerbCallers(raw string, verbs []string, defaults VerbCallers) (VerbCallers, error) { + if strings.TrimSpace(raw) == "" { + return defaults, nil + } + var c VerbCallers + if err := json.Unmarshal([]byte(raw), &c); err != nil { + return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err) + } + for verb := range c { + if !slices.Contains(verbs, verb) { + return nil, fmt.Errorf("callers names the verb %q, which the holder does not serve", verb) + } + } + return c, nil +} + +// Allowed answers nil when caller, as the runtime proved it, may call verb; else the refusal naming both. An unproven +// caller is never allowed, not even by AnyCaller. +func (c VerbCallers) Allowed(verb, caller string) error { + if caller == "" || caller == Unproven { + return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb) + } + person := strings.HasPrefix(caller, "person.") + for _, entry := range c[verb] { + switch entry { + case AnyCaller: + return nil + case AnyPerson: + if person { + return nil + } + default: + if (Callers{entry}).Allows(caller) { + return nil + } + } + } + return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb) +} + +// ModuleOf is the module of a proved module caller, which the runtime names .. +func ModuleOf(proved string) string { + if _, module, ok := strings.Cut(proved, "."); ok { + return module + } + return proved +} diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index 8e29474..7968c19 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -1,7 +1,9 @@ package delivery import ( + "encoding/json" "errors" + "fmt" "reflect" "strings" "testing" @@ -158,6 +160,21 @@ func TestBuilderBuildVersion(t *testing.T) { t.Errorf("DecodeBuilderBuild(%s) = %v", in, err) } } + // A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest; + // an answer without recorded is an error, never a yes. + for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest", + `{"name":"x"}`: "error", `not json`: "error"} { + b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) { + if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" { + t.Errorf("asked %s %v", addr, body) + } + return json.RawMessage(answer), nil + }} + ok, why, err := b.Recorded("n", "0.1.2", "sha256:d") + if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want { + t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want) + } + } } // 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which diff --git a/go/delivery/builder_versions.go b/go/delivery/builder_versions.go new file mode 100644 index 0000000..86e5c10 --- /dev/null +++ b/go/delivery/builder_versions.go @@ -0,0 +1,38 @@ +package delivery + +import ( + "encoding/json" + "fmt" +) + +// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder +// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here +// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again. +// +// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's +// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder. +const BuilderVersionsAddress = BuilderModule + ".builder_versions" + +// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does +// not depend on the runtime. +type AskFunc func(address string, body any) (json.RawMessage, error) + +// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check: +// recorded, and why not when it is not. +type BuilderVersions struct{ Ask AskFunc } + +// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes. +func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) { + raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest}) + if err != nil { + return false, "", err + } + var a struct { + Recorded *bool `json:"recorded"` + Why string `json:"why"` + } + if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil { + return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw) + } + return *a.Recorded, a.Why, nil +}